Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions cli/snapshot/register.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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 <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(
Expand All @@ -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,
Expand All @@ -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),
Expand Down
107 changes: 107 additions & 0 deletions lib/shared/camera-presets.ts
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)
Comment on lines +101 to +106
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applyCameraPreset uses preset in CAMERA_PRESETS to validate. The in operator 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.

Copilot uses AI. Check for mistakes.
}
13 changes: 12 additions & 1 deletion lib/shared/snapshot-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
convertCircuitJsonToGltf,
getBestCameraPosition,
} from "circuit-json-to-gltf"
import { type CameraPreset, applyCameraPreset } from "lib/shared/camera-presets"
import {
convertCircuitJsonToPcbSvg,
convertCircuitJsonToSchematicSvg,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
114 changes: 114 additions & 0 deletions tests/cli/snapshot/snapshot-camera-preset.test.ts
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
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CameraPreset is imported but never used. With Biome's recommended lint rules enabled, this will be reported as an unused import and can fail CI; remove it or use it (e.g., for stronger typing around preset usage).

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests write generated PNGs into tests/cli/snapshot/__snapshots__ in the repo (fs.copyFileSync(...)). That makes the test suite non-hermetic and can dirty the working tree / cause flakiness in environments that enforce a clean checkout. Consider asserting against committed snapshots (or keeping artifacts only in the temp fixture directory / behind an explicit env flag) instead of copying into the repo during normal test runs.

Copilot uses AI. Check for mistakes.
}, 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)
Loading