From 029e62e3e64f3762164a686896046b476af1f280 Mon Sep 17 00:00:00 2001 From: seveibar Date: Mon, 17 Aug 2026 12:15:08 -0700 Subject: [PATCH 1/2] Add component-focused PCB snapshots --- cli/snapshot/register.ts | 30 ++++- cli/snapshot/worker-pool.ts | 1 + cli/snapshot/worker-snapshot-handlers.ts | 6 +- cli/snapshot/worker-types.ts | 1 + lib/shared/get-component-pcb-viewport.ts | 126 ++++++++++++++++++ lib/shared/process-snapshot-file.ts | 16 ++- lib/shared/snapshot-project.ts | 7 +- .../snapshot/snapshot-component-name.test.ts | 42 ++++++ 8 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 lib/shared/get-component-pcb-viewport.ts create mode 100644 tests/cli/snapshot/snapshot-component-name.test.ts diff --git a/cli/snapshot/register.ts b/cli/snapshot/register.ts index 69a632649..2e5776737 100644 --- a/cli/snapshot/register.ts +++ b/cli/snapshot/register.ts @@ -1,6 +1,6 @@ -import type { Command } from "commander" import type { VisibleLayerRef } from "circuit-json" import { CAMERA_PRESET_NAMES, type CameraPreset } from "circuit-json-to-3d-png" +import type { Command } from "commander" import { snapshotProject } from "lib/shared/snapshot-project" export const registerSnapshot = (program: Command) => { @@ -25,6 +25,10 @@ export const registerSnapshot = (program: Command) => { ) .option("--disable-parts-engine", "Disable the parts engine") .option("--show-courtyards", "Show courtyard outlines in PCB snapshots") + .option( + "--component-name ", + "Focus on one PCB component (implies --pcb-only and --show-courtyards)", + ) .option( "--camera-preset ", `Camera angle preset for 3D snapshots (implies --3d). Valid presets: ${CAMERA_PRESET_NAMES.join(", ")}`, @@ -53,6 +57,7 @@ export const registerSnapshot = (program: Command) => { ci?: boolean test?: boolean concurrency?: string + componentName?: string }, ) => { if ( @@ -82,6 +87,19 @@ export const registerSnapshot = (program: Command) => { process.exit(1) } + if ( + options.componentName && + (options.schematicOnly || + options.simulationOnly || + options["3d"] || + options.cameraPreset) + ) { + console.error( + "--component-name cannot be combined with --schematic-only, --simulation-only, --3d, or --camera-preset.", + ) + process.exit(1) + } + if ( options.simulationOnly && (options.pcbOnly || @@ -97,7 +115,7 @@ export const registerSnapshot = (program: Command) => { } let pcbOnly = options.pcbOnly ?? false - if (pcbLayer) { + if (pcbLayer || options.componentName) { pcbOnly = true } @@ -117,9 +135,11 @@ export const registerSnapshot = (program: Command) => { platformConfig: options.disablePartsEngine ? { partsEngineDisabled: true } : undefined, - pcbSnapshotSettingsOverride: options.showCourtyards - ? { showCourtyards: true } - : undefined, + pcbSnapshotSettingsOverride: + options.showCourtyards || options.componentName + ? { showCourtyards: true } + : undefined, + componentName: options.componentName, cameraPreset: options.cameraPreset as CameraPreset | undefined, createDiff: (options.ci ?? false) || (options.test ?? false), onExit: (code) => process.exit(code), diff --git a/cli/snapshot/worker-pool.ts b/cli/snapshot/worker-pool.ts index d53a82f91..9f4a17ac8 100644 --- a/cli/snapshot/worker-pool.ts +++ b/cli/snapshot/worker-pool.ts @@ -81,6 +81,7 @@ export const snapshotFilesWithWorkerPool = async (options: { createDiff: job.options.createDiff, cameraPreset: job.options.cameraPreset, pcbLayer: job.options.pcbLayer, + componentName: job.options.componentName, }, }), isLogMessage: (message) => message.message_type === "worker_log", diff --git a/cli/snapshot/worker-snapshot-handlers.ts b/cli/snapshot/worker-snapshot-handlers.ts index d7a3b37de..082f58a16 100644 --- a/cli/snapshot/worker-snapshot-handlers.ts +++ b/cli/snapshot/worker-snapshot-handlers.ts @@ -1,11 +1,11 @@ import type { PlatformConfig } from "@tscircuit/props" import type { VisibleLayerRef } from "circuit-json" import type { CameraPreset } from "circuit-json-to-3d-png" +import { loadRuntimeProjectConfig } from "lib/project-config" import type { PcbSnapshotSettings } from "lib/project-config/project-config-schema" +import { mergePlatformConfigs } from "lib/shared/platform-config-utils" import { processSnapshotFile } from "lib/shared/process-snapshot-file" import { registerStaticAssetLoaders } from "lib/shared/register-static-asset-loaders" -import { loadRuntimeProjectConfig } from "lib/project-config" -import { mergePlatformConfigs } from "lib/shared/platform-config-utils" import type { SnapshotCompletedMessage } from "./worker-types" type SnapshotWorkerOptions = { @@ -20,6 +20,7 @@ type SnapshotWorkerOptions = { createDiff: boolean cameraPreset?: CameraPreset pcbLayer?: VisibleLayerRef + componentName?: string } export const handleSnapshotFile = async ( @@ -51,6 +52,7 @@ export const handleSnapshotFile = async ( createDiff: options.createDiff, cameraPreset: options.cameraPreset, pcbLayer: options.pcbLayer, + componentName: options.componentName, }) return { diff --git a/cli/snapshot/worker-types.ts b/cli/snapshot/worker-types.ts index 97a614cda..9df0793e1 100644 --- a/cli/snapshot/worker-types.ts +++ b/cli/snapshot/worker-types.ts @@ -21,6 +21,7 @@ export type SnapshotFileMessage = { createDiff: boolean cameraPreset?: CameraPreset pcbLayer?: VisibleLayerRef + componentName?: string } } diff --git a/lib/shared/get-component-pcb-viewport.ts b/lib/shared/get-component-pcb-viewport.ts new file mode 100644 index 000000000..3041185a6 --- /dev/null +++ b/lib/shared/get-component-pcb-viewport.ts @@ -0,0 +1,126 @@ +import type { AnyCircuitElement } from "circuit-json" + +export type PcbViewport = { + minX: number + minY: number + maxX: number + maxY: number +} + +const normalizeComponentName = (name: string) => name.replace(/^\./, "") + +const addPoint = (bounds: PcbViewport, x: number, y: number) => { + bounds.minX = Math.min(bounds.minX, x) + bounds.minY = Math.min(bounds.minY, y) + bounds.maxX = Math.max(bounds.maxX, x) + bounds.maxY = Math.max(bounds.maxY, y) +} + +const addRect = ( + bounds: PcbViewport, + center: { x: number; y: number }, + width: number, + height: number, + rotation = 0, +) => { + const radians = (rotation * Math.PI) / 180 + const halfWidth = width / 2 + const halfHeight = height / 2 + const rotatedHalfWidth = + Math.abs(Math.cos(radians)) * halfWidth + + Math.abs(Math.sin(radians)) * halfHeight + const rotatedHalfHeight = + Math.abs(Math.sin(radians)) * halfWidth + + Math.abs(Math.cos(radians)) * halfHeight + + addPoint(bounds, center.x - rotatedHalfWidth, center.y - rotatedHalfHeight) + addPoint(bounds, center.x + rotatedHalfWidth, center.y + rotatedHalfHeight) +} + +export const getComponentPcbViewport = ( + circuitJson: AnyCircuitElement[], + requestedComponentName: string, +): PcbViewport => { + const componentName = normalizeComponentName(requestedComponentName) + const sourceComponent = circuitJson.find( + (element) => + element.type === "source_component" && + normalizeComponentName(element.name) === componentName, + ) + + if (!sourceComponent || sourceComponent.type !== "source_component") { + throw new Error( + `Could not find component named "${requestedComponentName}"`, + ) + } + + const pcbComponent = circuitJson.find( + (element) => + element.type === "pcb_component" && + element.source_component_id === sourceComponent.source_component_id, + ) + + if (!pcbComponent || pcbComponent.type !== "pcb_component") { + throw new Error( + `Component "${requestedComponentName}" does not have a PCB component`, + ) + } + + const bounds: PcbViewport = { + minX: Number.POSITIVE_INFINITY, + minY: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + maxY: Number.NEGATIVE_INFINITY, + } + + addRect( + bounds, + pcbComponent.center, + pcbComponent.width, + pcbComponent.height, + pcbComponent.rotation, + ) + + for (const element of circuitJson) { + if ( + !("pcb_component_id" in element) || + element.pcb_component_id !== pcbComponent.pcb_component_id + ) { + continue + } + + if (element.type === "pcb_courtyard_rect") { + addRect( + bounds, + element.center, + element.width, + element.height, + element.ccw_rotation, + ) + } else if ( + element.type === "pcb_courtyard_outline" || + element.type === "pcb_courtyard_polygon" + ) { + const points = + element.type === "pcb_courtyard_outline" + ? element.outline + : element.points + for (const point of points) addPoint(bounds, point.x, point.y) + } else if (element.type === "pcb_courtyard_circle") { + addRect(bounds, element.center, element.radius * 2, element.radius * 2) + } else if (element.type === "pcb_courtyard_pill") { + addRect(bounds, element.center, element.width, element.height) + } + } + + const width = bounds.maxX - bounds.minX + const height = bounds.maxY - bounds.minY + const padding = Math.max(0.5, Math.max(width, height) * 0.1) + + return { + minX: bounds.minX - padding, + minY: bounds.minY - padding, + maxX: bounds.maxX + padding, + maxY: bounds.maxY + padding, + } +} diff --git a/lib/shared/process-snapshot-file.ts b/lib/shared/process-snapshot-file.ts index 4ae76e2fb..cee32294b 100644 --- a/lib/shared/process-snapshot-file.ts +++ b/lib/shared/process-snapshot-file.ts @@ -1,19 +1,20 @@ import fs from "node:fs" import path from "node:path" import type { PlatformConfig } from "@tscircuit/props" -import type { PcbSnapshotSettings } from "lib/project-config/project-config-schema" import type { AnyCircuitElement, VisibleLayerRef } from "circuit-json" import { renderCircuitJsonTo3dPng } from "circuit-json-to-3d-png" +import type { CameraPreset } from "circuit-json-to-3d-png" import { convertCircuitJsonToPcbSvg, convertCircuitJsonToStackedSchematicSheetsSvg, } from "circuit-to-svg" import kleur from "kleur" -import type { CameraPreset } from "circuit-json-to-3d-png" +import type { PcbSnapshotSettings } from "lib/project-config/project-config-schema" import { getOrGenerateCircuitJson } from "lib/shared/get-or-generate-circuit-json" import { getPlatformConfigWithCliDefaults } from "lib/shared/get-platform-config-with-cli-defaults" import { getSimulationSvgAssetsFromCircuitJson } from "lib/shared/simulation-svg-assets" import { compareAndCreateDiff } from "./compare-images" +import { getComponentPcbViewport } from "./get-component-pcb-viewport" import { isCircuitJsonFile } from "./is-circuit-json-file" export type ProcessSnapshotFileOptions = { @@ -31,6 +32,7 @@ export type ProcessSnapshotFileOptions = { createDiff: boolean cameraPreset?: CameraPreset pcbLayer?: VisibleLayerRef + componentName?: string } export type ProcessSnapshotFileResult = { @@ -57,6 +59,7 @@ export const processSnapshotFile = async ({ createDiff, cameraPreset, pcbLayer, + componentName, }: ProcessSnapshotFileOptions): Promise => { const relativeFilePath = path.relative(projectDir, file) const successPaths: string[] = [] @@ -104,9 +107,13 @@ export const processSnapshotFile = async ({ if (!simulationOnly) { try { + const viewport = componentName + ? getComponentPcbViewport(circuitJson, componentName) + : undefined pcbSvg = convertCircuitJsonToPcbSvg(circuitJson, { ...pcbSnapshotSettings, layer: pcbLayer, + viewport, }) } catch (error) { const errorMessage = @@ -228,6 +235,9 @@ export const processSnapshotFile = async ({ fs.mkdirSync(snapDir, { recursive: true }) const base = path.basename(file).replace(/\.[^.]+$/, "") + const componentSuffix = componentName + ? `-${componentName.replace(/^\./, "").replace(/[^a-zA-Z0-9._-]+/g, "-")}` + : "" const snapshots: Array< | { type: string | VisibleLayerRef @@ -283,7 +293,7 @@ export const processSnapshotFile = async ({ const is3d = type === "3d" const snapPath = path.join( snapDir, - `${base}-${type}.snap.${is3d ? "png" : "svg"}`, + `${base}${componentSuffix}-${type}.snap.${is3d ? "png" : "svg"}`, ) const existing = fs.existsSync(snapPath) diff --git a/lib/shared/snapshot-project.ts b/lib/shared/snapshot-project.ts index 2953a0478..4cd055b9f 100644 --- a/lib/shared/snapshot-project.ts +++ b/lib/shared/snapshot-project.ts @@ -2,6 +2,7 @@ import fs from "node:fs" import path from "node:path" import type { PlatformConfig } from "@tscircuit/props" import type { VisibleLayerRef } from "circuit-json" +import type { CameraPreset } from "circuit-json-to-3d-png" import { snapshotFilesWithWorkerPool } from "cli/snapshot/worker-pool" import kleur from "kleur" import { @@ -10,7 +11,6 @@ import { loadRuntimeProjectConfig, } from "lib/project-config" import type { PcbSnapshotSettings } from "lib/project-config/project-config-schema" -import type { CameraPreset } from "circuit-json-to-3d-png" import { findBoardFilesAsync } from "lib/shared/find-board-files" import { mergePlatformConfigs } from "lib/shared/platform-config-utils" import { processSnapshotFile } from "lib/shared/process-snapshot-file" @@ -44,6 +44,8 @@ type SnapshotOptions = { cameraPreset?: CameraPreset /** Limit PCB snapshots to one layer */ pcbLayer?: VisibleLayerRef + /** Focus the PCB snapshot on one component */ + componentName?: string /** Number of files to process in parallel (default: 1) */ concurrency?: number onExit?: (code: number) => void @@ -78,6 +80,7 @@ export const snapshotProject = async ({ createDiff = false, cameraPreset, pcbLayer, + componentName, concurrency = 1, }: SnapshotOptions = {}) => { // --camera-preset implies --3d @@ -188,6 +191,7 @@ export const snapshotProject = async ({ createDiff, cameraPreset, pcbLayer, + componentName, }, stopOnFailure: true, onLog: (lines) => { @@ -232,6 +236,7 @@ export const snapshotProject = async ({ createDiff, cameraPreset, pcbLayer, + componentName, }) processResult(result) diff --git a/tests/cli/snapshot/snapshot-component-name.test.ts b/tests/cli/snapshot/snapshot-component-name.test.ts new file mode 100644 index 000000000..dbe0bc57e --- /dev/null +++ b/tests/cli/snapshot/snapshot-component-name.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test" +import { join } from "node:path" +import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" + +test("snapshot --component-name focuses a PCB snapshot on one component", async () => { + const { tmpDir, runCommand } = await getCliTestFixture() + + await Bun.write( + join(tmpDir, "test.board.tsx"), + ` + export const TestBoard = () => ( + + + + + ) + `, + ) + + await runCommand("tsci snapshot --update --component-name R1") + + const snapshotPath = join( + tmpDir, + "__snapshots__", + "test.board-R1-pcb.snap.svg", + ) + const svg = await Bun.file(snapshotPath).text() + + await runCommand("tsci snapshot --update --show-courtyards --pcb-only") + const fullBoardSvg = await Bun.file( + join(tmpDir, "__snapshots__", "test.board-pcb.snap.svg"), + ).text() + + expect(svg).toContain("R1") + expect(svg).toContain("pcb-courtyard-") + expect(svg).not.toBe(fullBoardSvg) + expect( + await Bun.file( + join(tmpDir, "__snapshots__", "test.board-R1-schematic.snap.svg"), + ).exists(), + ).toBe(false) +}, 60_000) From eefea9c148ccb2cc73e2ba2a5a7f43d927efe7d7 Mon Sep 17 00:00:00 2001 From: seveibar Date: Mon, 17 Aug 2026 13:06:19 -0700 Subject: [PATCH 2/2] Test component snapshots visually --- .../__snapshots__/component-name-R1.snap.svg | 1 + .../cli/snapshot/snapshot-component-name.test.ts | 15 ++------------- 2 files changed, 3 insertions(+), 13 deletions(-) create mode 100644 tests/cli/snapshot/__snapshots__/component-name-R1.snap.svg diff --git a/tests/cli/snapshot/__snapshots__/component-name-R1.snap.svg b/tests/cli/snapshot/__snapshots__/component-name-R1.snap.svg new file mode 100644 index 000000000..fd8cbf518 --- /dev/null +++ b/tests/cli/snapshot/__snapshots__/component-name-R1.snap.svg @@ -0,0 +1 @@ +R1R2 \ No newline at end of file diff --git a/tests/cli/snapshot/snapshot-component-name.test.ts b/tests/cli/snapshot/snapshot-component-name.test.ts index dbe0bc57e..ef12b2c0e 100644 --- a/tests/cli/snapshot/snapshot-component-name.test.ts +++ b/tests/cli/snapshot/snapshot-component-name.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test" +import "bun-match-svg" import { join } from "node:path" import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture" @@ -26,17 +27,5 @@ test("snapshot --component-name focuses a PCB snapshot on one component", async ) const svg = await Bun.file(snapshotPath).text() - await runCommand("tsci snapshot --update --show-courtyards --pcb-only") - const fullBoardSvg = await Bun.file( - join(tmpDir, "__snapshots__", "test.board-pcb.snap.svg"), - ).text() - - expect(svg).toContain("R1") - expect(svg).toContain("pcb-courtyard-") - expect(svg).not.toBe(fullBoardSvg) - expect( - await Bun.file( - join(tmpDir, "__snapshots__", "test.board-R1-schematic.snap.svg"), - ).exists(), - ).toBe(false) + expect(svg).toMatchSvgSnapshot(import.meta.path, "component-name-R1") }, 60_000)