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..21a40b1bf --- /dev/null +++ b/lib/components/primitive-components/SilkscreenGraphic.ts @@ -0,0 +1,306 @@ +import { + type SilkscreenGraphicProps as PublicSilkscreenGraphicProps, + silkscreenGraphicProps as publicSilkscreenGraphicProps, +} from "@tscircuit/props" +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" +import { resolveStaticFileImport } from "lib/utils/resolveStaticFileImport" +import { svgToBrepShapes } from "lib/utils/svg/svg-to-brep-shapes" + +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], +) => ({ + vertices: ring.vertices.map((vertex) => { + const transformed = applyToPoint(transform, { x: vertex.x, y: vertex.y }) + return { + ...vertex, + x: transformed.x, + y: transformed.y, + } + }), +}) + +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, + 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_ids: string[] = [] + isPcbPrimitive = true + _hasStartedImageLoad = false + + get config() { + return { + componentName: "SilkscreenGraphic", + zodProps: silkscreenGraphicProps, + } + } + + doInitialPcbPrimitiveRender(): void { + if (this.root?.pcbDisabled) return + const { _parsedProps: props } = this + const { maybeFlipLayer } = this._getPcbPrimitiveFlippedHelpers() + const layer = maybeFlipLayer(props.layer ?? "top") as "top" | "bottom" + + if (layer !== "top" && layer !== "bottom") { + throw new Error( + `Invalid layer "${layer}" for SilkscreenGraphic. Must be "top" or "bottom".`, + ) + } + + 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"), + }) + }) + } + + 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_ids.length === 0) return + + 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({ + deltaX, + deltaY, + }: { deltaX: number; deltaY: number }) { + if (this.root?.pcbDisabled) return + const { db } = this.root! + 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(id, { + brep_shape: translateBrepShape(graphic.brep_shape, deltaX, deltaY), + }) + } + } + + getPcbSize(): { width: number; height: number } { + const transform = this._computePcbGlobalTransformBeforeLayout() + + 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 getBoundsFromVertices( + getBrepVertices(this._parsedProps.brepShape), + transform, + ) + } +} 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/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/__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..774497bca --- /dev/null +++ b/tests/utils/createComponentsFromCircuitJson/pcb-silkscreen-graphic-snapshot.test.tsx @@ -0,0 +1,80 @@ +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("createComponentsFromCircuitJson renders pcb_silkscreen_graphic elements in PCB snapshots", async () => { + 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 }, + { x: -3, y: -2 }, + { x: -3, y: 2 }, + { x: -7, y: 2 }, + ], + }, + inner_rings: [ + { + vertices: [ + { x: -6, y: -1 }, + { x: -4, y: -1 }, + { x: -4, y: 1 }, + { x: -6, y: 1 }, + ], + }, + ], + }, + }, + { + type: "pcb_silkscreen_graphic", + pcb_silkscreen_graphic_id: "pcb_silkscreen_graphic_1", + pcb_component_id: "pcb_component_0", + layer: "bottom", + shape: "brep", + brep_shape: { + outer_ring: { + vertices: [ + { x: 3, y: 0 }, + { x: 5, y: -2 }, + { x: 7, y: 0 }, + { x: 5, y: 2 }, + ], + }, + 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() + const silkscreenGraphics = circuitJson.filter( + (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) +})