From 23048239d822e62557465c93d068ea5a2b355d28 Mon Sep 17 00:00:00 2001 From: techmannih Date: Wed, 10 Jun 2026 08:17:09 +0530 Subject: [PATCH 1/3] Add pcb silkscreen graphic support --- lib/components/index.ts | 1 + .../primitive-components/SilkscreenGraphic.ts | 187 ++++++++++++++++++ lib/fiber/intrinsic-jsx.ts | 1 + lib/utils/createComponentsFromCircuitJson.ts | 9 + ...b-silkscreen-graphic-snapshot-pcb.snap.svg | 1 + .../pcb-silkscreen-graphic-snapshot.test.tsx | 58 ++++++ 6 files changed, 257 insertions(+) create mode 100644 lib/components/primitive-components/SilkscreenGraphic.ts create mode 100644 tests/utils/createComponentsFromCircuitJson/__snapshots__/pcb-silkscreen-graphic-snapshot-pcb.snap.svg create mode 100644 tests/utils/createComponentsFromCircuitJson/pcb-silkscreen-graphic-snapshot.test.tsx diff --git a/lib/components/index.ts b/lib/components/index.ts index b03e43ce2..54373d5e7 100644 --- a/lib/components/index.ts +++ b/lib/components/index.ts @@ -49,6 +49,7 @@ export { CourtyardCircle } from "./primitive-components/CourtyardCircle" export { CourtyardOutline } from "./primitive-components/CourtyardOutline" export { CourtyardRect } from "./primitive-components/CourtyardRect" export { SilkscreenCircle } from "./primitive-components/SilkscreenCircle" +export { SilkscreenGraphic } from "./primitive-components/SilkscreenGraphic" export { SilkscreenPath } from "./primitive-components/SilkscreenPath" export { SilkscreenRect } from "./primitive-components/SilkscreenRect" export { SilkscreenText } from "./primitive-components/SilkscreenText" diff --git a/lib/components/primitive-components/SilkscreenGraphic.ts b/lib/components/primitive-components/SilkscreenGraphic.ts new file mode 100644 index 000000000..2d347cc3f --- /dev/null +++ b/lib/components/primitive-components/SilkscreenGraphic.ts @@ -0,0 +1,187 @@ +import { + asset, + brep_shape, + type BRepShape, + type Ring, + visible_layer, +} from "circuit-json" +import { PrimitiveComponent } from "../base-components/PrimitiveComponent" +import { applyToPoint } from "transformation-matrix" +import { z } from "zod" + +const silkscreenGraphicProps = z.object({ + layer: visible_layer.optional(), + brepShape: brep_shape, + imageAsset: asset.optional(), +}) + +const transformRing = ( + ring: Ring, + transform: Parameters[0], +) => ({ + vertices: ring.vertices.map((vertex) => { + const transformed = applyToPoint(transform, { x: vertex.x, y: vertex.y }) + return { + ...vertex, + x: transformed.x, + y: transformed.y, + } + }), +}) + +const translateBrepShape = ( + brepShape: BRepShape, + deltaX: number, + deltaY: number, +): BRepShape => ({ + outer_ring: { + vertices: brepShape.outer_ring.vertices.map((vertex) => ({ + ...vertex, + x: vertex.x + deltaX, + y: vertex.y + deltaY, + })), + }, + inner_rings: brepShape.inner_rings.map((ring) => ({ + vertices: ring.vertices.map((vertex) => ({ + ...vertex, + x: vertex.x + deltaX, + y: vertex.y + deltaY, + })), + })), +}) + +const getBrepVertices = (brepShape: BRepShape) => [ + ...brepShape.outer_ring.vertices, + ...brepShape.inner_rings.flatMap((ring) => ring.vertices), +] + +const getBrepCenter = (brepShape: BRepShape) => { + const vertices = getBrepVertices(brepShape) + if (vertices.length === 0) return { x: 0, y: 0 } + + let x = 0 + let y = 0 + for (const vertex of vertices) { + x += vertex.x + y += vertex.y + } + + return { + x: x / vertices.length, + y: y / vertices.length, + } +} + +export class SilkscreenGraphic extends PrimitiveComponent< + typeof silkscreenGraphicProps +> { + pcb_silkscreen_graphic_id: string | null = null + isPcbPrimitive = true + + get config() { + return { + componentName: "SilkscreenGraphic", + zodProps: silkscreenGraphicProps, + } + } + + doInitialPcbPrimitiveRender(): void { + if (this.root?.pcbDisabled) return + const { db } = this.root! + const { _parsedProps: props } = this + const { maybeFlipLayer } = this._getPcbPrimitiveFlippedHelpers() + const layer = maybeFlipLayer(props.layer ?? "top") as "top" | "bottom" + const transform = this._computePcbGlobalTransformBeforeLayout() + const subcircuit = this.getSubcircuit() + const group = this.getGroup() + + const pcb_component_id = + this.parent?.pcb_component_id ?? + this.getPrimitiveContainer()?.pcb_component_id! + + if (layer !== "top" && layer !== "bottom") { + throw new Error( + `Invalid layer "${layer}" for SilkscreenGraphic. Must be "top" or "bottom".`, + ) + } + + const pcbSilkscreenGraphic = db.pcb_silkscreen_graphic.insert({ + pcb_component_id, + pcb_group_id: group?.pcb_group_id ?? undefined, + subcircuit_id: subcircuit?.subcircuit_id ?? undefined, + layer, + shape: "brep", + image_asset: props.imageAsset, + brep_shape: { + outer_ring: transformRing(props.brepShape.outer_ring, transform), + inner_rings: props.brepShape.inner_rings.map((ring) => + transformRing(ring, transform), + ), + }, + }) + + this.pcb_silkscreen_graphic_id = + pcbSilkscreenGraphic.pcb_silkscreen_graphic_id + } + + _setPositionFromLayout(newCenter: { x: number; y: number }) { + const { db } = this.root! + if (!this.pcb_silkscreen_graphic_id) return + + const currentGraphic = db.pcb_silkscreen_graphic.get( + this.pcb_silkscreen_graphic_id, + ) + if (!currentGraphic) return + + const currentCenter = getBrepCenter(currentGraphic.brep_shape) + db.pcb_silkscreen_graphic.update(this.pcb_silkscreen_graphic_id, { + brep_shape: translateBrepShape( + currentGraphic.brep_shape, + newCenter.x - currentCenter.x, + newCenter.y - currentCenter.y, + ), + }) + } + + _moveCircuitJsonElements({ + deltaX, + deltaY, + }: { deltaX: number; deltaY: number }) { + if (this.root?.pcbDisabled) return + const { db } = this.root! + if (!this.pcb_silkscreen_graphic_id) return + + const graphic = db.pcb_silkscreen_graphic.get( + this.pcb_silkscreen_graphic_id, + ) + if (!graphic) return + + db.pcb_silkscreen_graphic.update(this.pcb_silkscreen_graphic_id, { + brep_shape: translateBrepShape(graphic.brep_shape, deltaX, deltaY), + }) + } + + getPcbSize(): { width: number; height: number } { + const vertices = getBrepVertices(this._parsedProps.brepShape) + if (vertices.length === 0) { + return { width: 0, height: 0 } + } + + let minX = Infinity + let maxX = -Infinity + let minY = Infinity + let maxY = -Infinity + + for (const vertex of vertices) { + minX = Math.min(minX, vertex.x) + maxX = Math.max(maxX, vertex.x) + minY = Math.min(minY, vertex.y) + maxY = Math.max(maxY, vertex.y) + } + + return { + width: maxX - minX, + height: maxY - minY, + } + } +} diff --git a/lib/fiber/intrinsic-jsx.ts b/lib/fiber/intrinsic-jsx.ts index 12b037552..6aee69b53 100644 --- a/lib/fiber/intrinsic-jsx.ts +++ b/lib/fiber/intrinsic-jsx.ts @@ -61,6 +61,7 @@ export interface TscircuitElements { silkscreenline: Props.SilkscreenLineProps silkscreenrect: Props.SilkscreenRectProps silkscreencircle: Props.SilkscreenCircleProps + silkscreengraphic: Props.SilkscreenGraphicProps tracehint: Props.TraceHintProps courtyardcircle: Props.CourtyardCircleProps courtyardoutline: Props.CourtyardOutlineProps diff --git a/lib/utils/createComponentsFromCircuitJson.ts b/lib/utils/createComponentsFromCircuitJson.ts index d82de05da..9f893fbff 100644 --- a/lib/utils/createComponentsFromCircuitJson.ts +++ b/lib/utils/createComponentsFromCircuitJson.ts @@ -25,6 +25,7 @@ import { SchematicPath } from "lib/components/primitive-components/SchematicPath import { SchematicRect } from "lib/components/primitive-components/SchematicRect" import { SchematicText } from "lib/components/primitive-components/SchematicText" import { SilkscreenCircle } from "lib/components/primitive-components/SilkscreenCircle" +import { SilkscreenGraphic } from "lib/components/primitive-components/SilkscreenGraphic" import { SilkscreenLine } from "lib/components/primitive-components/SilkscreenLine" import { SilkscreenPath } from "lib/components/primitive-components/SilkscreenPath" import { SilkscreenRect } from "lib/components/primitive-components/SilkscreenRect" @@ -229,6 +230,14 @@ export const createComponentsFromCircuitJson = ( strokeWidth: elm.stroke_width, }), ) + } else if (elm.type === "pcb_silkscreen_graphic" && elm.shape === "brep") { + components.push( + new SilkscreenGraphic({ + layer: elm.layer, + brepShape: elm.brep_shape, + imageAsset: optional(elm.image_asset), + }), + ) } else if (elm.type === "pcb_copper_text") { components.push( new CopperText({ diff --git a/tests/utils/createComponentsFromCircuitJson/__snapshots__/pcb-silkscreen-graphic-snapshot-pcb.snap.svg b/tests/utils/createComponentsFromCircuitJson/__snapshots__/pcb-silkscreen-graphic-snapshot-pcb.snap.svg new file mode 100644 index 000000000..808a0aeb8 --- /dev/null +++ b/tests/utils/createComponentsFromCircuitJson/__snapshots__/pcb-silkscreen-graphic-snapshot-pcb.snap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/utils/createComponentsFromCircuitJson/pcb-silkscreen-graphic-snapshot.test.tsx b/tests/utils/createComponentsFromCircuitJson/pcb-silkscreen-graphic-snapshot.test.tsx new file mode 100644 index 000000000..c62833f2b --- /dev/null +++ b/tests/utils/createComponentsFromCircuitJson/pcb-silkscreen-graphic-snapshot.test.tsx @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("silkscreengraphic renders in PCB snapshots", async () => { + const { circuit } = getTestFixture() + + circuit.add( + + + + , + ) + + await circuit.renderUntilSettled() + + const circuitJson = circuit.getCircuitJson() + const silkscreenGraphics = circuitJson.filter( + (elm) => elm.type === "pcb_silkscreen_graphic", + ) + + expect(silkscreenGraphics).toHaveLength(2) + await expect(circuitJson).toMatchPcbSnapshot(import.meta.path) +}) From 74e532749a783b220cedf2ac6fc598f037b6e064 Mon Sep 17 00:00:00 2001 From: techmannih Date: Thu, 11 Jun 2026 10:31:21 +0530 Subject: [PATCH 2/3] update --- .../primitive-components/SilkscreenGraphic.ts | 247 ++++++-- lib/utils/svg/svg-to-brep-shapes.ts | 569 ++++++++++++++++++ package.json | 1 + ...-silkscreen-graphic-svg-image-pcb.snap.svg | 1 + .../pcb-silkscreen-graphic-svg-image.test.tsx | 35 ++ tests/fixtures/assets/silkscreen-logo.svg | 12 + .../pcb-silkscreen-graphic-snapshot.test.tsx | 54 +- .../svg/svg-to-brep-shapes-fill-rule.test.ts | 75 +++ 8 files changed, 914 insertions(+), 80 deletions(-) create mode 100644 lib/utils/svg/svg-to-brep-shapes.ts create mode 100644 tests/components/primitive-components/__snapshots__/pcb-silkscreen-graphic-svg-image-pcb.snap.svg create mode 100644 tests/components/primitive-components/pcb-silkscreen-graphic-svg-image.test.tsx create mode 100644 tests/fixtures/assets/silkscreen-logo.svg create mode 100644 tests/utils/svg/svg-to-brep-shapes-fill-rule.test.ts diff --git a/lib/components/primitive-components/SilkscreenGraphic.ts b/lib/components/primitive-components/SilkscreenGraphic.ts index 2d347cc3f..21a40b1bf 100644 --- a/lib/components/primitive-components/SilkscreenGraphic.ts +++ b/lib/components/primitive-components/SilkscreenGraphic.ts @@ -1,3 +1,7 @@ +import { + type SilkscreenGraphicProps as PublicSilkscreenGraphicProps, + silkscreenGraphicProps as publicSilkscreenGraphicProps, +} from "@tscircuit/props" import { asset, brep_shape, @@ -8,13 +12,34 @@ import { import { PrimitiveComponent } from "../base-components/PrimitiveComponent" import { applyToPoint } from "transformation-matrix" import { z } from "zod" +import { resolveStaticFileImport } from "lib/utils/resolveStaticFileImport" +import { svgToBrepShapes } from "lib/utils/svg/svg-to-brep-shapes" -const silkscreenGraphicProps = z.object({ +const internalImportedSilkscreenGraphicProps = z.object({ layer: visible_layer.optional(), brepShape: brep_shape, imageAsset: asset.optional(), }) +const silkscreenGraphicProps = z.union([ + publicSilkscreenGraphicProps, + internalImportedSilkscreenGraphicProps, +]) + +type ParsedSilkscreenGraphicProps = z.infer + +const isImageSilkscreenGraphicProps = ( + props: ParsedSilkscreenGraphicProps, +): props is Extract => + "imageUrl" in props && typeof props.imageUrl === "string" + +const isImportedBrepSilkscreenGraphicProps = ( + props: ParsedSilkscreenGraphicProps, +): props is z.infer => + "brepShape" in props && + Boolean(props.brepShape) && + !("imageUrl" in props && typeof props.imageUrl === "string") + const transformRing = ( ring: Ring, transform: Parameters[0], @@ -29,6 +54,31 @@ const transformRing = ( }), }) +const getBoundsFromVertices = ( + vertices: Array<{ x: number; y: number }>, + transform: Parameters[0], +) => { + if (vertices.length === 0) return { width: 0, height: 0 } + + let minX = Infinity + let maxX = -Infinity + let minY = Infinity + let maxY = -Infinity + + for (const vertex of vertices) { + const transformed = applyToPoint(transform, vertex) + minX = Math.min(minX, transformed.x) + maxX = Math.max(maxX, transformed.x) + minY = Math.min(minY, transformed.y) + maxY = Math.max(maxY, transformed.y) + } + + return { + width: maxX - minX, + height: maxY - minY, + } +} + const translateBrepShape = ( brepShape: BRepShape, deltaX: number, @@ -75,8 +125,9 @@ const getBrepCenter = (brepShape: BRepShape) => { export class SilkscreenGraphic extends PrimitiveComponent< typeof silkscreenGraphicProps > { - pcb_silkscreen_graphic_id: string | null = null + pcb_silkscreen_graphic_ids: string[] = [] isPcbPrimitive = true + _hasStartedImageLoad = false get config() { return { @@ -87,17 +138,9 @@ export class SilkscreenGraphic extends PrimitiveComponent< doInitialPcbPrimitiveRender(): void { if (this.root?.pcbDisabled) return - const { db } = this.root! const { _parsedProps: props } = this const { maybeFlipLayer } = this._getPcbPrimitiveFlippedHelpers() const layer = maybeFlipLayer(props.layer ?? "top") as "top" | "bottom" - const transform = this._computePcbGlobalTransformBeforeLayout() - const subcircuit = this.getSubcircuit() - const group = this.getGroup() - - const pcb_component_id = - this.parent?.pcb_component_id ?? - this.getPrimitiveContainer()?.pcb_component_id! if (layer !== "top" && layer !== "bottom") { throw new Error( @@ -105,42 +148,121 @@ export class SilkscreenGraphic extends PrimitiveComponent< ) } - const pcbSilkscreenGraphic = db.pcb_silkscreen_graphic.insert({ - pcb_component_id, - pcb_group_id: group?.pcb_group_id ?? undefined, - subcircuit_id: subcircuit?.subcircuit_id ?? undefined, - layer, - shape: "brep", - image_asset: props.imageAsset, - brep_shape: { - outer_ring: transformRing(props.brepShape.outer_ring, transform), - inner_rings: props.brepShape.inner_rings.map((ring) => - transformRing(ring, transform), - ), - }, + if (isImportedBrepSilkscreenGraphicProps(props)) { + if (this.pcb_silkscreen_graphic_ids.length > 0) return + this._insertBrepShapes([props.brepShape], layer, props.imageAsset) + return + } + + if (!isImageSilkscreenGraphicProps(props)) { + throw new Error( + "SilkscreenGraphic must receive either imageUrl/width/height or an internal brepShape", + ) + } + + if (this._hasStartedImageLoad) return + this._hasStartedImageLoad = true + + this._queueAsyncEffect("load-silkscreen-graphic-image", async () => { + const resolvedUrl = await resolveStaticFileImport( + props.imageUrl, + this.root?.platform, + ) + + const response = await fetch(resolvedUrl) + if (!response.ok) { + throw new Error( + `Failed to fetch silkscreen graphic "${resolvedUrl}": ${response.status}`, + ) + } + + const svgContent = await response.text() + const brepShapes = svgToBrepShapes(svgContent, { + width: props.width, + height: props.height, + }) + + this._insertBrepShapes(brepShapes, layer, { + project_relative_path: props.imageUrl, + url: resolvedUrl, + mimetype: + response.headers.get("content-type") || + (resolvedUrl.toLowerCase().endsWith(".svg") + ? "image/svg+xml" + : "application/octet-stream"), + }) }) + } - this.pcb_silkscreen_graphic_id = - pcbSilkscreenGraphic.pcb_silkscreen_graphic_id + private _insertBrepShapes( + brepShapes: BRepShape[], + layer: "top" | "bottom", + imageAsset: z.infer | undefined, + ) { + if (brepShapes.length === 0) { + throw new Error("SilkscreenGraphic requires at least one BRep shape") + } + + const { db } = this.root! + const transform = this._computePcbGlobalTransformBeforeLayout() + const subcircuit = this.getSubcircuit() + const group = this.getGroup() + const pcb_component_id = + this.parent?.pcb_component_id ?? + this.getPrimitiveContainer()?.pcb_component_id! + + for (const brepShape of brepShapes) { + const pcbSilkscreenGraphic = db.pcb_silkscreen_graphic.insert({ + pcb_component_id, + pcb_group_id: group?.pcb_group_id ?? undefined, + subcircuit_id: subcircuit?.subcircuit_id ?? undefined, + layer, + shape: "brep", + image_asset: imageAsset, + brep_shape: { + outer_ring: transformRing(brepShape.outer_ring, transform), + inner_rings: brepShape.inner_rings.map((ring) => + transformRing(ring, transform), + ), + }, + }) + + this.pcb_silkscreen_graphic_ids.push( + pcbSilkscreenGraphic.pcb_silkscreen_graphic_id, + ) + } } _setPositionFromLayout(newCenter: { x: number; y: number }) { const { db } = this.root! - if (!this.pcb_silkscreen_graphic_id) return + if (this.pcb_silkscreen_graphic_ids.length === 0) return - const currentGraphic = db.pcb_silkscreen_graphic.get( - this.pcb_silkscreen_graphic_id, - ) - if (!currentGraphic) return - - const currentCenter = getBrepCenter(currentGraphic.brep_shape) - db.pcb_silkscreen_graphic.update(this.pcb_silkscreen_graphic_id, { - brep_shape: translateBrepShape( - currentGraphic.brep_shape, - newCenter.x - currentCenter.x, - newCenter.y - currentCenter.y, + const currentShapes = this.pcb_silkscreen_graphic_ids + .map((id) => db.pcb_silkscreen_graphic.get(id)) + .filter(Boolean) + + if (currentShapes.length === 0) return + + const currentCenter = getBrepCenter({ + outer_ring: { + vertices: currentShapes.flatMap( + (shape) => shape!.brep_shape.outer_ring.vertices, + ), + }, + inner_rings: currentShapes.flatMap( + (shape) => shape!.brep_shape.inner_rings, ), }) + + for (const graphic of currentShapes) { + db.pcb_silkscreen_graphic.update(graphic!.pcb_silkscreen_graphic_id, { + brep_shape: translateBrepShape( + graphic!.brep_shape, + newCenter.x - currentCenter.x, + newCenter.y - currentCenter.y, + ), + }) + } } _moveCircuitJsonElements({ @@ -149,39 +271,36 @@ export class SilkscreenGraphic extends PrimitiveComponent< }: { deltaX: number; deltaY: number }) { if (this.root?.pcbDisabled) return const { db } = this.root! - if (!this.pcb_silkscreen_graphic_id) return - - const graphic = db.pcb_silkscreen_graphic.get( - this.pcb_silkscreen_graphic_id, - ) - if (!graphic) return + for (const id of this.pcb_silkscreen_graphic_ids) { + const graphic = db.pcb_silkscreen_graphic.get(id) + if (!graphic) continue - db.pcb_silkscreen_graphic.update(this.pcb_silkscreen_graphic_id, { - brep_shape: translateBrepShape(graphic.brep_shape, deltaX, deltaY), - }) + db.pcb_silkscreen_graphic.update(id, { + brep_shape: translateBrepShape(graphic.brep_shape, deltaX, deltaY), + }) + } } getPcbSize(): { width: number; height: number } { - const vertices = getBrepVertices(this._parsedProps.brepShape) - if (vertices.length === 0) { - return { width: 0, height: 0 } - } - - let minX = Infinity - let maxX = -Infinity - let minY = Infinity - let maxY = -Infinity + const transform = this._computePcbGlobalTransformBeforeLayout() - for (const vertex of vertices) { - minX = Math.min(minX, vertex.x) - maxX = Math.max(maxX, vertex.x) - minY = Math.min(minY, vertex.y) - maxY = Math.max(maxY, vertex.y) + if (isImageSilkscreenGraphicProps(this._parsedProps)) { + const halfWidth = this._parsedProps.width / 2 + const halfHeight = this._parsedProps.height / 2 + return getBoundsFromVertices( + [ + { x: -halfWidth, y: -halfHeight }, + { x: halfWidth, y: -halfHeight }, + { x: halfWidth, y: halfHeight }, + { x: -halfWidth, y: halfHeight }, + ], + transform, + ) } - return { - width: maxX - minX, - height: maxY - minY, - } + return getBoundsFromVertices( + getBrepVertices(this._parsedProps.brepShape), + transform, + ) } } diff --git a/lib/utils/svg/svg-to-brep-shapes.ts b/lib/utils/svg/svg-to-brep-shapes.ts new file mode 100644 index 000000000..ffe69fdec --- /dev/null +++ b/lib/utils/svg/svg-to-brep-shapes.ts @@ -0,0 +1,569 @@ +import type { BRepShape } from "circuit-json" +import { XMLParser } from "fast-xml-parser" +import { svgPathToPoints } from "lib/utils/schematic/svgPathToPoints" +import { + applyToPoint, + compose, + fromDefinition, + fromTransformAttribute, + identity, + type Matrix, +} from "transformation-matrix" + +type Point = { x: number; y: number } +type Polygon = Point[] +type XmlScalar = string | number | boolean | null | undefined +type XmlNodeValue = XmlScalar | XmlNode | Array +interface XmlNode { + [key: string]: XmlNodeValue +} +type FillRule = "evenodd" | "nonzero" +type CollectedElement = { + polygons: Polygon[] + fillRule: FillRule +} + +const xmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", + parseTagValue: false, + trimValues: true, + allowBooleanAttributes: true, +}) + +const ensureArray = (value: T | T[] | undefined): T[] => + value === undefined ? [] : Array.isArray(value) ? value : [value] + +const parseNumber = (value: XmlNodeValue, fallback = 0): number => { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string") { + const parsed = Number.parseFloat(value) + if (Number.isFinite(parsed)) return parsed + } + return fallback +} + +const parseStyle = (style: XmlNodeValue): Record => { + if (typeof style !== "string") return {} + + const entries = style + .split(";") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const [key, ...valueParts] = entry.split(":") + return [key?.trim() ?? "", valueParts.join(":").trim()] as const + }) + .filter(([key, value]) => key && value) + + return Object.fromEntries(entries) +} + +const getAttr = ( + node: XmlNode, + name: string, + style: Record, +): string | undefined => { + const attrValue = node[`@_${name}`] + if (typeof attrValue === "string") return attrValue + if (typeof attrValue === "number") return String(attrValue) + return style[name] +} + +const isZeroLike = (value: string | undefined) => + value !== undefined && Math.abs(parseNumber(value, Number.NaN)) < 1e-9 + +const isHiddenNode = (node: XmlNode): boolean => { + const style = parseStyle(node["@_style"]) + const display = getAttr(node, "display", style) + const visibility = getAttr(node, "visibility", style) + const opacity = getAttr(node, "opacity", style) + + return ( + display === "none" || + visibility === "hidden" || + visibility === "collapse" || + isZeroLike(opacity) + ) +} + +const hasVisibleFill = (node: XmlNode): boolean => { + const style = parseStyle(node["@_style"]) + const fill = getAttr(node, "fill", style) + const fillOpacity = getAttr(node, "fill-opacity", style) + const opacity = getAttr(node, "opacity", style) + + if (fill === "none") return false + if (isZeroLike(fillOpacity) || isZeroLike(opacity)) return false + return true +} + +const getFillRule = (node: XmlNode): FillRule => { + const style = parseStyle(node["@_style"]) + const fillRule = getAttr(node, "fill-rule", style)?.toLowerCase() + return fillRule === "evenodd" || fillRule === "even-odd" + ? "evenodd" + : "nonzero" +} + +const parseTransform = (transformAttr: XmlNodeValue): Matrix => { + if (typeof transformAttr !== "string" || transformAttr.trim() === "") { + return identity() + } + + const definitions = fromTransformAttribute(transformAttr) + if (definitions.length === 0) return identity() + + return compose(...fromDefinition(definitions)) +} + +const dedupePolygon = (points: Polygon): Polygon => { + const deduped: Polygon = [] + + for (const point of points) { + const previous = deduped[deduped.length - 1] + if ( + !previous || + Math.abs(previous.x - point.x) > 1e-9 || + Math.abs(previous.y - point.y) > 1e-9 + ) { + deduped.push(point) + } + } + + if (deduped.length > 1) { + const first = deduped[0]! + const last = deduped[deduped.length - 1]! + if ( + Math.abs(first.x - last.x) < 1e-9 && + Math.abs(first.y - last.y) < 1e-9 + ) { + deduped.pop() + } + } + + return deduped +} + +const closePolygon = (points: Polygon): Polygon => { + if (points.length < 2) return points + const first = points[0]! + const last = points[points.length - 1]! + + if (Math.abs(first.x - last.x) < 1e-9 && Math.abs(first.y - last.y) < 1e-9) { + return dedupePolygon(points) + } + + return dedupePolygon([...points, first]) +} + +const polygonArea = (points: Polygon): number => { + if (points.length < 3) return 0 + + let area = 0 + for (let i = 0; i < points.length; i++) { + const current = points[i]! + const next = points[(i + 1) % points.length]! + area += current.x * next.y - next.x * current.y + } + return area / 2 +} + +const orientPolygon = (points: Polygon, clockwise: boolean): Polygon => { + const area = polygonArea(points) + if (area === 0) return points + const isClockwise = area < 0 + return isClockwise === clockwise ? points : [...points].reverse() +} + +const pointInPolygon = (point: Point, polygon: Polygon): boolean => { + let inside = false + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const a = polygon[i]! + const b = polygon[j]! + + const intersects = + a.y > point.y !== b.y > point.y && + point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x + + if (intersects) inside = !inside + } + + return inside +} + +const getPolygonBounds = (polygons: Polygon[]) => { + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + + for (const polygon of polygons) { + for (const point of polygon) { + minX = Math.min(minX, point.x) + minY = Math.min(minY, point.y) + maxX = Math.max(maxX, point.x) + maxY = Math.max(maxY, point.y) + } + } + + return { + minX, + minY, + width: maxX - minX, + height: maxY - minY, + } +} + +const parsePointList = (value: XmlNodeValue): Polygon => { + if (typeof value !== "string") return [] + + const numbers = value + .trim() + .split(/[\s,]+/) + .map((part) => Number.parseFloat(part)) + .filter((num) => Number.isFinite(num)) + + const points: Polygon = [] + for (let i = 0; i + 1 < numbers.length; i += 2) { + points.push({ x: numbers[i]!, y: numbers[i + 1]! }) + } + + return points +} + +const rectToPath = (node: XmlNode): string => { + const x = parseNumber(node["@_x"]) + const y = parseNumber(node["@_y"]) + const width = parseNumber(node["@_width"]) + const height = parseNumber(node["@_height"]) + let rx = parseNumber(node["@_rx"]) + let ry = parseNumber(node["@_ry"]) + + if (rx === 0 && ry > 0) rx = ry + if (ry === 0 && rx > 0) ry = rx + + rx = Math.max(0, Math.min(rx, width / 2)) + ry = Math.max(0, Math.min(ry, height / 2)) + + if (rx === 0 && ry === 0) { + return `M ${x} ${y} H ${x + width} V ${y + height} H ${x} Z` + } + + return [ + `M ${x + rx} ${y}`, + `H ${x + width - rx}`, + `A ${rx} ${ry} 0 0 1 ${x + width} ${y + ry}`, + `V ${y + height - ry}`, + `A ${rx} ${ry} 0 0 1 ${x + width - rx} ${y + height}`, + `H ${x + rx}`, + `A ${rx} ${ry} 0 0 1 ${x} ${y + height - ry}`, + `V ${y + ry}`, + `A ${rx} ${ry} 0 0 1 ${x + rx} ${y}`, + "Z", + ].join(" ") +} + +const circleToPath = (node: XmlNode): string => { + const cx = parseNumber(node["@_cx"]) + const cy = parseNumber(node["@_cy"]) + const r = parseNumber(node["@_r"]) + + return [ + `M ${cx + r} ${cy}`, + `A ${r} ${r} 0 1 0 ${cx - r} ${cy}`, + `A ${r} ${r} 0 1 0 ${cx + r} ${cy}`, + "Z", + ].join(" ") +} + +const ellipseToPath = (node: XmlNode): string => { + const cx = parseNumber(node["@_cx"]) + const cy = parseNumber(node["@_cy"]) + const rx = parseNumber(node["@_rx"]) + const ry = parseNumber(node["@_ry"]) + + return [ + `M ${cx + rx} ${cy}`, + `A ${rx} ${ry} 0 1 0 ${cx - rx} ${cy}`, + `A ${rx} ${ry} 0 1 0 ${cx + rx} ${cy}`, + "Z", + ].join(" ") +} + +const pathToPolygons = (path: string): Polygon[] => + svgPathToPoints(path) + .map(closePolygon) + .filter( + (polygon) => polygon.length >= 3 && Math.abs(polygonArea(polygon)) > 1e-6, + ) + +const getFilledPolygonsForTag = (tagName: string, node: XmlNode): Polygon[] => { + if (isHiddenNode(node) || !hasVisibleFill(node)) return [] + + if (tagName === "path" && typeof node["@_d"] === "string") { + return pathToPolygons(node["@_d"]) + } + + if (tagName === "rect") { + return pathToPolygons(rectToPath(node)) + } + + if (tagName === "circle") { + return pathToPolygons(circleToPath(node)) + } + + if (tagName === "ellipse") { + return pathToPolygons(ellipseToPath(node)) + } + + if (tagName === "polygon") { + const polygon = closePolygon(parsePointList(node["@_points"])) + return polygon.length >= 3 && Math.abs(polygonArea(polygon)) > 1e-6 + ? [polygon] + : [] + } + + return [] +} + +const applyTransformToPolygon = ( + polygon: Polygon, + transform: Matrix, +): Polygon => polygon.map((point) => applyToPoint(transform, point)) + +const collectElements = ( + tagName: string, + node: XmlNode, + inheritedTransform: Matrix, + elements: CollectedElement[], +) => { + const currentTransform = compose( + inheritedTransform, + parseTransform(node["@_transform"]), + ) + + const transformedPolygons = getFilledPolygonsForTag(tagName, node).map( + (polygon) => applyTransformToPolygon(polygon, currentTransform), + ) + if (transformedPolygons.length > 0) { + elements.push({ + polygons: transformedPolygons, + fillRule: getFillRule(node), + }) + } + + for (const [childTagName, childValue] of Object.entries(node)) { + if (childTagName.startsWith("@_") || childTagName === "#text") continue + + for (const child of ensureArray(childValue)) { + if (child && typeof child === "object") { + collectElements( + childTagName, + child as XmlNode, + currentTransform, + elements, + ) + } + } + } +} + +const parseViewBox = ( + root: XmlNode, +): { minX: number; minY: number; width: number; height: number } | null => { + const viewBox = root["@_viewBox"] + if (typeof viewBox === "string") { + const numbers = viewBox + .trim() + .split(/[\s,]+/) + .map((value) => Number.parseFloat(value)) + .filter((num) => Number.isFinite(num)) + if (numbers.length === 4 && numbers[2]! > 0 && numbers[3]! > 0) { + return { + minX: numbers[0]!, + minY: numbers[1]!, + width: numbers[2]!, + height: numbers[3]!, + } + } + } + + const width = parseNumber(root["@_width"], Number.NaN) + const height = parseNumber(root["@_height"], Number.NaN) + if ( + Number.isFinite(width) && + Number.isFinite(height) && + width > 0 && + height > 0 + ) { + return { minX: 0, minY: 0, width, height } + } + + return null +} + +type PolygonNode = { + polygon: Polygon + sign: number + children: PolygonNode[] +} + +const buildPolygonTree = (polygons: Polygon[]): PolygonNode[] => { + const sortedPolygons = [...polygons].sort( + (a, b) => Math.abs(polygonArea(b)) - Math.abs(polygonArea(a)), + ) + + const roots: PolygonNode[] = [] + + const insertNode = (node: PolygonNode, candidates: PolygonNode[]) => { + for (const candidate of candidates) { + if (pointInPolygon(node.polygon[0]!, candidate.polygon)) { + insertNode(node, candidate.children) + return + } + } + candidates.push(node) + } + + for (const polygon of sortedPolygons) { + insertNode( + { + polygon, + sign: polygonArea(polygon) >= 0 ? 1 : -1, + children: [], + }, + roots, + ) + } + + return roots +} + +const polygonsToBrepShapesEvenOdd = (roots: PolygonNode[]): BRepShape[] => { + const shapes: BRepShape[] = [] + + const visitNode = (node: PolygonNode, depth: number) => { + if (depth % 2 === 0) { + shapes.push({ + outer_ring: { + vertices: orientPolygon(node.polygon, false), + }, + inner_rings: node.children.map((child) => ({ + vertices: orientPolygon(child.polygon, true), + })), + }) + } + + for (const child of node.children) { + for (const grandchild of child.children) { + visitNode(grandchild, depth + 2) + } + } + } + + for (const node of roots) { + visitNode(node, 0) + } + + return shapes +} + +const polygonsToBrepShapesNonZero = (roots: PolygonNode[]): BRepShape[] => { + const shapes: BRepShape[] = [] + + const visitNode = (node: PolygonNode, parentWinding: number) => { + const winding = parentWinding + node.sign + + if (parentWinding === 0 && winding !== 0) { + shapes.push({ + outer_ring: { + vertices: orientPolygon(node.polygon, false), + }, + inner_rings: node.children + .filter((child) => winding + child.sign === 0) + .map((child) => ({ + vertices: orientPolygon(child.polygon, true), + })), + }) + } + + for (const child of node.children) { + visitNode(child, winding) + } + } + + for (const node of roots) { + visitNode(node, 0) + } + + return shapes +} + +const polygonsToBrepShapes = ( + polygons: Polygon[], + fillRule: FillRule, +): BRepShape[] => { + const roots = buildPolygonTree(polygons) + return fillRule === "evenodd" + ? polygonsToBrepShapesEvenOdd(roots) + : polygonsToBrepShapesNonZero(roots) +} + +export const svgToBrepShapes = ( + svgContent: string, + { + width, + height, + }: { + width: number + height: number + }, +): BRepShape[] => { + const parsed = xmlParser.parse(svgContent) as { svg?: XmlNode } + const root = parsed.svg + + if (!root) { + throw new Error("Silkscreen graphic loader expected an SVG document") + } + + const collectedElements: CollectedElement[] = [] + collectElements("svg", root, identity(), collectedElements) + + const sourcePolygons = collectedElements.flatMap( + (element) => element.polygons, + ) + + if (sourcePolygons.length === 0) { + throw new Error("SVG does not contain any filled geometry to convert") + } + + const sourceBounds = parseViewBox(root) ?? getPolygonBounds(sourcePolygons) + if (sourceBounds.width <= 0 || sourceBounds.height <= 0) { + throw new Error("SVG has invalid bounds for silkscreen conversion") + } + + const centerX = sourceBounds.minX + sourceBounds.width / 2 + const centerY = sourceBounds.minY + sourceBounds.height / 2 + const scaleX = width / sourceBounds.width + const scaleY = height / sourceBounds.height + + return collectedElements.flatMap((element) => + polygonsToBrepShapes( + element.polygons + .map((polygon) => + polygon.map((point) => ({ + x: (point.x - centerX) * scaleX, + y: (centerY - point.y) * scaleY, + })), + ) + .map(closePolygon) + .filter( + (polygon) => + polygon.length >= 3 && Math.abs(polygonArea(polygon)) > 1e-6, + ), + element.fillRule, + ), + ) +} diff --git a/package.json b/package.json index 37afc09ad..13779126a 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "calculate-cell-boundaries": "^0.0.13", "calculate-packing": "0.0.73", "css-select": "5.1.0", + "fast-xml-parser": "^5.3.1", "format-si-unit": "^0.0.3", "nanoid": "^5.0.7", "performance-now": "^2.1.0", diff --git a/tests/components/primitive-components/__snapshots__/pcb-silkscreen-graphic-svg-image-pcb.snap.svg b/tests/components/primitive-components/__snapshots__/pcb-silkscreen-graphic-svg-image-pcb.snap.svg new file mode 100644 index 000000000..49feebd3f --- /dev/null +++ b/tests/components/primitive-components/__snapshots__/pcb-silkscreen-graphic-svg-image-pcb.snap.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/components/primitive-components/pcb-silkscreen-graphic-svg-image.test.tsx b/tests/components/primitive-components/pcb-silkscreen-graphic-svg-image.test.tsx new file mode 100644 index 000000000..b021f6c37 --- /dev/null +++ b/tests/components/primitive-components/pcb-silkscreen-graphic-svg-image.test.tsx @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +import "lib/register-catalogue" + +test("silkscreengraphic converts an SVG asset into pcb_silkscreen_graphic breps", async () => { + const { circuit, staticAssetsServerUrl } = getTestFixture({ + withStaticAssetsServer: true, + }) + + circuit.add( + + + , + ) + + await circuit.renderUntilSettled() + + const graphics = circuit.db.pcb_silkscreen_graphic.list() + + expect(graphics).toHaveLength(2) + expect(graphics.every((graphic) => graphic.shape === "brep")).toBe(true) + expect( + graphics.some((graphic) => graphic.brep_shape.inner_rings.length === 1), + ).toBe(true) + + await expect(circuit.getCircuitJson()).toMatchPcbSnapshot(import.meta.path) +}) diff --git a/tests/fixtures/assets/silkscreen-logo.svg b/tests/fixtures/assets/silkscreen-logo.svg new file mode 100644 index 000000000..903539f13 --- /dev/null +++ b/tests/fixtures/assets/silkscreen-logo.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/tests/utils/createComponentsFromCircuitJson/pcb-silkscreen-graphic-snapshot.test.tsx b/tests/utils/createComponentsFromCircuitJson/pcb-silkscreen-graphic-snapshot.test.tsx index c62833f2b..774497bca 100644 --- a/tests/utils/createComponentsFromCircuitJson/pcb-silkscreen-graphic-snapshot.test.tsx +++ b/tests/utils/createComponentsFromCircuitJson/pcb-silkscreen-graphic-snapshot.test.tsx @@ -1,14 +1,22 @@ import { expect, test } from "bun:test" +import type { AnyCircuitElement } from "circuit-json" +import { createComponentsFromCircuitJson } from "lib/utils/createComponentsFromCircuitJson" import { getTestFixture } from "tests/fixtures/get-test-fixture" -test("silkscreengraphic renders in PCB snapshots", async () => { - const { circuit } = getTestFixture() - - circuit.add( - - { + const components = createComponentsFromCircuitJson( + { + componentName: "imported_silkscreen_graphic", + componentRotation: "0", + }, + [ + { + type: "pcb_silkscreen_graphic", + pcb_silkscreen_graphic_id: "pcb_silkscreen_graphic_0", + pcb_component_id: "pcb_component_0", + layer: "top", + shape: "brep", + brep_shape: { outer_ring: { vertices: [ { x: -7, y: -2 }, @@ -27,11 +35,15 @@ test("silkscreengraphic renders in PCB snapshots", async () => { ], }, ], - }} - /> - { ], }, inner_rings: [], - }} - /> - , + }, + }, + ] as AnyCircuitElement[], ) + const { circuit } = getTestFixture() + circuit.add() + + const board = circuit.children[0]! + + for (const component of components) { + board.add(component) + } + await circuit.renderUntilSettled() const circuitJson = circuit.getCircuitJson() @@ -53,6 +74,7 @@ test("silkscreengraphic renders in PCB snapshots", async () => { (elm) => elm.type === "pcb_silkscreen_graphic", ) + expect(components).toHaveLength(2) expect(silkscreenGraphics).toHaveLength(2) await expect(circuitJson).toMatchPcbSnapshot(import.meta.path) }) diff --git a/tests/utils/svg/svg-to-brep-shapes-fill-rule.test.ts b/tests/utils/svg/svg-to-brep-shapes-fill-rule.test.ts new file mode 100644 index 000000000..4c08aa892 --- /dev/null +++ b/tests/utils/svg/svg-to-brep-shapes-fill-rule.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test" +import { svgToBrepShapes } from "lib/utils/svg/svg-to-brep-shapes" + +test("svgToBrepShapes respects fill rules and does not merge nested separate elements into holes", () => { + const evenOddSvg = ` + + + + ` + + const nonZeroSameDirectionSvg = ` + + + + ` + + const nonZeroOppositeDirectionSvg = ` + + + + ` + + const separateNestedElementsSvg = ` + + + + + ` + + const evenOddShapes = svgToBrepShapes(evenOddSvg, { width: 10, height: 10 }) + const nonZeroSameDirectionShapes = svgToBrepShapes(nonZeroSameDirectionSvg, { + width: 10, + height: 10, + }) + const nonZeroOppositeDirectionShapes = svgToBrepShapes( + nonZeroOppositeDirectionSvg, + { + width: 10, + height: 10, + }, + ) + const separateNestedElementShapes = svgToBrepShapes( + separateNestedElementsSvg, + { + width: 10, + height: 10, + }, + ) + + expect(evenOddShapes).toHaveLength(1) + expect(evenOddShapes[0]!.inner_rings).toHaveLength(1) + + expect(nonZeroSameDirectionShapes).toHaveLength(1) + expect(nonZeroSameDirectionShapes[0]!.inner_rings).toHaveLength(0) + + expect(nonZeroOppositeDirectionShapes).toHaveLength(1) + expect(nonZeroOppositeDirectionShapes[0]!.inner_rings).toHaveLength(1) + + expect(separateNestedElementShapes).toHaveLength(2) + expect( + separateNestedElementShapes.every( + (shape) => shape.inner_rings.length === 0, + ), + ).toBe(true) +}) From 8975a901b7c92ad3c0f9122b8cf8462de8688a5d Mon Sep 17 00:00:00 2001 From: techmannih Date: Tue, 16 Jun 2026 14:41:15 +0530 Subject: [PATCH 3/3] Add bitmap-based silkscreen graphic import --- .../primitive-components/SilkscreenGraphic.ts | 9 +- .../image/silkscreen-graphics/bitmap-utils.ts | 268 ++++++++++++++++++ lib/utils/image/silkscreen-graphics/index.ts | 69 +++++ .../silkscreen-graphics/polygon-utils.ts | 151 ++++++++++ .../image/silkscreen-graphics/trace-utils.ts | 173 +++++++++++ lib/utils/image/silkscreen-graphics/types.ts | 61 ++++ package.json | 3 +- .../image-to-brep-shapes-png-hole.test.ts | 33 +++ 8 files changed, 763 insertions(+), 4 deletions(-) create mode 100644 lib/utils/image/silkscreen-graphics/bitmap-utils.ts create mode 100644 lib/utils/image/silkscreen-graphics/index.ts create mode 100644 lib/utils/image/silkscreen-graphics/polygon-utils.ts create mode 100644 lib/utils/image/silkscreen-graphics/trace-utils.ts create mode 100644 lib/utils/image/silkscreen-graphics/types.ts create mode 100644 tests/utils/image/image-to-brep-shapes-png-hole.test.ts diff --git a/lib/components/primitive-components/SilkscreenGraphic.ts b/lib/components/primitive-components/SilkscreenGraphic.ts index 21a40b1bf..bc7ed8df8 100644 --- a/lib/components/primitive-components/SilkscreenGraphic.ts +++ b/lib/components/primitive-components/SilkscreenGraphic.ts @@ -13,7 +13,7 @@ import { PrimitiveComponent } from "../base-components/PrimitiveComponent" import { applyToPoint } from "transformation-matrix" import { z } from "zod" import { resolveStaticFileImport } from "lib/utils/resolveStaticFileImport" -import { svgToBrepShapes } from "lib/utils/svg/svg-to-brep-shapes" +import { imageToBrepShapes } from "lib/utils/image/silkscreen-graphics" const internalImportedSilkscreenGraphicProps = z.object({ layer: visible_layer.optional(), @@ -176,8 +176,11 @@ export class SilkscreenGraphic extends PrimitiveComponent< ) } - const svgContent = await response.text() - const brepShapes = svgToBrepShapes(svgContent, { + const imageData = await response.arrayBuffer() + const brepShapes = await imageToBrepShapes({ + importedImageBytes: imageData, + contentType: response.headers.get("content-type") ?? undefined, + sourceName: resolvedUrl, width: props.width, height: props.height, }) diff --git a/lib/utils/image/silkscreen-graphics/bitmap-utils.ts b/lib/utils/image/silkscreen-graphics/bitmap-utils.ts new file mode 100644 index 000000000..bbc1ea132 --- /dev/null +++ b/lib/utils/image/silkscreen-graphics/bitmap-utils.ts @@ -0,0 +1,268 @@ +import { Resvg } from "@resvg/resvg-js" +import { decode } from "fast-png" +import type { + Bitmap, + BunRuntime, + GraphicTargetSize, + ImageFormat, + ImportedGraphicSource, + Rgba, +} from "./types" + +const PIXELS_PER_MM = 24 +const MIN_RENDER_DIMENSION_PX = 128 +const MAX_RENDER_DIMENSION_PX = 1024 +const MIN_VISIBLE_ALPHA = 24 +const MIN_BACKGROUND_DISTANCE = 48 + +const clamp = ({ + unclampedNumber, + min, + max, +}: { + unclampedNumber: number + min: number + max: number +}) => Math.max(min, Math.min(max, unclampedNumber)) + +const normalizeContentType = (contentType: string | undefined) => + contentType?.split(";")[0]?.trim().toLowerCase() + +export const getImageFormat = ( + graphicSource: ImportedGraphicSource, +): ImageFormat | null => { + const { contentType, sourceName } = graphicSource + const normalizedContentType = normalizeContentType(contentType) + if (normalizedContentType === "image/svg+xml") return "svg" + if (normalizedContentType === "image/png") return "png" + + const normalizedSourceName = sourceName?.toLowerCase() + if (normalizedSourceName?.endsWith(".svg")) return "svg" + if (normalizedSourceName?.endsWith(".png")) return "png" + + return null +} + +const getBunRuntime = (): BunRuntime | null => { + const runtime = (globalThis as typeof globalThis & { Bun?: BunRuntime }).Bun + return runtime?.Image ? runtime : null +} + +const getSvgRenderTarget = (width: number, height: number) => { + const longestEdgeMm = Math.max(width, height) + const longestEdgePixels = clamp({ + unclampedNumber: Math.ceil(longestEdgeMm * PIXELS_PER_MM), + min: MIN_RENDER_DIMENSION_PX, + max: MAX_RENDER_DIMENSION_PX, + }) + + return width >= height + ? { mode: "width" as const, value: longestEdgePixels } + : { mode: "height" as const, value: longestEdgePixels } +} + +const normalizeChannel = ({ + channelSample, + bitDepth, +}: { + channelSample: number + bitDepth: 8 | 16 +}) => (bitDepth === 16 ? Math.round(channelSample / 257) : channelSample) + +export const decodePngToBitmap = (pngBytes: Uint8Array): Bitmap => { + const decodedPng = decode(pngBytes) + const { width, height, channels } = decodedPng + const bitDepth = decodedPng.depth === 16 ? 16 : 8 + const decodedChannelSamples = decodedPng.data + const rgbaPixelBuffer = new Uint8Array(width * height * 4) + + for (let index = 0; index < width * height; index++) { + const sourceOffset = index * channels + const targetOffset = index * 4 + + if (channels === 1) { + const grayscaleChannel = normalizeChannel({ + channelSample: decodedChannelSamples[sourceOffset]!, + bitDepth, + }) + rgbaPixelBuffer[targetOffset] = grayscaleChannel + rgbaPixelBuffer[targetOffset + 1] = grayscaleChannel + rgbaPixelBuffer[targetOffset + 2] = grayscaleChannel + rgbaPixelBuffer[targetOffset + 3] = 255 + continue + } + + if (channels === 2) { + const grayscaleChannel = normalizeChannel({ + channelSample: decodedChannelSamples[sourceOffset]!, + bitDepth, + }) + rgbaPixelBuffer[targetOffset] = grayscaleChannel + rgbaPixelBuffer[targetOffset + 1] = grayscaleChannel + rgbaPixelBuffer[targetOffset + 2] = grayscaleChannel + rgbaPixelBuffer[targetOffset + 3] = normalizeChannel({ + channelSample: decodedChannelSamples[sourceOffset + 1]!, + bitDepth, + }) + continue + } + + if (channels === 3 || channels === 4) { + rgbaPixelBuffer[targetOffset] = normalizeChannel({ + channelSample: decodedChannelSamples[sourceOffset]!, + bitDepth, + }) + rgbaPixelBuffer[targetOffset + 1] = normalizeChannel({ + channelSample: decodedChannelSamples[sourceOffset + 1]!, + bitDepth, + }) + rgbaPixelBuffer[targetOffset + 2] = normalizeChannel({ + channelSample: decodedChannelSamples[sourceOffset + 2]!, + bitDepth, + }) + rgbaPixelBuffer[targetOffset + 3] = + channels === 4 + ? normalizeChannel({ + channelSample: decodedChannelSamples[sourceOffset + 3]!, + bitDepth, + }) + : 255 + continue + } + + throw new Error( + `Unsupported PNG channel count "${channels}" for silkscreen conversion`, + ) + } + + return { + width, + height, + rgbaPixels: rgbaPixelBuffer, + } +} + +const renderSvgToBitmap = ({ + svgText, + targetSize, +}: { + svgText: string + targetSize: GraphicTargetSize +}): Bitmap => { + const pngBytes = new Resvg(svgText, { + fitTo: getSvgRenderTarget(targetSize.width, targetSize.height), + }) + .render() + .asPng() + + return decodePngToBitmap(pngBytes) +} + +const convertRasterToBitmap = async ({ + imageBytes, + contentType, +}: { + imageBytes: Uint8Array + contentType: string | undefined +}): Promise => { + const normalizedContentType = normalizeContentType(contentType) + if (normalizedContentType === "image/png") { + return decodePngToBitmap(imageBytes) + } + + const bunRuntime = getBunRuntime() + if (!bunRuntime) { + throw new Error( + `Unsupported silkscreen graphic format "${contentType ?? "unknown"}". Use SVG or PNG when Bun image codecs are unavailable.`, + ) + } + + const pngBytes = await new bunRuntime.Image(imageBytes).png().bytes() + return decodePngToBitmap(pngBytes) +} + +const getPixel = ({ + bitmap, + x, + y, +}: { + bitmap: Bitmap + x: number + y: number +}): Rgba => { + const offset = (y * bitmap.width + x) * 4 + return { + r: bitmap.rgbaPixels[offset]!, + g: bitmap.rgbaPixels[offset + 1]!, + b: bitmap.rgbaPixels[offset + 2]!, + a: bitmap.rgbaPixels[offset + 3]!, + } +} + +const getBackgroundColor = (bitmap: Bitmap): Rgba => { + const samples = [ + getPixel({ bitmap, x: 0, y: 0 }), + getPixel({ bitmap, x: bitmap.width - 1, y: 0 }), + getPixel({ bitmap, x: 0, y: bitmap.height - 1 }), + getPixel({ bitmap, x: bitmap.width - 1, y: bitmap.height - 1 }), + ] + + return { + r: Math.round(samples.reduce((sum, sample) => sum + sample.r, 0) / 4), + g: Math.round(samples.reduce((sum, sample) => sum + sample.g, 0) / 4), + b: Math.round(samples.reduce((sum, sample) => sum + sample.b, 0) / 4), + a: Math.round(samples.reduce((sum, sample) => sum + sample.a, 0) / 4), + } +} + +const rgbaDistance = (a: Rgba, b: Rgba) => + Math.sqrt( + (a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2 + (a.a - b.a) ** 2, + ) + +export const createFilledMask = (bitmap: Bitmap): boolean[] => { + // Use the corner color as the background reference so transparent SVGs and + // black-on-white raster logos both collapse into a binary silkscreen mask. + const background = getBackgroundColor(bitmap) + const filledMask = new Array(bitmap.width * bitmap.height) + + for (let y = 0; y < bitmap.height; y++) { + for (let x = 0; x < bitmap.width; x++) { + const pixel = getPixel({ bitmap, x, y }) + const isVisible = pixel.a >= MIN_VISIBLE_ALPHA + const differsFromBackground = + rgbaDistance(pixel, background) >= MIN_BACKGROUND_DISTANCE + + filledMask[y * bitmap.width + x] = + isVisible && (background.a < MIN_VISIBLE_ALPHA || differsFromBackground) + } + } + + return filledMask +} + +export const imageBytesToBitmap = async ({ + imageBytes, + graphicSource, + targetSize, +}: { + imageBytes: Uint8Array + graphicSource: ImportedGraphicSource + targetSize: GraphicTargetSize +}): Promise => { + const format = getImageFormat(graphicSource) + + if (format === "svg") { + const svgText = new TextDecoder().decode(imageBytes) + return renderSvgToBitmap({ svgText, targetSize }) + } + + if (format === "png") { + return decodePngToBitmap(imageBytes) + } + + return convertRasterToBitmap({ + imageBytes, + contentType: graphicSource.contentType, + }) +} diff --git a/lib/utils/image/silkscreen-graphics/index.ts b/lib/utils/image/silkscreen-graphics/index.ts new file mode 100644 index 000000000..f020c3b5b --- /dev/null +++ b/lib/utils/image/silkscreen-graphics/index.ts @@ -0,0 +1,69 @@ +import type { BRepShape } from "circuit-json" +import { createFilledMask, imageBytesToBitmap } from "./bitmap-utils" +import { + polygonArea, + polygonsToBrepShapes, + scalePolygonToTargetSize, + simplifyPolygon, +} from "./polygon-utils" +import { extractBoundaryEdges, traceBoundaryLoops } from "./trace-utils" +import type { + Bitmap, + GraphicTargetSize, + SilkscreenGraphicConversionInput, +} from "./types" + +const bitmapToBrepShapes = ({ + bitmap, + targetSize, +}: { + bitmap: Bitmap + targetSize: GraphicTargetSize +}): BRepShape[] => { + const loops = traceBoundaryLoops( + extractBoundaryEdges({ + filledMask: createFilledMask(bitmap), + maskWidth: bitmap.width, + maskHeight: bitmap.height, + }), + ) + .map(simplifyPolygon) + .map((polygon) => + scalePolygonToTargetSize({ + polygon, + bitmapWidth: bitmap.width, + bitmapHeight: bitmap.height, + targetSize, + }), + ) + .filter( + (polygon) => polygon.length >= 4 && Math.abs(polygonArea(polygon)) > 1e-6, + ) + + if (loops.length === 0) { + throw new Error( + "Imported image does not contain any visible silkscreen geometry", + ) + } + + return polygonsToBrepShapes(loops) +} + +export const imageToBrepShapes = async ({ + importedImageBytes, + contentType, + sourceName, + width, + height, +}: SilkscreenGraphicConversionInput): Promise => { + const imageBytes = new Uint8Array(importedImageBytes) + const targetSize = { width, height } + const graphicSource = { contentType, sourceName } + const bitmap = await imageBytesToBitmap({ + imageBytes, + graphicSource, + targetSize, + }) + + return bitmapToBrepShapes({ bitmap, targetSize }) +} diff --git a/lib/utils/image/silkscreen-graphics/polygon-utils.ts b/lib/utils/image/silkscreen-graphics/polygon-utils.ts new file mode 100644 index 000000000..07bdb543d --- /dev/null +++ b/lib/utils/image/silkscreen-graphics/polygon-utils.ts @@ -0,0 +1,151 @@ +import type { BRepShape } from "circuit-json" +import type { Point, Polygon, PolygonNode } from "./types" + +export const pointKey = ({ x, y }: Point) => `${x},${y}` + +export const samePoint = (a: Point, b: Point) => a.x === b.x && a.y === b.y + +export const closePolygon = (points: Polygon): Polygon => { + if (points.length < 2) return points + const first = points[0]! + const last = points[points.length - 1]! + return samePoint(first, last) ? [...points] : [...points, first] +} + +export const polygonArea = (points: Polygon): number => { + if (points.length < 3) return 0 + + let area = 0 + for (let i = 0; i < points.length; i++) { + const current = points[i]! + const next = points[(i + 1) % points.length]! + area += current.x * next.y - next.x * current.y + } + return area / 2 +} + +export const orientPolygon = ({ + points, + clockwise, +}: { + points: Polygon + clockwise: boolean +}): Polygon => { + const area = polygonArea(points) + if (area === 0) return points + const isClockwise = area < 0 + return isClockwise === clockwise ? points : [...points].reverse() +} + +const pointInPolygon = (point: Point, polygon: Polygon): boolean => { + let inside = false + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const a = polygon[i]! + const b = polygon[j]! + + const intersects = + a.y > point.y !== b.y > point.y && + point.x < ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x + + if (intersects) inside = !inside + } + + return inside +} + +const buildPolygonTree = (polygons: Polygon[]): PolygonNode[] => { + const sortedPolygons = [...polygons].sort( + (a, b) => Math.abs(polygonArea(b)) - Math.abs(polygonArea(a)), + ) + const roots: PolygonNode[] = [] + + const insertNode = (node: PolygonNode, candidates: PolygonNode[]) => { + for (const candidate of candidates) { + if (pointInPolygon(node.polygon[0]!, candidate.polygon)) { + insertNode(node, candidate.children) + return + } + } + candidates.push(node) + } + + for (const polygon of sortedPolygons) { + insertNode({ polygon, children: [] }, roots) + } + + return roots +} + +export const polygonsToBrepShapes = (polygons: Polygon[]): BRepShape[] => { + const shapes: BRepShape[] = [] + + const visitNode = (node: PolygonNode, depth: number) => { + if (depth % 2 === 0) { + shapes.push({ + outer_ring: { + vertices: orientPolygon({ points: node.polygon, clockwise: false }), + }, + inner_rings: node.children.map((child) => ({ + vertices: orientPolygon({ points: child.polygon, clockwise: true }), + })), + }) + } + + for (const child of node.children) { + for (const grandchild of child.children) { + visitNode(grandchild, depth + 2) + } + } + } + + for (const node of buildPolygonTree(polygons)) { + visitNode(node, 0) + } + + return shapes +} + +const areCollinear = (a: Point, b: Point, c: Point) => + Math.abs((b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x)) < 1e-9 + +export const simplifyPolygon = (polygon: Polygon): Polygon => { + if (polygon.length <= 4) return polygon + + const closedPolygon = closePolygon(polygon) + const openPolygon = closedPolygon.slice(0, -1) + const simplified: Polygon = [] + + for (let index = 0; index < openPolygon.length; index++) { + const previous = + openPolygon[(index - 1 + openPolygon.length) % openPolygon.length]! + const current = openPolygon[index]! + const next = openPolygon[(index + 1) % openPolygon.length]! + + if (!areCollinear(previous, current, next)) { + simplified.push(current) + } + } + + return closePolygon(simplified) +} + +export const scalePolygonToTargetSize = ({ + polygon, + bitmapWidth, + bitmapHeight, + targetSize, +}: { + polygon: Polygon + bitmapWidth: number + bitmapHeight: number + targetSize: { width: number; height: number } +}): Polygon => { + const pixelWidth = targetSize.width / bitmapWidth + const pixelHeight = targetSize.height / bitmapHeight + + return polygon.map((point) => ({ + x: (point.x - bitmapWidth / 2) * pixelWidth, + y: (bitmapHeight / 2 - point.y) * pixelHeight, + })) +} diff --git a/lib/utils/image/silkscreen-graphics/trace-utils.ts b/lib/utils/image/silkscreen-graphics/trace-utils.ts new file mode 100644 index 000000000..92ae07f99 --- /dev/null +++ b/lib/utils/image/silkscreen-graphics/trace-utils.ts @@ -0,0 +1,173 @@ +import type { Edge, Polygon, Point } from "./types" +import { closePolygon, pointKey, samePoint } from "./polygon-utils" + +const isFilledCell = ({ + filledMask, + x, + y, + maskWidth, + maskHeight, +}: { + filledMask: boolean[] + x: number + y: number + maskWidth: number + maskHeight: number +}) => { + if (x < 0 || x >= maskWidth || y < 0 || y >= maskHeight) return false + return filledMask[y * maskWidth + x] ?? false +} + +export const extractBoundaryEdges = ({ + filledMask, + maskWidth, + maskHeight, +}: { + filledMask: boolean[] + maskWidth: number + maskHeight: number +}): Edge[] => { + // Emit directed cell edges with the filled region on the right so the traced + // loops have a stable winding before we convert them into BRep rings. + const edges: Edge[] = [] + + for (let y = 0; y < maskHeight; y++) { + for (let x = 0; x < maskWidth; x++) { + if (!isFilledCell({ filledMask, maskWidth, maskHeight, x, y })) continue + + if (!isFilledCell({ filledMask, maskWidth, maskHeight, x, y: y - 1 })) { + edges.push({ + start: { x, y }, + end: { x: x + 1, y }, + }) + } + + if (!isFilledCell({ filledMask, maskWidth, maskHeight, x: x + 1, y })) { + edges.push({ + start: { x: x + 1, y }, + end: { x: x + 1, y: y + 1 }, + }) + } + + if (!isFilledCell({ filledMask, maskWidth, maskHeight, x, y: y + 1 })) { + edges.push({ + start: { x: x + 1, y: y + 1 }, + end: { x, y: y + 1 }, + }) + } + + if (!isFilledCell({ filledMask, maskWidth, maskHeight, x: x - 1, y })) { + edges.push({ + start: { x, y: y + 1 }, + end: { x, y }, + }) + } + } + } + + return edges +} + +const directionIndex = ({ + fromPoint, + toPoint, +}: { + fromPoint: Point + toPoint: Point +}) => { + if (toPoint.x > fromPoint.x) return 0 + if (toPoint.y > fromPoint.y) return 1 + if (toPoint.x < fromPoint.x) return 2 + return 3 +} + +const getTurnPriority = (previousDirection: number, nextDirection: number) => { + const difference = (nextDirection - previousDirection + 4) % 4 + if (difference === 1) return 0 + if (difference === 0) return 1 + if (difference === 3) return 2 + return 3 +} + +export const traceBoundaryLoops = (edges: Edge[]): Polygon[] => { + const remainingOutgoing = new Map() + + for (const edge of edges) { + const key = pointKey(edge.start) + const current = remainingOutgoing.get(key) + if (current) { + current.push(edge) + } else { + remainingOutgoing.set(key, [edge]) + } + } + + const takeNextEdge = ( + start: Point, + previousDirection: number, + ): Edge | undefined => { + const key = pointKey(start) + const candidates = remainingOutgoing.get(key) + if (!candidates || candidates.length === 0) return undefined + + let bestIndex = 0 + let bestPriority = Infinity + for (let index = 0; index < candidates.length; index++) { + const candidate = candidates[index]! + const priority = getTurnPriority( + previousDirection, + directionIndex({ + fromPoint: candidate.start, + toPoint: candidate.end, + }), + ) + if (priority < bestPriority) { + bestPriority = priority + bestIndex = index + } + } + + const [selected] = candidates.splice(bestIndex, 1) + if (candidates.length === 0) { + remainingOutgoing.delete(key) + } + return selected + } + + const loops: Polygon[] = [] + + while (remainingOutgoing.size > 0) { + const [firstKey, firstEdges] = remainingOutgoing.entries().next().value as [ + string, + Edge[], + ] + const firstEdge = firstEdges.shift()! + if (firstEdges.length === 0) { + remainingOutgoing.delete(firstKey) + } + + const loop: Polygon = [firstEdge.start] + let currentPoint = firstEdge.end + let previousDirection = directionIndex({ + fromPoint: firstEdge.start, + toPoint: firstEdge.end, + }) + + while (!samePoint(currentPoint, firstEdge.start)) { + loop.push(currentPoint) + const nextEdge = takeNextEdge(currentPoint, previousDirection) + if (!nextEdge) { + throw new Error("Silkscreen bitmap contour trace produced an open loop") + } + previousDirection = directionIndex({ + fromPoint: nextEdge.start, + toPoint: nextEdge.end, + }) + currentPoint = nextEdge.end + } + + loops.push(closePolygon(loop)) + } + + return loops +} diff --git a/lib/utils/image/silkscreen-graphics/types.ts b/lib/utils/image/silkscreen-graphics/types.ts new file mode 100644 index 000000000..debc3df46 --- /dev/null +++ b/lib/utils/image/silkscreen-graphics/types.ts @@ -0,0 +1,61 @@ +export type Point = { x: number; y: number } + +export type Polygon = Point[] + +export type Bitmap = { + width: number + height: number + rgbaPixels: Uint8Array +} + +export type Rgba = { + r: number + g: number + b: number + a: number +} + +export type Edge = { + start: Point + end: Point +} + +export type PolygonNode = { + polygon: Polygon + children: PolygonNode[] +} + +export type ImageFormat = "svg" | "png" + +export type BunImageInstance = { + png: (options?: { + compressionLevel?: number + palette?: boolean + colors?: number + dither?: boolean + }) => BunImageInstance + bytes: () => Promise +} + +export type BunImageCtor = new ( + input: ArrayBuffer | Uint8Array | Blob | string, +) => BunImageInstance + +export type BunRuntime = { + Image: BunImageCtor +} + +export type GraphicTargetSize = { + width: number + height: number +} + +export type ImportedGraphicSource = { + contentType?: string + sourceName?: string +} + +export type SilkscreenGraphicConversionInput = ImportedGraphicSource & + GraphicTargetSize & { + importedImageBytes: ArrayBufferLike + } diff --git a/package.json b/package.json index 2e735dea1..7201f88e5 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ }, "devDependencies": { "@biomejs/biome": "^1.8.3", - "@resvg/resvg-js": "^2.6.2", "@tscircuit/alphabet": "0.0.25", "@tscircuit/breakout-point-solver": "github:tscircuit/breakout-point-solver#bac9629", "@tscircuit/capacity-autorouter": "^0.0.583", @@ -111,12 +110,14 @@ "typescript": "^5.0.0" }, "dependencies": { + "@resvg/resvg-js": "^2.6.2", "@flatten-js/core": "^1.6.2", "@lume/kiwi": "^0.4.3", "calculate-cell-boundaries": "^0.0.13", "calculate-packing": "0.0.73", "css-select": "5.1.0", "fast-xml-parser": "^5.3.1", + "fast-png": "^8.0.0", "format-si-unit": "^0.0.3", "nanoid": "^5.0.7", "performance-now": "^2.1.0", diff --git a/tests/utils/image/image-to-brep-shapes-png-hole.test.ts b/tests/utils/image/image-to-brep-shapes-png-hole.test.ts new file mode 100644 index 000000000..79942eb06 --- /dev/null +++ b/tests/utils/image/image-to-brep-shapes-png-hole.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from "bun:test" +import { Resvg } from "@resvg/resvg-js" +import { imageToBrepShapes } from "lib/utils/image/silkscreen-graphics" + +test("imageToBrepShapes preserves holes when converting a PNG into silkscreen breps", async () => { + const svg = ` + + + + + ` + + const pngBytes = new Resvg(svg, { + fitTo: { mode: "width", value: 240 }, + }) + .render() + .asPng() + + const shapes = await imageToBrepShapes({ + importedImageBytes: pngBytes.buffer, + contentType: "image/png", + sourceName: "ring.png", + width: 20, + height: 20, + }) + + expect(shapes).toHaveLength(1) + expect(shapes[0]!.inner_rings).toHaveLength(1) +})