-
Notifications
You must be signed in to change notification settings - Fork 54
feat: add camera presets for 3D snapshots and validation #2187
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| /** | ||
| * Camera presets for 3D snapshot rendering. | ||
| * | ||
| * Each preset is a function that takes the default camera result from | ||
| * `getBestCameraPosition` and returns modified camera options for | ||
| * the desired viewpoint. | ||
| * | ||
| * Coordinate system (GLTF / circuit-json-to-gltf): | ||
| * Y = up (perpendicular to PCB) | ||
| * X, Z = PCB plane | ||
| * camPos/lookAt use negated X (camPos = [-camX, camY, camZ]) | ||
| */ | ||
|
|
||
| type CameraResult = { | ||
| camPos: readonly [number, number, number] | ||
| lookAt: readonly [number, number, number] | ||
| fov: number | ||
| } | ||
|
|
||
| function distance( | ||
| a: readonly [number, number, number], | ||
| b: readonly [number, number, number], | ||
| ): number { | ||
| return Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) | ||
| } | ||
|
|
||
| /** | ||
| * Place camera along a unit direction from lookAt at the same distance | ||
| * as the default camera. | ||
| */ | ||
| function repositionCamera( | ||
| cam: CameraResult, | ||
| dir: [number, number, number], | ||
| ): CameraResult { | ||
| const dist = distance(cam.camPos, cam.lookAt) | ||
| const len = Math.sqrt(dir[0] ** 2 + dir[1] ** 2 + dir[2] ** 2) | ||
| const nx = dir[0] / len | ||
| const ny = dir[1] / len | ||
| const nz = dir[2] / len | ||
| return { | ||
| camPos: [ | ||
| cam.lookAt[0] + nx * dist, | ||
| cam.lookAt[1] + ny * dist, | ||
| cam.lookAt[2] + nz * dist, | ||
| ] as const, | ||
| lookAt: cam.lookAt, | ||
| fov: cam.fov, | ||
| } | ||
| } | ||
|
|
||
| export const CAMERA_PRESETS = { | ||
| /** Directly above the board looking straight down */ | ||
| "top-down": (cam: CameraResult): CameraResult => | ||
| repositionCamera(cam, [0.001, 1, -0.001]), | ||
|
|
||
| /** Angled view from top-left corner */ | ||
| "top-left-corner": (cam: CameraResult): CameraResult => | ||
| repositionCamera(cam, [0.7, 1.2, -0.8]), | ||
|
|
||
| /** From the left side, angled from above */ | ||
| "top-left": (cam: CameraResult): CameraResult => | ||
| repositionCamera(cam, [1, 1.2, 0]), | ||
|
|
||
| /** Angled view from top-right corner */ | ||
| "top-right-corner": (cam: CameraResult): CameraResult => | ||
| repositionCamera(cam, [-0.7, 1.2, -0.8]), | ||
|
|
||
| /** From the right side, angled from above */ | ||
| "top-right": (cam: CameraResult): CameraResult => | ||
| repositionCamera(cam, [-1, 1.2, 0]), | ||
|
|
||
| /** Side view from the left (eye level) */ | ||
| "left-sideview": (cam: CameraResult): CameraResult => | ||
| repositionCamera(cam, [1, 0.05, 0]), | ||
|
|
||
| /** Side view from the right (eye level) */ | ||
| "right-sideview": (cam: CameraResult): CameraResult => | ||
| repositionCamera(cam, [-1, 0.05, 0]), | ||
|
|
||
| /** Front view (eye level) */ | ||
| front: (cam: CameraResult): CameraResult => | ||
| repositionCamera(cam, [0, 0.05, -1]), | ||
|
|
||
| /** Top-center with a moderate angle (the default 3D view) */ | ||
| "top-center-angled": (cam: CameraResult): CameraResult => | ||
| repositionCamera(cam, [0, 1, -1]), | ||
| } as const satisfies Record<string, (cam: CameraResult) => CameraResult> | ||
|
|
||
| export type CameraPreset = keyof typeof CAMERA_PRESETS | ||
|
|
||
| export const CAMERA_PRESET_NAMES = Object.keys(CAMERA_PRESETS) as CameraPreset[] | ||
|
|
||
| /** | ||
| * Apply a camera preset to a default camera result from getBestCameraPosition. | ||
| * Throws if the preset is unknown. | ||
| */ | ||
| export function applyCameraPreset( | ||
| preset: string, | ||
| cam: CameraResult, | ||
| ): CameraResult { | ||
| if (!(preset in CAMERA_PRESETS)) { | ||
| throw new Error( | ||
| `Unknown camera preset "${preset}". Valid presets: ${CAMERA_PRESET_NAMES.join(", ")}`, | ||
| ) | ||
| } | ||
| return CAMERA_PRESETS[preset as CameraPreset](cam) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| import { test, expect } from "bun:test" | ||
| import { join } from "node:path" | ||
| import fs from "node:fs" | ||
| import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" | ||
| import { | ||
| CAMERA_PRESET_NAMES, | ||
| type CameraPreset, | ||
| } from "lib/shared/camera-presets" | ||
|
Comment on lines
+5
to
+8
|
||
|
|
||
| const BOARD_TSX = ` | ||
| export const TestBoard = () => ( | ||
| <board width="10mm" height="10mm"> | ||
| <chip name="U1" footprint="soic8" /> | ||
| </board> | ||
| ) | ||
| ` | ||
|
|
||
| /** Directory next to this test file where preset snapshots are saved */ | ||
| const SAVED_SNAPSHOTS_DIR = join(import.meta.dir, "__snapshots__") | ||
|
|
||
| for (const preset of CAMERA_PRESET_NAMES) { | ||
| test(`snapshot --camera-preset=${preset} creates 3D snapshot`, async () => { | ||
| const { tmpDir, runCommand } = await getCliTestFixture() | ||
|
|
||
| await Bun.write(join(tmpDir, "test.board.tsx"), BOARD_TSX) | ||
|
|
||
| const { stdout, exitCode } = await runCommand( | ||
| `tsci snapshot --update --camera-preset=${preset}`, | ||
| ) | ||
|
|
||
| expect(exitCode).toBe(0) | ||
| expect(stdout).toContain("Created snapshots") | ||
|
|
||
| // --camera-preset implies --3d, so 3D snapshot must exist | ||
| const snapshotDir = join(tmpDir, "__snapshots__") | ||
| expect(fs.existsSync(join(snapshotDir, "test.board-3d.snap.png"))).toBe( | ||
| true, | ||
| ) | ||
|
|
||
| // PCB and schematic snapshots should also be generated | ||
| expect(fs.existsSync(join(snapshotDir, "test.board-pcb.snap.svg"))).toBe( | ||
| true, | ||
| ) | ||
| expect( | ||
| fs.existsSync(join(snapshotDir, "test.board-schematic.snap.svg")), | ||
| ).toBe(true) | ||
|
|
||
| // The 3D snapshot should be a valid PNG (starts with PNG magic bytes) | ||
| const pngBuf = fs.readFileSync(join(snapshotDir, "test.board-3d.snap.png")) | ||
| expect(pngBuf.length).toBeGreaterThan(0) | ||
| // PNG magic: 0x89 P N G | ||
| expect(pngBuf[0]).toBe(0x89) | ||
| expect(pngBuf[1]).toBe(0x50) // P | ||
| expect(pngBuf[2]).toBe(0x4e) // N | ||
| expect(pngBuf[3]).toBe(0x47) // G | ||
|
|
||
| // Save the generated snapshot with a preset-specific name for inspection | ||
| fs.mkdirSync(SAVED_SNAPSHOTS_DIR, { recursive: true }) | ||
| fs.copyFileSync( | ||
| join(snapshotDir, "test.board-3d.snap.png"), | ||
| join(SAVED_SNAPSHOTS_DIR, `3d-${preset}.snap.png`), | ||
| ) | ||
|
Comment on lines
+57
to
+62
|
||
| }, 60_000) | ||
| } | ||
|
|
||
| test("snapshot --camera-preset with invalid preset fails", async () => { | ||
| const { tmpDir, runCommand } = await getCliTestFixture() | ||
|
|
||
| await Bun.write(join(tmpDir, "test.board.tsx"), BOARD_TSX) | ||
|
|
||
| const { stderr, exitCode } = await runCommand( | ||
| "tsci snapshot --update --camera-preset=bogus-preset", | ||
| ) | ||
|
|
||
| expect(exitCode).not.toBe(0) | ||
| expect(stderr).toContain('Unknown camera preset "bogus-preset"') | ||
| }, 30_000) | ||
|
|
||
| test("snapshot --camera-preset without --3d still generates 3D snapshot", async () => { | ||
| const { tmpDir, runCommand } = await getCliTestFixture() | ||
|
|
||
| await Bun.write(join(tmpDir, "test.board.tsx"), BOARD_TSX) | ||
|
|
||
| // No explicit --3d flag; --camera-preset should imply it | ||
| const { stdout, exitCode } = await runCommand( | ||
| "tsci snapshot --update --camera-preset=top-down", | ||
| ) | ||
|
|
||
| expect(exitCode).toBe(0) | ||
| expect(stdout).toContain("Created snapshots") | ||
|
|
||
| const snapshotDir = join(tmpDir, "__snapshots__") | ||
| expect(fs.existsSync(join(snapshotDir, "test.board-3d.snap.png"))).toBe(true) | ||
| }, 60_000) | ||
|
|
||
| test("different camera presets produce different 3D snapshots", async () => { | ||
| const { tmpDir, runCommand } = await getCliTestFixture() | ||
|
|
||
| await Bun.write(join(tmpDir, "test.board.tsx"), BOARD_TSX) | ||
|
|
||
| // Generate with top-down | ||
| await runCommand("tsci snapshot --update --camera-preset=top-down") | ||
| const snapshotDir = join(tmpDir, "__snapshots__") | ||
| const topDownPng = fs.readFileSync( | ||
| join(snapshotDir, "test.board-3d.snap.png"), | ||
| ) | ||
|
|
||
| // Generate with front (overwrites the snapshot) | ||
| await runCommand("tsci snapshot --update --camera-preset=front") | ||
| const frontPng = fs.readFileSync(join(snapshotDir, "test.board-3d.snap.png")) | ||
|
|
||
| // The two images should differ | ||
| expect(Buffer.compare(topDownPng, frontPng)).not.toBe(0) | ||
| }, 90_000) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
applyCameraPresetusespreset in CAMERA_PRESETSto validate. Theinoperator also returns true for properties on the prototype chain (e.g. "toString"), which would bypass the error and then attempt to call a non-preset value. Use an own-property check (e.g.Object.hasOwn/hasOwnProperty) to ensure unknown strings reliably throw.