From d819ff64693ee73b9928e909d90c233d45e3be70 Mon Sep 17 00:00:00 2001 From: Sangit Manandhar Date: Fri, 24 Jul 2026 14:47:44 -0700 Subject: [PATCH 1/3] feat: tooling layer support --- bun.lock | 9 +- lib/components/workspace-context.tsx | 46 +- lib/helpers/convert-circuit-json-to-lbrn.ts | 433 ++++++++++++++++++ .../generate-lightburn-svg-for-preview.ts | 30 ++ lib/helpers/lightburn-cut-settings.ts | 1 + lib/hooks/preview-hooks.tsx | 6 +- package.json | 5 +- tests/conductivity-pads.test.ts | 3 +- tests/fabrication-paths.test.ts | 140 ++++++ 9 files changed, 621 insertions(+), 52 deletions(-) create mode 100644 lib/helpers/convert-circuit-json-to-lbrn.ts create mode 100644 lib/helpers/generate-lightburn-svg-for-preview.ts create mode 100644 tests/fabrication-paths.test.ts diff --git a/bun.lock b/bun.lock index 54213ef..8038ae3 100644 --- a/bun.lock +++ b/bun.lock @@ -8,11 +8,12 @@ "@radix-ui/react-dialog": "^1.1.15", "@tscircuit/plop": "^0.0.72", "circuit-json": "^0.0.403", - "circuit-json-to-lbrn": "^0.0.82", + "circuit-json-to-connectivity-map": "^0.0.23", + "circuit-json-to-lbrn": "^0.0.85", "circuit-to-svg": "^0.0.344", "kicad-to-circuit-json": "^0.0.88", "kicadts": "^0.0.45", - "lbrnts": "^0.0.19", + "lbrnts": "^0.0.22", "lucide-react": "^0.562.0", "react": "^19.1.0", "react-cosmos": "^7.1.0", @@ -595,7 +596,7 @@ "circuit-json-to-gltf": ["circuit-json-to-gltf@0.0.96", "", { "dependencies": { "@jscad/modeling": "^2.12.6", "earcut": "^3.0.2", "jscad-electronics": "^0.0.129", "jscad-to-gltf": "^0.0.5", "occt-import-js": "^0.0.23" }, "peerDependencies": { "@resvg/resvg-js": "2", "@resvg/resvg-wasm": "2", "@tscircuit/circuit-json-util": "*", "circuit-json": "*", "circuit-to-svg": "*", "typescript": "^5" }, "optionalPeers": ["@resvg/resvg-js", "@resvg/resvg-wasm"] }, "sha512-7V1cj+WhyPBRsVbgghSCnbJNyWkx/KAhvnZU1Pdp7lZH+iHFIqRyIL/vNRRo657JDGM4bTlVFMUugo+RrseoKw=="], - "circuit-json-to-lbrn": ["circuit-json-to-lbrn@0.0.82", "", { "dependencies": { "lbrnts": "^0.0.19", "manifold-3d": "^3.3.2" }, "peerDependencies": { "typescript": "^5" } }, "sha512-DEF0S7+j983BKDA6dqLYqBi7+lkiKax5PDTzBxf5Jg+uQrWxW7ddSDuDyPUTVkM2OE25DjNt02MBpsKV56upow=="], + "circuit-json-to-lbrn": ["circuit-json-to-lbrn@0.0.85", "", { "dependencies": { "lbrnts": "^0.0.22", "manifold-3d": "^3.3.2" }, "peerDependencies": { "typescript": "^5" } }, "sha512-Ws0891TlqhKZ2RdVjh7lrss8e+GHA+sVXmtaNOr2n7iXe3ga7nYOWm2LWq/edWLonjYL6IkoVXk8MWjPMPQrgQ=="], "circuit-json-to-simple-3d": ["circuit-json-to-simple-3d@0.0.9", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-thpCtDb9LpAQfGO+Z0hae19sxVAmJAMYM/It9UTMCtyXC3zoUHwRcp/oqtMO/ZIW+JDBhiR8AHmmtKScL2kW7Q=="], @@ -933,7 +934,7 @@ "ktx-parse": ["ktx-parse@1.1.0", "", {}, "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ=="], - "lbrnts": ["lbrnts@0.0.19", "", { "dependencies": { "fast-xml-parser": "^5.3.2" }, "peerDependencies": { "typescript": "^5" } }, "sha512-FaQJKMZaQNSbAbsFBkqEZvTA55sn4WOfrbJPgrQa8lR09nPtHLofXuA8ydOrTZggP/drgpamDao+q4cuGcL7OQ=="], + "lbrnts": ["lbrnts@0.0.22", "", { "dependencies": { "fast-xml-parser": "^5.3.2" }, "peerDependencies": { "typescript": "^5" } }, "sha512-XeUvQ91qOtD+QXKIQHUeFf+a/EiDAPt5hdmmIn+w0MqOyHnvYyJQQeoyYpuqiNvGlovOROA+8eBftF8OsW/RIA=="], "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], diff --git a/lib/components/workspace-context.tsx b/lib/components/workspace-context.tsx index 8e4666d..38ac009 100644 --- a/lib/components/workspace-context.tsx +++ b/lib/components/workspace-context.tsx @@ -9,6 +9,7 @@ import React, { useState, type ReactNode, } from "react" +import { convertCircuitJsonToLbrn } from "../helpers/convert-circuit-json-to-lbrn" import conductivityPadsTemplateRaw from "../../assets/connectivity-test-pads.json?raw" import fiducialsTemplateRaw from "../../assets/fiducials.json?raw" @@ -144,6 +145,7 @@ const defaultLbrnOptions: ConvertCircuitJsonToLbrnOptions = { includeSoldermaskCure: true, mirrorBottomLayer: true, includeLayers: ["top", "bottom"], + toolingLayerIncludeRefs: ["test_short_*"], laserSpotSize: 0.005, traceMargin: 0.5, copperCutFillMargin: 0.5, @@ -451,12 +453,10 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { setError(null) try { - const { convertCircuitJsonToLbrn } = await import("circuit-json-to-lbrn") - const finalOptions = { ...lbrnOptions, ...options } - const rawXml = await convertCircuitJsonToLbrn(circuitJson, finalOptions) - const xml = formatLbrnXml(rawXml) + const project = await convertCircuitJsonToLbrn(circuitJson, finalOptions) + const xml = project.getString() setLbrnFileContent({ xml, @@ -500,44 +500,6 @@ export function WorkspaceProvider({ children }: { children: ReactNode }) { ) } -const formatLbrnXml = (value: unknown): string => { - if (typeof value === "string") { - return value - } - - if (value && typeof value === "object") { - const candidate = value as { xml?: unknown; outerHTML?: unknown } - if (typeof candidate.xml === "string") { - return candidate.xml - } - if (typeof candidate.outerHTML === "string") { - return candidate.outerHTML - } - } - - if (typeof window !== "undefined" && value instanceof XMLDocument) { - return new XMLSerializer().serializeToString(value) - } - - if (typeof window !== "undefined" && value instanceof Element) { - return new XMLSerializer().serializeToString(value) - } - - if (Array.isArray(value)) { - return value.map(formatLbrnXml).join("") - } - - const fallback = String(value) - if (fallback.startsWith("[object ") && fallback.endsWith("]")) { - const inner = fallback.slice(8, -1) - if (inner.trim().startsWith(" + circuitJson.flatMap((element) => { + if (element.type !== "pcb_fabrication_note_path") return [] + + const path = element as unknown as Record + const layer = path.layer + const route = path.route + const strokeWidth = path.stroke_width + if ( + (layer !== "top" && layer !== "bottom") || + !Array.isArray(route) || + typeof strokeWidth !== "number" + ) { + return [] + } + + return [ + { + ...path, + type: "pcb_fabrication_note_path", + layer, + route: route as Point[], + stroke_width: strokeWidth, + } as FabricationPath & Record, + ] + }) + +const TOOLING_PATH_ID_PREFIX = "pcb_fabrication_note_path_" + +const matchesRef = (value: string, ref: string) => { + const escapedRef = ref.replace(/[.+?^${}()|[\]\\]/g, "\\$&") + return new RegExp(`^${escapedRef.replaceAll("*", ".*")}$`).test(value) +} + +const getToolingPaths = ( + circuitJson: CircuitJson, + includeRefs: string[], +): ToolingPath[] => + getFabricationPaths(circuitJson).filter((path): path is ToolingPath => { + if ( + path.role !== "tooling" || + typeof path.pcb_fabrication_note_path_id !== "string" + ) { + return false + } + + const pathRef = path.pcb_fabrication_note_path_id.startsWith( + TOOLING_PATH_ID_PREFIX, + ) + ? path.pcb_fabrication_note_path_id.slice(TOOLING_PATH_ID_PREFIX.length) + : path.pcb_fabrication_note_path_id + + return includeRefs.some((ref) => matchesRef(pathRef, ref)) + }) + +const getCopperCutFillReplacementPaths = ( + circuitJson: CircuitJson, +): CopperCutFillReplacementPath[] => + getFabricationPaths(circuitJson).filter( + (path): path is CopperCutFillReplacementPath => + path.role === "copper_cut_fill" && + typeof path.replaces_pcb_trace_id === "string", + ) + +const getConnectedIdsForReplacementPaths = ( + circuitJson: CircuitJson, + replacementPaths: CopperCutFillReplacementPath[], +) => { + const connectivityMap = getFullConnectivityMapFromCircuitJson(circuitJson) + const connectedIdsByLayer = { + top: new Set(), + bottom: new Set(), + } + + for (const replacementPath of replacementPaths) { + const replacedTrace = circuitJson.find( + (element) => + element.type === "pcb_trace" && + element.pcb_trace_id === replacementPath.replaces_pcb_trace_id, + ) + if (replacedTrace?.type !== "pcb_trace") { + throw new Error( + `Copper cut fill replacement references unknown trace ${replacementPath.replaces_pcb_trace_id}`, + ) + } + + const connectivityId = + replacedTrace.source_trace_id ?? replacedTrace.pcb_trace_id + const netId = connectivityMap.getNetConnectedToId(connectivityId) + if (!netId) { + throw new Error( + `Cannot resolve the net for copper cut fill replacement trace ${replacementPath.replaces_pcb_trace_id}`, + ) + } + + for (const connectedId of connectivityMap.getIdsConnectedToNet(netId)) { + connectedIdsByLayer[replacementPath.layer].add(connectedId) + } + } + + return connectedIdsByLayer +} + +const removeConnectedCopperGeometry = ( + circuitJson: CircuitJson, + connectedIds: Set, +): CircuitJson => + circuitJson.filter((element) => { + if (!COPPER_GEOMETRY_TYPES.has(element.type)) return true + + return !Object.entries(element).some( + ([key, value]) => + key.endsWith("_id") && + typeof value === "string" && + connectedIds.has(value), + ) + }) as CircuitJson + +const keepOnlyCutIndex = ( + node: LightBurnBaseElement, + cutIndex: number, +): boolean => { + if (node instanceof CutSetting) return node.index === cutIndex + if (node instanceof ShapeBase && !(node instanceof ShapeGroup)) { + return node.cutIndex === cutIndex + } + if (node instanceof ShapeGroup) { + node.children = node.children.filter((child) => + keepOnlyCutIndex(child, cutIndex), + ) + return node.children.length > 0 + } + return false +} + +const appendCutFillLayer = ( + targetProject: LightBurnProject, + fillProject: LightBurnProject, + cutIndex: number, +) => { + targetProject.children.push( + ...fillProject.children.filter((child) => + keepOnlyCutIndex(child, cutIndex), + ), + ) +} + +const rotatePoint = (point: Point, center: Point, rotation: number): Point => { + const deltaX = point.x - center.x + const deltaY = point.y - center.y + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + return { + x: center.x + deltaX * cos - deltaY * sin, + y: center.y + deltaX * sin + deltaY * cos, + } +} + +const createStrokedSegment = ({ + start, + end, + strokeWidth, + origin, +}: { + start: Point + end: Point + strokeWidth: number + origin: Point +}) => { + const deltaX = end.x - start.x + const deltaY = end.y - start.y + const segmentLength = Math.hypot(deltaX, deltaY) + if (segmentLength === 0) return null + + const center = { + x: (start.x + end.x) / 2 + origin.x, + y: (start.y + end.y) / 2 + origin.y, + } + const radius = strokeWidth / 2 + const capOffset = segmentLength / 2 + const rotation = Math.atan2(deltaY, deltaX) + const verts: Point[] = [] + const prims: Array<{ type: number }> = [] + const addPoint = (point: Point) => { + verts.push(rotatePoint(point, center, rotation)) + if (verts.length > 1) prims.push({ type: 0 }) + } + + for (let index = 0; index <= 32; index += 1) { + const angle = -Math.PI / 2 + (index / 32) * Math.PI + addPoint({ + x: center.x + capOffset + radius * Math.cos(angle), + y: center.y + radius * Math.sin(angle), + }) + } + addPoint({ x: center.x - capOffset, y: center.y + radius }) + for (let index = 0; index <= 32; index += 1) { + const angle = Math.PI / 2 + (index / 32) * Math.PI + addPoint({ + x: center.x - capOffset + radius * Math.cos(angle), + y: center.y + radius * Math.sin(angle), + }) + } + addPoint({ x: center.x + capOffset, y: center.y - radius }) + verts.push({ ...verts[0]! }) + prims.push({ type: 0 }) + + return { verts, prims } +} + +const getBottomLayerXform = ( + circuitJson: CircuitJson, + origin: Point, + mirrorBottomLayer: boolean, +): Mat | undefined => { + if (!mirrorBottomLayer) return undefined + + const board = circuitJson.find((element) => element.type === "pcb_board") + if (board?.type !== "pcb_board") return undefined + + let centerX = board.center?.x + if (typeof centerX !== "number" && board.outline?.length) { + const xs = board.outline.map((point) => point.x) + centerX = (Math.min(...xs) + Math.max(...xs)) / 2 + } + if (typeof centerX !== "number") return undefined + + return [-1, 0, 0, 1, 2 * (centerX + origin.x), 0] +} + +const appendFabricationPaths = ({ + project, + paths, + cutIndex, + origin, + bottomLayerXform, +}: { + project: LightBurnProject + paths: FabricationPath[] + cutIndex: number + origin: Point + bottomLayerXform?: Mat +}) => { + for (const path of paths) { + if (path.stroke_width <= 0) continue + + for (let index = 0; index < path.route.length - 1; index += 1) { + const segment = createStrokedSegment({ + start: path.route[index]!, + end: path.route[index + 1]!, + strokeWidth: path.stroke_width, + origin, + }) + if (!segment) continue + + project.children.push( + new ShapePath({ + ...segment, + isClosed: true, + cutIndex, + xform: path.layer === "bottom" ? bottomLayerXform : undefined, + }), + ) + } + } +} + +const appendToolingPaths = ({ + project, + paths, + origin, + bottomLayerXform, +}: { + project: LightBurnProject + paths: ToolingPath[] + origin: Point + bottomLayerXform?: Mat +}) => { + if (paths.length === 0) return + + const hasToolingCutSetting = project.children.some( + (child) => child instanceof CutSetting && child.index === TOOL_1_INDEX, + ) + if (!hasToolingCutSetting) { + project.children.push( + new CutSetting({ type: "Tool", index: TOOL_1_INDEX, name: "T1" }), + ) + } + appendFabricationPaths({ + project, + paths, + cutIndex: TOOL_1_INDEX, + origin, + bottomLayerXform, + }) +} + +export const convertCircuitJsonToLbrn = async ( + circuitJson: CircuitJson, + options: ConvertCircuitJsonToLbrnOptions = {}, +): Promise => { + const includeLayers = options.includeLayers ?? ["top", "bottom"] + const packageToolingLayerIncludeRefs = ( + options.toolingLayerIncludeRefs ?? [] + ).filter((ref) => !ref.includes("*")) + const toolingPaths = getToolingPaths( + circuitJson, + options.toolingLayerIncludeRefs ?? [], + ).filter((path) => includeLayers.includes(path.layer)) + const replacementPaths = getCopperCutFillReplacementPaths(circuitJson).filter( + (path) => includeLayers.includes(path.layer), + ) + const shouldReplaceCopperCutFill = + replacementPaths.length > 0 && + (options.includeCopper ?? true) && + (options.includeCopperCutFill ?? false) + + // The current package owns component selection and copper geometry. Keep the + // primary conversion on its public API, suppressing only the fill that must + // be regenerated without the replacement path's connected net. + const project = await convertWithPackage(circuitJson, { + ...options, + toolingLayerIncludeRefs: packageToolingLayerIncludeRefs, + includeCopperCutFill: shouldReplaceCopperCutFill + ? false + : options.includeCopperCutFill, + }) + + const origin = options.origin ?? { x: 0, y: 0 } + const bottomLayerXform = getBottomLayerXform( + circuitJson, + origin, + options.mirrorBottomLayer ?? false, + ) + // The package handles component selectors. Trace refs such as test_short_* + // resolve to authored fabrication paths in this adapter. + appendToolingPaths({ + project, + paths: toolingPaths, + origin, + bottomLayerXform, + }) + + if (!shouldReplaceCopperCutFill) return project + + const connectedIdsByLayer = getConnectedIdsForReplacementPaths( + circuitJson, + replacementPaths, + ) + // Generate each side independently so replacing a top-layer testpoint does + // not remove the same connected net from the bottom-layer fill. + for (const layer of includeLayers) { + const fillProject = await convertWithPackage( + removeConnectedCopperGeometry(circuitJson, connectedIdsByLayer[layer]), + { + ...options, + includeLayers: [layer], + includeCopper: true, + includeCopperCutFill: true, + toolingLayerIncludeRefs: [], + }, + ) + appendCutFillLayer( + project, + fillProject, + layer === "top" + ? TOP_COPPER_CUT_FILL_INDEX + : BOTTOM_COPPER_CUT_FILL_INDEX, + ) + } + + for (const layer of includeLayers) { + appendFabricationPaths({ + project, + paths: replacementPaths.filter((path) => path.layer === layer), + cutIndex: + layer === "top" + ? TOP_COPPER_CUT_FILL_INDEX + : BOTTOM_COPPER_CUT_FILL_INDEX, + origin, + bottomLayerXform, + }) + } + + return project +} diff --git a/lib/helpers/generate-lightburn-svg-for-preview.ts b/lib/helpers/generate-lightburn-svg-for-preview.ts new file mode 100644 index 0000000..33d5aac --- /dev/null +++ b/lib/helpers/generate-lightburn-svg-for-preview.ts @@ -0,0 +1,30 @@ +import { generateLightBurnSvg, type LightBurnProject, ShapeBase } from "lbrnts" + +const TOOL_1_INDEX = 30 +const PREVIEW_INDEX = 24 +const PREVIEW_COLOR = "#FF80C0" +const TOOL_1_COLOR = "#F36926" + +export const generateLightBurnSvgForPreview = ( + project: LightBurnProject, +): string => { + const toolingShapes = project.children.filter( + (child): child is ShapeBase => + child instanceof ShapeBase && child.cutIndex === TOOL_1_INDEX, + ) + + // lbrnts 0.0.22 supports native tool layers in the project model but its SVG + // palette stops at C24. Borrow an unused preview color without changing the + // exported LightBurn project. + for (const shape of toolingShapes) { + shape.cutIndex = PREVIEW_INDEX + } + + try { + return generateLightBurnSvg(project).replaceAll(PREVIEW_COLOR, TOOL_1_COLOR) + } finally { + for (const shape of toolingShapes) { + shape.cutIndex = TOOL_1_INDEX + } + } +} diff --git a/lib/helpers/lightburn-cut-settings.ts b/lib/helpers/lightburn-cut-settings.ts index a34a7a8..0602f39 100644 --- a/lib/helpers/lightburn-cut-settings.ts +++ b/lib/helpers/lightburn-cut-settings.ts @@ -24,6 +24,7 @@ export const LIGHTBURN_COLORS: Record = { 22: "#FF8040", 23: "#FFC0FF", 24: "#FF80C0", + 30: "#F36926", } export type ExistingCutSetting = { diff --git a/lib/hooks/preview-hooks.tsx b/lib/hooks/preview-hooks.tsx index c0a3daf..b22f03b 100644 --- a/lib/hooks/preview-hooks.tsx +++ b/lib/hooks/preview-hooks.tsx @@ -1,9 +1,7 @@ import { distance, type CircuitJson } from "circuit-json" -import { convertCircuitJsonToLbrn } from "circuit-json-to-lbrn" import type { ConvertCircuitJsonToLbrnOptions } from "circuit-json-to-lbrn" import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" import type { PcbSvgOptions } from "circuit-to-svg" -import { generateLightBurnSvg } from "lbrnts" import { type RefObject, useCallback, @@ -20,6 +18,8 @@ import { translate, } from "transformation-matrix" import { useMouseMatrixTransform } from "use-mouse-matrix-transform" +import { convertCircuitJsonToLbrn } from "../helpers/convert-circuit-json-to-lbrn" +import { generateLightBurnSvgForPreview } from "../helpers/generate-lightburn-svg-for-preview" import { IDENTITY_MATRIX, computeFitTransform } from "../helpers/svg-transform" const pcbPreviewSvgOptions: PcbSvgOptions = { @@ -189,7 +189,7 @@ export function useSvgGeneration({ lbrnOptions, ) - const lbrnSvgResult = generateLightBurnSvg(lbrnProject) + const lbrnSvgResult = generateLightBurnSvgForPreview(lbrnProject) if (!cancelled) { setLbrnSvg(String(lbrnSvgResult)) lastLbrnInputs.current = { circuitJson, lbrnOptions } diff --git a/package.json b/package.json index 60960ab..5851c03 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,12 @@ "@radix-ui/react-dialog": "^1.1.15", "@tscircuit/plop": "^0.0.72", "circuit-json": "^0.0.403", - "circuit-json-to-lbrn": "^0.0.82", + "circuit-json-to-connectivity-map": "^0.0.23", + "circuit-json-to-lbrn": "^0.0.85", "circuit-to-svg": "^0.0.344", "kicad-to-circuit-json": "^0.0.88", "kicadts": "^0.0.45", - "lbrnts": "^0.0.19", + "lbrnts": "^0.0.22", "lucide-react": "^0.562.0", "react": "^19.1.0", "react-cosmos": "^7.1.0", diff --git a/tests/conductivity-pads.test.ts b/tests/conductivity-pads.test.ts index 6af34ce..275d427 100644 --- a/tests/conductivity-pads.test.ts +++ b/tests/conductivity-pads.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "bun:test" import type { CircuitJson } from "circuit-json" -import { convertCircuitJsonToLbrn } from "circuit-json-to-lbrn" import conductivityPads from "../assets/connectivity-test-pads.json" with { type: "json", } +import { convertCircuitJsonToLbrn } from "../lib/helpers/convert-circuit-json-to-lbrn" const TOP_COPPER_CUT_FILL_INDEX = 6 const BOTTOM_COPPER_CUT_FILL_INDEX = 7 @@ -45,6 +45,7 @@ test("inserted conductivity pads are included in top and bottom copper cut fill" includeCopper: true, includeCopperCutFill: true, includeLayers: ["top", "bottom"], + toolingLayerIncludeRefs: ["test_short_*"], origin: { x: 0, y: 0 }, copperCutFillMargin: 0.5, }) diff --git a/tests/fabrication-paths.test.ts b/tests/fabrication-paths.test.ts new file mode 100644 index 0000000..717fdc7 --- /dev/null +++ b/tests/fabrication-paths.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from "bun:test" +import type { CircuitJson } from "circuit-json" +import { CutSetting, ShapePath } from "lbrnts" +import { convertCircuitJsonToLbrn } from "../lib/helpers/convert-circuit-json-to-lbrn" +import { generateLightBurnSvgForPreview } from "../lib/helpers/generate-lightburn-svg-for-preview" +import circuitJson from "./examples/example01/1206x4_3216metric.json" with { + type: "json", +} + +const toolingPath = { + type: "pcb_fabrication_note_path", + pcb_fabrication_note_path_id: + "pcb_fabrication_note_path_test_short_top_left_top_trace", + pcb_component_id: "pcb_component_0", + layer: "top", + route: [ + { x: -1.5, y: 0 }, + { x: 1.5, y: 0 }, + ], + stroke_width: 0.5, + role: "tooling", +} + +const replacementPath = { + type: "pcb_fabrication_note_path", + pcb_fabrication_note_path_id: "pcb_fabrication_note_path_cross_cut_0", + pcb_component_id: "pcb_component_1", + layer: "top", + route: [ + { x: -2.02, y: -2.4 }, + { x: -0.52, y: -2.4 }, + ], + stroke_width: 0.5, + role: "copper_cut_fill", + replaces_pcb_trace_id: "source_trace_0_0", +} + +const collectShapes = (children: unknown[]): ShapePath[] => + children.flatMap((child) => { + if (child instanceof ShapePath) return [child] + if ( + child && + typeof child === "object" && + "children" in child && + Array.isArray(child.children) + ) { + return collectShapes(child.children) + } + return [] + }) + +test("adapts tooling fabrication paths selected by trace ref to T1 output", async () => { + const project = await convertCircuitJsonToLbrn( + [...circuitJson, toolingPath] as unknown as CircuitJson, + { + includeLayers: ["top"], + toolingLayerIncludeRefs: ["test_short_*"], + origin: { x: 0, y: 0 }, + }, + ) + + expect( + project.children.find( + (child) => child instanceof CutSetting && child.index === 30, + ), + ).toMatchObject({ type: "Tool", name: "T1" }) + expect( + collectShapes(project.children).filter((shape) => shape.cutIndex === 30) + .length, + ).toBeGreaterThan(0) + const authoredToolingShape = collectShapes(project.children) + .filter((shape) => shape.cutIndex === 30) + .find((shape) => { + const xs = shape.verts.map((vertex) => vertex.x) + const ys = shape.verts.map((vertex) => vertex.y) + return ( + Math.abs(Math.min(...xs) - -1.75) < 0.001 && + Math.abs(Math.max(...xs) - 1.75) < 0.001 && + Math.abs(Math.min(...ys) - -0.25) < 0.001 && + Math.abs(Math.max(...ys) - 0.25) < 0.001 + ) + }) + expect(authoredToolingShape).toBeDefined() + expect(authoredToolingShape?.verts.at(-1)).toEqual( + authoredToolingShape?.verts[0], + ) + + const previewSvg = generateLightBurnSvgForPreview(project) + expect(previewSvg).toContain("#F36926") + expect(project.getString()).toContain('CutIndex="30"') +}) + +test("does not infer tooling paths from their role", async () => { + const project = await convertCircuitJsonToLbrn( + [...circuitJson, toolingPath] as unknown as CircuitJson, + { + includeLayers: ["top"], + toolingLayerIncludeRefs: ["unrelated_*"], + origin: { x: 0, y: 0 }, + }, + ) + + expect( + project.children.some( + (child) => child instanceof CutSetting && child.index === 30, + ), + ).toBeFalse() +}) + +test("replaces a corner trace fill with a perpendicular pill", async () => { + const project = await convertCircuitJsonToLbrn( + [...circuitJson, replacementPath] as unknown as CircuitJson, + { + includeLayers: ["top"], + includeCopper: true, + includeCopperCutFill: true, + copperCutFillMargin: 0.5, + origin: { x: 0, y: 0 }, + }, + ) + const copperFillShapes = collectShapes(project.children).filter( + (shape) => shape.cutIndex === 6, + ) + const replacementPill = copperFillShapes.find((shape) => { + const xs = shape.verts.map((vertex) => vertex.x) + const ys = shape.verts.map((vertex) => vertex.y) + return ( + Math.abs(Math.min(...xs) - -2.27) < 0.001 && + Math.abs(Math.max(...xs) - -0.27) < 0.001 && + Math.abs(Math.min(...ys) - -2.65) < 0.001 && + Math.abs(Math.max(...ys) - -2.15) < 0.001 + ) + }) + + expect(replacementPill?.isClosed).toBe(true) + expect(copperFillShapes.length).toBeGreaterThan(1) + expect( + collectShapes(project.children).some((shape) => shape.cutIndex === 0), + ).toBe(true) +}) From bdeff1a5b2e1112a64f04ff4fe2ca9f16a2e48c7 Mon Sep 17 00:00:00 2001 From: Sangit Manandhar Date: Fri, 24 Jul 2026 18:27:48 -0700 Subject: [PATCH 2/3] refactor: use upstream lbrn converter --- bun.lock | 1 - lib/components/workspace-context.tsx | 13 +- lib/helpers/convert-circuit-json-to-lbrn.ts | 433 -------------------- lib/hooks/preview-hooks.tsx | 6 +- package.json | 1 - tests/conductivity-pads.test.ts | 9 +- tests/fabrication-paths.test.ts | 140 ------- tests/svg.test.ts | 22 + 8 files changed, 43 insertions(+), 582 deletions(-) delete mode 100644 lib/helpers/convert-circuit-json-to-lbrn.ts delete mode 100644 tests/fabrication-paths.test.ts diff --git a/bun.lock b/bun.lock index 8038ae3..e42b84e 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,6 @@ "@radix-ui/react-dialog": "^1.1.15", "@tscircuit/plop": "^0.0.72", "circuit-json": "^0.0.403", - "circuit-json-to-connectivity-map": "^0.0.23", "circuit-json-to-lbrn": "^0.0.85", "circuit-to-svg": "^0.0.344", "kicad-to-circuit-json": "^0.0.88", diff --git a/lib/components/workspace-context.tsx b/lib/components/workspace-context.tsx index 38ac009..05a812d 100644 --- a/lib/components/workspace-context.tsx +++ b/lib/components/workspace-context.tsx @@ -1,5 +1,8 @@ import type { CircuitJson } from "circuit-json" -import type { ConvertCircuitJsonToLbrnOptions } from "circuit-json-to-lbrn" +import { + convertCircuitJsonToLbrn, + type ConvertCircuitJsonToLbrnOptions, +} from "circuit-json-to-lbrn" import { KicadToCircuitJsonConverter } from "kicad-to-circuit-json" import React, { createContext, @@ -9,7 +12,6 @@ import React, { useState, type ReactNode, } from "react" -import { convertCircuitJsonToLbrn } from "../helpers/convert-circuit-json-to-lbrn" import conductivityPadsTemplateRaw from "../../assets/connectivity-test-pads.json?raw" import fiducialsTemplateRaw from "../../assets/fiducials.json?raw" @@ -145,7 +147,12 @@ const defaultLbrnOptions: ConvertCircuitJsonToLbrnOptions = { includeSoldermaskCure: true, mirrorBottomLayer: true, includeLayers: ["top", "bottom"], - toolingLayerIncludeRefs: ["test_short_*"], + toolingLayerIncludeRefs: [ + "test_short_top_left_top_trace", + "test_short_top_right_top_trace", + "test_short_bottom_right_top_trace", + "test_short_bottom_left_top_trace", + ], laserSpotSize: 0.005, traceMargin: 0.5, copperCutFillMargin: 0.5, diff --git a/lib/helpers/convert-circuit-json-to-lbrn.ts b/lib/helpers/convert-circuit-json-to-lbrn.ts deleted file mode 100644 index 090dc28..0000000 --- a/lib/helpers/convert-circuit-json-to-lbrn.ts +++ /dev/null @@ -1,433 +0,0 @@ -import type { CircuitJson } from "circuit-json" -import { getFullConnectivityMapFromCircuitJson } from "circuit-json-to-connectivity-map" -import { - convertCircuitJsonToLbrn as convertWithPackage, - type ConvertCircuitJsonToLbrnOptions, -} from "circuit-json-to-lbrn" -import { - CutSetting, - type LightBurnBaseElement, - type LightBurnProject, - type Mat, - ShapeBase, - ShapeGroup, - ShapePath, -} from "lbrnts" - -const TOP_COPPER_CUT_FILL_INDEX = 6 -const BOTTOM_COPPER_CUT_FILL_INDEX = 7 -const TOOL_1_INDEX = 30 - -type PcbSide = "top" | "bottom" -type Point = { x: number; y: number } - -type FabricationPath = { - type: "pcb_fabrication_note_path" - pcb_fabrication_note_path_id?: string - layer: PcbSide - route: Point[] - stroke_width: number -} - -type ToolingPath = FabricationPath & { - role: "tooling" -} - -type CopperCutFillReplacementPath = FabricationPath & { - role: "copper_cut_fill" - replaces_pcb_trace_id: string -} - -const COPPER_GEOMETRY_TYPES = new Set([ - "pcb_plated_hole", - "pcb_smtpad", - "pcb_trace", - "pcb_via", -]) - -const getFabricationPaths = (circuitJson: CircuitJson) => - circuitJson.flatMap((element) => { - if (element.type !== "pcb_fabrication_note_path") return [] - - const path = element as unknown as Record - const layer = path.layer - const route = path.route - const strokeWidth = path.stroke_width - if ( - (layer !== "top" && layer !== "bottom") || - !Array.isArray(route) || - typeof strokeWidth !== "number" - ) { - return [] - } - - return [ - { - ...path, - type: "pcb_fabrication_note_path", - layer, - route: route as Point[], - stroke_width: strokeWidth, - } as FabricationPath & Record, - ] - }) - -const TOOLING_PATH_ID_PREFIX = "pcb_fabrication_note_path_" - -const matchesRef = (value: string, ref: string) => { - const escapedRef = ref.replace(/[.+?^${}()|[\]\\]/g, "\\$&") - return new RegExp(`^${escapedRef.replaceAll("*", ".*")}$`).test(value) -} - -const getToolingPaths = ( - circuitJson: CircuitJson, - includeRefs: string[], -): ToolingPath[] => - getFabricationPaths(circuitJson).filter((path): path is ToolingPath => { - if ( - path.role !== "tooling" || - typeof path.pcb_fabrication_note_path_id !== "string" - ) { - return false - } - - const pathRef = path.pcb_fabrication_note_path_id.startsWith( - TOOLING_PATH_ID_PREFIX, - ) - ? path.pcb_fabrication_note_path_id.slice(TOOLING_PATH_ID_PREFIX.length) - : path.pcb_fabrication_note_path_id - - return includeRefs.some((ref) => matchesRef(pathRef, ref)) - }) - -const getCopperCutFillReplacementPaths = ( - circuitJson: CircuitJson, -): CopperCutFillReplacementPath[] => - getFabricationPaths(circuitJson).filter( - (path): path is CopperCutFillReplacementPath => - path.role === "copper_cut_fill" && - typeof path.replaces_pcb_trace_id === "string", - ) - -const getConnectedIdsForReplacementPaths = ( - circuitJson: CircuitJson, - replacementPaths: CopperCutFillReplacementPath[], -) => { - const connectivityMap = getFullConnectivityMapFromCircuitJson(circuitJson) - const connectedIdsByLayer = { - top: new Set(), - bottom: new Set(), - } - - for (const replacementPath of replacementPaths) { - const replacedTrace = circuitJson.find( - (element) => - element.type === "pcb_trace" && - element.pcb_trace_id === replacementPath.replaces_pcb_trace_id, - ) - if (replacedTrace?.type !== "pcb_trace") { - throw new Error( - `Copper cut fill replacement references unknown trace ${replacementPath.replaces_pcb_trace_id}`, - ) - } - - const connectivityId = - replacedTrace.source_trace_id ?? replacedTrace.pcb_trace_id - const netId = connectivityMap.getNetConnectedToId(connectivityId) - if (!netId) { - throw new Error( - `Cannot resolve the net for copper cut fill replacement trace ${replacementPath.replaces_pcb_trace_id}`, - ) - } - - for (const connectedId of connectivityMap.getIdsConnectedToNet(netId)) { - connectedIdsByLayer[replacementPath.layer].add(connectedId) - } - } - - return connectedIdsByLayer -} - -const removeConnectedCopperGeometry = ( - circuitJson: CircuitJson, - connectedIds: Set, -): CircuitJson => - circuitJson.filter((element) => { - if (!COPPER_GEOMETRY_TYPES.has(element.type)) return true - - return !Object.entries(element).some( - ([key, value]) => - key.endsWith("_id") && - typeof value === "string" && - connectedIds.has(value), - ) - }) as CircuitJson - -const keepOnlyCutIndex = ( - node: LightBurnBaseElement, - cutIndex: number, -): boolean => { - if (node instanceof CutSetting) return node.index === cutIndex - if (node instanceof ShapeBase && !(node instanceof ShapeGroup)) { - return node.cutIndex === cutIndex - } - if (node instanceof ShapeGroup) { - node.children = node.children.filter((child) => - keepOnlyCutIndex(child, cutIndex), - ) - return node.children.length > 0 - } - return false -} - -const appendCutFillLayer = ( - targetProject: LightBurnProject, - fillProject: LightBurnProject, - cutIndex: number, -) => { - targetProject.children.push( - ...fillProject.children.filter((child) => - keepOnlyCutIndex(child, cutIndex), - ), - ) -} - -const rotatePoint = (point: Point, center: Point, rotation: number): Point => { - const deltaX = point.x - center.x - const deltaY = point.y - center.y - const cos = Math.cos(rotation) - const sin = Math.sin(rotation) - return { - x: center.x + deltaX * cos - deltaY * sin, - y: center.y + deltaX * sin + deltaY * cos, - } -} - -const createStrokedSegment = ({ - start, - end, - strokeWidth, - origin, -}: { - start: Point - end: Point - strokeWidth: number - origin: Point -}) => { - const deltaX = end.x - start.x - const deltaY = end.y - start.y - const segmentLength = Math.hypot(deltaX, deltaY) - if (segmentLength === 0) return null - - const center = { - x: (start.x + end.x) / 2 + origin.x, - y: (start.y + end.y) / 2 + origin.y, - } - const radius = strokeWidth / 2 - const capOffset = segmentLength / 2 - const rotation = Math.atan2(deltaY, deltaX) - const verts: Point[] = [] - const prims: Array<{ type: number }> = [] - const addPoint = (point: Point) => { - verts.push(rotatePoint(point, center, rotation)) - if (verts.length > 1) prims.push({ type: 0 }) - } - - for (let index = 0; index <= 32; index += 1) { - const angle = -Math.PI / 2 + (index / 32) * Math.PI - addPoint({ - x: center.x + capOffset + radius * Math.cos(angle), - y: center.y + radius * Math.sin(angle), - }) - } - addPoint({ x: center.x - capOffset, y: center.y + radius }) - for (let index = 0; index <= 32; index += 1) { - const angle = Math.PI / 2 + (index / 32) * Math.PI - addPoint({ - x: center.x - capOffset + radius * Math.cos(angle), - y: center.y + radius * Math.sin(angle), - }) - } - addPoint({ x: center.x + capOffset, y: center.y - radius }) - verts.push({ ...verts[0]! }) - prims.push({ type: 0 }) - - return { verts, prims } -} - -const getBottomLayerXform = ( - circuitJson: CircuitJson, - origin: Point, - mirrorBottomLayer: boolean, -): Mat | undefined => { - if (!mirrorBottomLayer) return undefined - - const board = circuitJson.find((element) => element.type === "pcb_board") - if (board?.type !== "pcb_board") return undefined - - let centerX = board.center?.x - if (typeof centerX !== "number" && board.outline?.length) { - const xs = board.outline.map((point) => point.x) - centerX = (Math.min(...xs) + Math.max(...xs)) / 2 - } - if (typeof centerX !== "number") return undefined - - return [-1, 0, 0, 1, 2 * (centerX + origin.x), 0] -} - -const appendFabricationPaths = ({ - project, - paths, - cutIndex, - origin, - bottomLayerXform, -}: { - project: LightBurnProject - paths: FabricationPath[] - cutIndex: number - origin: Point - bottomLayerXform?: Mat -}) => { - for (const path of paths) { - if (path.stroke_width <= 0) continue - - for (let index = 0; index < path.route.length - 1; index += 1) { - const segment = createStrokedSegment({ - start: path.route[index]!, - end: path.route[index + 1]!, - strokeWidth: path.stroke_width, - origin, - }) - if (!segment) continue - - project.children.push( - new ShapePath({ - ...segment, - isClosed: true, - cutIndex, - xform: path.layer === "bottom" ? bottomLayerXform : undefined, - }), - ) - } - } -} - -const appendToolingPaths = ({ - project, - paths, - origin, - bottomLayerXform, -}: { - project: LightBurnProject - paths: ToolingPath[] - origin: Point - bottomLayerXform?: Mat -}) => { - if (paths.length === 0) return - - const hasToolingCutSetting = project.children.some( - (child) => child instanceof CutSetting && child.index === TOOL_1_INDEX, - ) - if (!hasToolingCutSetting) { - project.children.push( - new CutSetting({ type: "Tool", index: TOOL_1_INDEX, name: "T1" }), - ) - } - appendFabricationPaths({ - project, - paths, - cutIndex: TOOL_1_INDEX, - origin, - bottomLayerXform, - }) -} - -export const convertCircuitJsonToLbrn = async ( - circuitJson: CircuitJson, - options: ConvertCircuitJsonToLbrnOptions = {}, -): Promise => { - const includeLayers = options.includeLayers ?? ["top", "bottom"] - const packageToolingLayerIncludeRefs = ( - options.toolingLayerIncludeRefs ?? [] - ).filter((ref) => !ref.includes("*")) - const toolingPaths = getToolingPaths( - circuitJson, - options.toolingLayerIncludeRefs ?? [], - ).filter((path) => includeLayers.includes(path.layer)) - const replacementPaths = getCopperCutFillReplacementPaths(circuitJson).filter( - (path) => includeLayers.includes(path.layer), - ) - const shouldReplaceCopperCutFill = - replacementPaths.length > 0 && - (options.includeCopper ?? true) && - (options.includeCopperCutFill ?? false) - - // The current package owns component selection and copper geometry. Keep the - // primary conversion on its public API, suppressing only the fill that must - // be regenerated without the replacement path's connected net. - const project = await convertWithPackage(circuitJson, { - ...options, - toolingLayerIncludeRefs: packageToolingLayerIncludeRefs, - includeCopperCutFill: shouldReplaceCopperCutFill - ? false - : options.includeCopperCutFill, - }) - - const origin = options.origin ?? { x: 0, y: 0 } - const bottomLayerXform = getBottomLayerXform( - circuitJson, - origin, - options.mirrorBottomLayer ?? false, - ) - // The package handles component selectors. Trace refs such as test_short_* - // resolve to authored fabrication paths in this adapter. - appendToolingPaths({ - project, - paths: toolingPaths, - origin, - bottomLayerXform, - }) - - if (!shouldReplaceCopperCutFill) return project - - const connectedIdsByLayer = getConnectedIdsForReplacementPaths( - circuitJson, - replacementPaths, - ) - // Generate each side independently so replacing a top-layer testpoint does - // not remove the same connected net from the bottom-layer fill. - for (const layer of includeLayers) { - const fillProject = await convertWithPackage( - removeConnectedCopperGeometry(circuitJson, connectedIdsByLayer[layer]), - { - ...options, - includeLayers: [layer], - includeCopper: true, - includeCopperCutFill: true, - toolingLayerIncludeRefs: [], - }, - ) - appendCutFillLayer( - project, - fillProject, - layer === "top" - ? TOP_COPPER_CUT_FILL_INDEX - : BOTTOM_COPPER_CUT_FILL_INDEX, - ) - } - - for (const layer of includeLayers) { - appendFabricationPaths({ - project, - paths: replacementPaths.filter((path) => path.layer === layer), - cutIndex: - layer === "top" - ? TOP_COPPER_CUT_FILL_INDEX - : BOTTOM_COPPER_CUT_FILL_INDEX, - origin, - bottomLayerXform, - }) - } - - return project -} diff --git a/lib/hooks/preview-hooks.tsx b/lib/hooks/preview-hooks.tsx index b22f03b..6014a4a 100644 --- a/lib/hooks/preview-hooks.tsx +++ b/lib/hooks/preview-hooks.tsx @@ -1,5 +1,8 @@ import { distance, type CircuitJson } from "circuit-json" -import type { ConvertCircuitJsonToLbrnOptions } from "circuit-json-to-lbrn" +import { + convertCircuitJsonToLbrn, + type ConvertCircuitJsonToLbrnOptions, +} from "circuit-json-to-lbrn" import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" import type { PcbSvgOptions } from "circuit-to-svg" import { @@ -18,7 +21,6 @@ import { translate, } from "transformation-matrix" import { useMouseMatrixTransform } from "use-mouse-matrix-transform" -import { convertCircuitJsonToLbrn } from "../helpers/convert-circuit-json-to-lbrn" import { generateLightBurnSvgForPreview } from "../helpers/generate-lightburn-svg-for-preview" import { IDENTITY_MATRIX, computeFitTransform } from "../helpers/svg-transform" diff --git a/package.json b/package.json index 5851c03..fddedc6 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "@radix-ui/react-dialog": "^1.1.15", "@tscircuit/plop": "^0.0.72", "circuit-json": "^0.0.403", - "circuit-json-to-connectivity-map": "^0.0.23", "circuit-json-to-lbrn": "^0.0.85", "circuit-to-svg": "^0.0.344", "kicad-to-circuit-json": "^0.0.88", diff --git a/tests/conductivity-pads.test.ts b/tests/conductivity-pads.test.ts index 275d427..9dfe3d4 100644 --- a/tests/conductivity-pads.test.ts +++ b/tests/conductivity-pads.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "bun:test" import type { CircuitJson } from "circuit-json" +import { convertCircuitJsonToLbrn } from "circuit-json-to-lbrn" import conductivityPads from "../assets/connectivity-test-pads.json" with { type: "json", } -import { convertCircuitJsonToLbrn } from "../lib/helpers/convert-circuit-json-to-lbrn" const TOP_COPPER_CUT_FILL_INDEX = 6 const BOTTOM_COPPER_CUT_FILL_INDEX = 7 @@ -45,7 +45,12 @@ test("inserted conductivity pads are included in top and bottom copper cut fill" includeCopper: true, includeCopperCutFill: true, includeLayers: ["top", "bottom"], - toolingLayerIncludeRefs: ["test_short_*"], + toolingLayerIncludeRefs: [ + "test_short_top_left_top_trace", + "test_short_top_right_top_trace", + "test_short_bottom_right_top_trace", + "test_short_bottom_left_top_trace", + ], origin: { x: 0, y: 0 }, copperCutFillMargin: 0.5, }) diff --git a/tests/fabrication-paths.test.ts b/tests/fabrication-paths.test.ts deleted file mode 100644 index 717fdc7..0000000 --- a/tests/fabrication-paths.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { expect, test } from "bun:test" -import type { CircuitJson } from "circuit-json" -import { CutSetting, ShapePath } from "lbrnts" -import { convertCircuitJsonToLbrn } from "../lib/helpers/convert-circuit-json-to-lbrn" -import { generateLightBurnSvgForPreview } from "../lib/helpers/generate-lightburn-svg-for-preview" -import circuitJson from "./examples/example01/1206x4_3216metric.json" with { - type: "json", -} - -const toolingPath = { - type: "pcb_fabrication_note_path", - pcb_fabrication_note_path_id: - "pcb_fabrication_note_path_test_short_top_left_top_trace", - pcb_component_id: "pcb_component_0", - layer: "top", - route: [ - { x: -1.5, y: 0 }, - { x: 1.5, y: 0 }, - ], - stroke_width: 0.5, - role: "tooling", -} - -const replacementPath = { - type: "pcb_fabrication_note_path", - pcb_fabrication_note_path_id: "pcb_fabrication_note_path_cross_cut_0", - pcb_component_id: "pcb_component_1", - layer: "top", - route: [ - { x: -2.02, y: -2.4 }, - { x: -0.52, y: -2.4 }, - ], - stroke_width: 0.5, - role: "copper_cut_fill", - replaces_pcb_trace_id: "source_trace_0_0", -} - -const collectShapes = (children: unknown[]): ShapePath[] => - children.flatMap((child) => { - if (child instanceof ShapePath) return [child] - if ( - child && - typeof child === "object" && - "children" in child && - Array.isArray(child.children) - ) { - return collectShapes(child.children) - } - return [] - }) - -test("adapts tooling fabrication paths selected by trace ref to T1 output", async () => { - const project = await convertCircuitJsonToLbrn( - [...circuitJson, toolingPath] as unknown as CircuitJson, - { - includeLayers: ["top"], - toolingLayerIncludeRefs: ["test_short_*"], - origin: { x: 0, y: 0 }, - }, - ) - - expect( - project.children.find( - (child) => child instanceof CutSetting && child.index === 30, - ), - ).toMatchObject({ type: "Tool", name: "T1" }) - expect( - collectShapes(project.children).filter((shape) => shape.cutIndex === 30) - .length, - ).toBeGreaterThan(0) - const authoredToolingShape = collectShapes(project.children) - .filter((shape) => shape.cutIndex === 30) - .find((shape) => { - const xs = shape.verts.map((vertex) => vertex.x) - const ys = shape.verts.map((vertex) => vertex.y) - return ( - Math.abs(Math.min(...xs) - -1.75) < 0.001 && - Math.abs(Math.max(...xs) - 1.75) < 0.001 && - Math.abs(Math.min(...ys) - -0.25) < 0.001 && - Math.abs(Math.max(...ys) - 0.25) < 0.001 - ) - }) - expect(authoredToolingShape).toBeDefined() - expect(authoredToolingShape?.verts.at(-1)).toEqual( - authoredToolingShape?.verts[0], - ) - - const previewSvg = generateLightBurnSvgForPreview(project) - expect(previewSvg).toContain("#F36926") - expect(project.getString()).toContain('CutIndex="30"') -}) - -test("does not infer tooling paths from their role", async () => { - const project = await convertCircuitJsonToLbrn( - [...circuitJson, toolingPath] as unknown as CircuitJson, - { - includeLayers: ["top"], - toolingLayerIncludeRefs: ["unrelated_*"], - origin: { x: 0, y: 0 }, - }, - ) - - expect( - project.children.some( - (child) => child instanceof CutSetting && child.index === 30, - ), - ).toBeFalse() -}) - -test("replaces a corner trace fill with a perpendicular pill", async () => { - const project = await convertCircuitJsonToLbrn( - [...circuitJson, replacementPath] as unknown as CircuitJson, - { - includeLayers: ["top"], - includeCopper: true, - includeCopperCutFill: true, - copperCutFillMargin: 0.5, - origin: { x: 0, y: 0 }, - }, - ) - const copperFillShapes = collectShapes(project.children).filter( - (shape) => shape.cutIndex === 6, - ) - const replacementPill = copperFillShapes.find((shape) => { - const xs = shape.verts.map((vertex) => vertex.x) - const ys = shape.verts.map((vertex) => vertex.y) - return ( - Math.abs(Math.min(...xs) - -2.27) < 0.001 && - Math.abs(Math.max(...xs) - -0.27) < 0.001 && - Math.abs(Math.min(...ys) - -2.65) < 0.001 && - Math.abs(Math.max(...ys) - -2.15) < 0.001 - ) - }) - - expect(replacementPill?.isClosed).toBe(true) - expect(copperFillShapes.length).toBeGreaterThan(1) - expect( - collectShapes(project.children).some((shape) => shape.cutIndex === 0), - ).toBe(true) -}) diff --git a/tests/svg.test.ts b/tests/svg.test.ts index 9251566..689d522 100644 --- a/tests/svg.test.ts +++ b/tests/svg.test.ts @@ -1,4 +1,6 @@ import { expect, test } from "bun:test" +import { LightBurnProject, ShapePath } from "lbrnts" +import { generateLightBurnSvgForPreview } from "../lib/helpers/generate-lightburn-svg-for-preview" const testSvg = ` @@ -9,3 +11,23 @@ test("svg snapshot example", async () => { // Subsequent runs will compare against the saved snapshot await expect(testSvg).toMatchSvgSnapshot(import.meta.path) }) + +test("renders native T1 shapes in the preview palette", () => { + const project = new LightBurnProject() + const toolingShape = new ShapePath({ + cutIndex: 30, + isClosed: true, + verts: [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { x: 0, y: 1 }, + { x: 0, y: 0 }, + ], + prims: Array.from({ length: 5 }, () => ({ type: 0 })), + }) + project.children.push(toolingShape) + + expect(generateLightBurnSvgForPreview(project)).toContain("#F36926") + expect(toolingShape.cutIndex).toBe(30) +}) From d7cabfd371d75634f880bf0428c6be2057b1379a Mon Sep 17 00:00:00 2001 From: Sangit Manandhar Date: Fri, 24 Jul 2026 18:35:11 -0700 Subject: [PATCH 3/3] bump "circuit-json-to-lbrn": "^0.0.86", --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fddedc6..e88967f 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@tscircuit/plop": "^0.0.72", "circuit-json": "^0.0.403", - "circuit-json-to-lbrn": "^0.0.85", + "circuit-json-to-lbrn": "^0.0.86", "circuit-to-svg": "^0.0.344", "kicad-to-circuit-json": "^0.0.88", "kicadts": "^0.0.45",