diff --git a/cli/snapshot/register.ts b/cli/snapshot/register.ts index 71048535..2fa7ae5f 100644 --- a/cli/snapshot/register.ts +++ b/cli/snapshot/register.ts @@ -1,4 +1,8 @@ import type { Command } from "commander" +import { + CAMERA_PRESET_NAMES, + type CameraPreset, +} from "lib/shared/camera-presets" import { snapshotProject } from "lib/shared/snapshot-project" export const registerSnapshot = (program: Command) => { @@ -17,6 +21,10 @@ export const registerSnapshot = (program: Command) => { .option("--pcb-only", "Generate only PCB snapshots") .option("--schematic-only", "Generate only schematic snapshots") .option("--disable-parts-engine", "Disable the parts engine") + .option( + "--camera-preset ", + `Camera angle preset for 3D snapshots (implies --3d). Valid presets: ${CAMERA_PRESET_NAMES.join(", ")}`, + ) .option("--ci", "Enable CI mode with snapshot diff artifacts") .option("--test", "Enable test mode with snapshot diff artifacts") .action( @@ -29,10 +37,21 @@ export const registerSnapshot = (program: Command) => { schematicOnly?: boolean forceUpdate?: boolean disablePartsEngine?: boolean + cameraPreset?: string ci?: boolean test?: boolean }, ) => { + if ( + options.cameraPreset && + !CAMERA_PRESET_NAMES.includes(options.cameraPreset as CameraPreset) + ) { + console.error( + `Unknown camera preset "${options.cameraPreset}". Valid presets: ${CAMERA_PRESET_NAMES.join(", ")}`, + ) + process.exit(1) + } + await snapshotProject({ update: options.update ?? false, threeD: options["3d"] ?? false, @@ -43,6 +62,7 @@ export const registerSnapshot = (program: Command) => { platformConfig: options.disablePartsEngine ? { partsEngineDisabled: true } : undefined, + cameraPreset: options.cameraPreset as CameraPreset | undefined, createDiff: (options.ci ?? false) || (options.test ?? false), onExit: (code) => process.exit(code), onError: (msg) => console.error(msg), diff --git a/lib/shared/camera-presets.ts b/lib/shared/camera-presets.ts new file mode 100644 index 00000000..3af419de --- /dev/null +++ b/lib/shared/camera-presets.ts @@ -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.0001, 1, -0.01]), + + /** 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 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) +} diff --git a/lib/shared/snapshot-project.ts b/lib/shared/snapshot-project.ts index 52cd50f8..38852252 100644 --- a/lib/shared/snapshot-project.ts +++ b/lib/shared/snapshot-project.ts @@ -6,6 +6,7 @@ import { convertCircuitJsonToGltf, getBestCameraPosition, } from "circuit-json-to-gltf" +import { type CameraPreset, applyCameraPreset } from "lib/shared/camera-presets" import { convertCircuitJsonToPcbSvg, convertCircuitJsonToSchematicSvg, @@ -41,6 +42,8 @@ type SnapshotOptions = { platformConfig?: PlatformConfig /** Create visual diff artifacts when snapshots mismatch */ createDiff?: boolean + /** Camera preset name for 3D snapshots (implies --3d) */ + cameraPreset?: CameraPreset onExit?: (code: number) => void onError?: (message: string) => void onSuccess?: (message: string) => void @@ -59,7 +62,12 @@ export const snapshotProject = async ({ onSuccess = (msg) => console.log(msg), platformConfig, createDiff = false, + cameraPreset, }: SnapshotOptions = {}) => { + // --camera-preset implies --3d + if (cameraPreset) { + threeD = true + } const projectDir = process.cwd() const ignore = [ ...DEFAULT_IGNORED_PATTERNS, @@ -162,7 +170,10 @@ export const snapshotProject = async ({ ) } - const cameraOptions = getBestCameraPosition(circuitJson) + let cameraOptions = getBestCameraPosition(circuitJson) + if (cameraPreset) { + cameraOptions = applyCameraPreset(cameraPreset, cameraOptions) + } png3d = await renderGLTFToPNGBufferFromGLBBuffer( glbBuffer, diff --git a/tests/cli/snapshot/__snapshots__/3d-front.snap.png b/tests/cli/snapshot/__snapshots__/3d-front.snap.png new file mode 100644 index 00000000..9d6a395b Binary files /dev/null and b/tests/cli/snapshot/__snapshots__/3d-front.snap.png differ diff --git a/tests/cli/snapshot/__snapshots__/3d-left-sideview.snap.png b/tests/cli/snapshot/__snapshots__/3d-left-sideview.snap.png new file mode 100644 index 00000000..b155fe08 Binary files /dev/null and b/tests/cli/snapshot/__snapshots__/3d-left-sideview.snap.png differ diff --git a/tests/cli/snapshot/__snapshots__/3d-right-sideview.snap.png b/tests/cli/snapshot/__snapshots__/3d-right-sideview.snap.png new file mode 100644 index 00000000..7e6cfb25 Binary files /dev/null and b/tests/cli/snapshot/__snapshots__/3d-right-sideview.snap.png differ diff --git a/tests/cli/snapshot/__snapshots__/3d-top-center-angled.snap.png b/tests/cli/snapshot/__snapshots__/3d-top-center-angled.snap.png new file mode 100644 index 00000000..c648f9d3 Binary files /dev/null and b/tests/cli/snapshot/__snapshots__/3d-top-center-angled.snap.png differ diff --git a/tests/cli/snapshot/__snapshots__/3d-top-down.snap.png b/tests/cli/snapshot/__snapshots__/3d-top-down.snap.png new file mode 100644 index 00000000..15262632 Binary files /dev/null and b/tests/cli/snapshot/__snapshots__/3d-top-down.snap.png differ diff --git a/tests/cli/snapshot/__snapshots__/3d-top-left-corner.snap.png b/tests/cli/snapshot/__snapshots__/3d-top-left-corner.snap.png new file mode 100644 index 00000000..705c0dfe Binary files /dev/null and b/tests/cli/snapshot/__snapshots__/3d-top-left-corner.snap.png differ diff --git a/tests/cli/snapshot/__snapshots__/3d-top-left.snap.png b/tests/cli/snapshot/__snapshots__/3d-top-left.snap.png new file mode 100644 index 00000000..e9fc2b79 Binary files /dev/null and b/tests/cli/snapshot/__snapshots__/3d-top-left.snap.png differ diff --git a/tests/cli/snapshot/__snapshots__/3d-top-right-corner.snap.png b/tests/cli/snapshot/__snapshots__/3d-top-right-corner.snap.png new file mode 100644 index 00000000..59b8ea59 Binary files /dev/null and b/tests/cli/snapshot/__snapshots__/3d-top-right-corner.snap.png differ diff --git a/tests/cli/snapshot/__snapshots__/3d-top-right.snap.png b/tests/cli/snapshot/__snapshots__/3d-top-right.snap.png new file mode 100644 index 00000000..88baab48 Binary files /dev/null and b/tests/cli/snapshot/__snapshots__/3d-top-right.snap.png differ diff --git a/tests/cli/snapshot/snapshot-camera-preset-different.test.ts b/tests/cli/snapshot/snapshot-camera-preset-different.test.ts new file mode 100644 index 00000000..02ce1991 --- /dev/null +++ b/tests/cli/snapshot/snapshot-camera-preset-different.test.ts @@ -0,0 +1,32 @@ +import { test, expect } from "bun:test" +import { join } from "node:path" +import fs from "node:fs" +import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" + +const BOARD_TSX = ` +export const TestBoard = () => ( + + + +) +` + +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) diff --git a/tests/cli/snapshot/snapshot-camera-preset-invalid.test.ts b/tests/cli/snapshot/snapshot-camera-preset-invalid.test.ts new file mode 100644 index 00000000..d3bf5f2c --- /dev/null +++ b/tests/cli/snapshot/snapshot-camera-preset-invalid.test.ts @@ -0,0 +1,25 @@ +import { test, expect } from "bun:test" +import { join } from "node:path" +import fs from "node:fs" +import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" + +const BOARD_TSX = ` +export const TestBoard = () => ( + + + +) +` + +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) diff --git a/tests/cli/snapshot/snapshot-camera-preset-without-3d.test.ts b/tests/cli/snapshot/snapshot-camera-preset-without-3d.test.ts new file mode 100644 index 00000000..3983499d --- /dev/null +++ b/tests/cli/snapshot/snapshot-camera-preset-without-3d.test.ts @@ -0,0 +1,29 @@ +import { test, expect } from "bun:test" +import { join } from "node:path" +import fs from "node:fs" +import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" + +const BOARD_TSX = ` +export const TestBoard = () => ( + + + +) +` + +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) diff --git a/tests/cli/snapshot/snapshot-camera-preset.test.ts b/tests/cli/snapshot/snapshot-camera-preset.test.ts new file mode 100644 index 00000000..2b6b3616 --- /dev/null +++ b/tests/cli/snapshot/snapshot-camera-preset.test.ts @@ -0,0 +1,61 @@ +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 } from "lib/shared/camera-presets" + +const BOARD_TSX = ` +export const TestBoard = () => ( + + + +) +` + +/** 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`), + ) + }, 60_000) +}