diff --git a/README.md b/README.md index 2d4a1f0..583e35b 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Equivalent Euler triples at gimbal lock may differ, but the pose round-trips. `point`/`direction`, and `inversePoint`/`inverseDirection` with a **flat anchor**. - `foldSurfaceMesh(mesh, fold)`: tessellate at tangencies and at most 5-degree arc intervals, retaining flat UVs, face identity, and triangle metadata. -- `extrudePolygon(outline, bottom, top)` / `createStiffenerMesh(...)`: shared +- `extrudePolygon({ outline, bottom, top })` / `createStiffenerMesh(...)`: shared polygon extrusion and rigid stiffener geometry including adhesive spacing. - `transformCadComponent` / `getCadFoldContext`: single-component pose helpers. @@ -37,3 +37,5 @@ lie in bend zones. Unsupported geometry throws. This package has no Three.js, GLTF, DOM, native addon, model loader or rendering dependency. Run `bun test`, `bun run typecheck`, and `bun run build`. + +`transformCadComponent({ cadComponent, foldPcbs }, context)` transforms an existing record. `transformCadComponentPlacement({ cadComponentPlacement, foldPcbs }, context)` transforms its position and rotation before a record exists. Both use `CadFoldContext` as the second argument. diff --git a/lib/cad.ts b/lib/cad.ts index 6009382..685ce16 100644 --- a/lib/cad.ts +++ b/lib/cad.ts @@ -52,61 +52,83 @@ export interface CadFoldContext { defaultRotation?: Point3 } -/** Pure, idempotent CAD pose conversion. Position is global Circuit JSON +Z-up +export type CadComponentPlacement = Pick< + CadComponentWithFoldState, + "position" | "rotation" | "is_on_folded_board" +> + +/** Pure, idempotent CAD component placement conversion. Position is global Circuit JSON +Z-up * millimeters; rotation is XYZ degrees. Model-origin/scale/normal fields stay * model-local. The PCB mount selects the inverse when assembled regions overlap. */ export function transformCadComponent( - cad: T, + { cadComponent, foldPcbs }: { cadComponent: T; foldPcbs: boolean }, context: CadFoldContext, - folded: boolean, ): T { - if ((cad.is_on_folded_board === true) === folded) return cad + return transformCadComponentPlacement( + { cadComponentPlacement: cadComponent, foldPcbs }, + context, + ) +} + +/** Transform a CAD component placement before a circuit-json record/id exists. Coordinates + * and rotations use the same world-space convention as transformCadComponent. + */ +export function transformCadComponentPlacement( + { + cadComponentPlacement, + foldPcbs, + }: { cadComponentPlacement: T; foldPcbs: boolean }, + context: CadFoldContext, +): T { + if ((cadComponentPlacement.is_on_folded_board === true) === foldPcbs) + return cadComponentPlacement const { fold, boardCenter, flatMount } = context const anchor = { x: flatMount.x - boardCenter.x, y: flatMount.y - boardCenter.y, z: 0, } - fold.assertRigid([anchor], cad.cad_component_id) + fold.assertRigid([anchor], "CAD mount") const point = { - x: cad.position.x - boardCenter.x, - y: cad.position.y - boardCenter.y, - z: cad.position.z, + x: cadComponentPlacement.position.x - boardCenter.x, + y: cadComponentPlacement.position.y - boardCenter.y, + z: cadComponentPlacement.position.z, } - const direction = folded ? fold.direction : fold.inverseDirection - const transformed = folded + const direction = foldPcbs ? fold.direction : fold.inverseDirection + const transformed = foldPcbs ? fold.point(point, anchor) : fold.inversePoint(point, anchor) - const rotation = cad.rotation ?? + const rotation = cadComponentPlacement.rotation ?? context.defaultRotation ?? { x: 0, y: 0, z: 0 } const basis = axes.map((v) => direction(rotateVector(v, rotation), anchor)) return { - ...cad, + ...cadComponentPlacement, position: { x: transformed.x + boardCenter.x, y: transformed.y + boardCenter.y, z: transformed.z, }, rotation: rotationFromAxes(basis), - is_on_folded_board: folded, + is_on_folded_board: foldPcbs, } } /** Resolve an explicit board reference, then subcircuit ownership. An ambiguous * multi-board association fails rather than guessing from folded coordinates. */ export function getCadFoldContext( - cad: CadComponentWithFoldState, + cadComponent: CadComponentWithFoldState, json: AnyCircuitElement[], ): CadFoldContext | undefined { const pcb = json.find( (e) => - e.type === "pcb_component" && e.pcb_component_id === cad.pcb_component_id, + e.type === "pcb_component" && + e.pcb_component_id === cadComponent.pcb_component_id, ) if (pcb?.type !== "pcb_component") { - if (cad.is_on_folded_board) + if (cadComponent.is_on_folded_board) throw new Error( - `Folded CAD ${cad.cad_component_id} requires its flat pcb_component reference`, + `Folded CAD ${cadComponent.cad_component_id} requires its flat pcb_component reference`, ) return undefined } @@ -125,7 +147,7 @@ export function getCadFoldContext( : undefined if (!board) throw new Error( - `Cannot unambiguously resolve board for CAD ${cad.cad_component_id}`, + `Cannot unambiguously resolve board for CAD ${cadComponent.cad_component_id}`, ) const bends = json.filter( (e): e is PcbBendRecord => @@ -136,7 +158,7 @@ export function getCadFoldContext( boardCenter: board.center, flatMount: pcb.center, defaultRotation: { - x: (cad.layer ?? pcb.layer) === "bottom" ? 180 : 0, + x: (cadComponent.layer ?? pcb.layer) === "bottom" ? 180 : 0, y: 0, z: 0, }, @@ -145,7 +167,7 @@ export function getCadFoldContext( /** Transform CAD records only; PCB traces/components, bends, stiffeners, source * records and the caller's input array are left untouched. Mixed flat/folded CAD - * is normalized record-by-record, so repeated calls cannot double-fold a pose. */ + * is normalized record-by-record, so repeated calls cannot double-fold a placement. */ export function transformCircuitJsonCadComponents( json: T[], options: { foldPcbs: boolean }, @@ -153,12 +175,19 @@ export function transformCircuitJsonCadComponents( const hasBends = json.some((element) => element.type === "pcb_bend") return json.map((element) => { if (element.type !== "cad_component") return element - const cad = element as CadComponentWithFoldState - if (!hasBends && !cad.is_on_folded_board) return element - if ((cad.is_on_folded_board === true) === options.foldPcbs) return element - const context = getCadFoldContext(cad, json) - if (!context || (!context.fold.bends.length && !cad.is_on_folded_board)) + const cadComponent = element as CadComponentWithFoldState + if (!hasBends && !cadComponent.is_on_folded_board) return element + if ((cadComponent.is_on_folded_board === true) === options.foldPcbs) + return element + const context = getCadFoldContext(cadComponent, json) + if ( + !context || + (!context.fold.bends.length && !cadComponent.is_on_folded_board) + ) return element - return transformCadComponent(cad, context, options.foldPcbs) as T + return transformCadComponent( + { cadComponent: cadComponent, foldPcbs: options.foldPcbs }, + context, + ) as T }) } diff --git a/lib/pcb-fold.ts b/lib/pcb-fold.ts index f908d56..1ca3258 100644 --- a/lib/pcb-fold.ts +++ b/lib/pcb-fold.ts @@ -107,7 +107,11 @@ export function createPcbFold( throw new Error("Overlapping PCB bend zones are not supported") } const distalFirst = [...bends].reverse() - const transform = (p: Point3, anchor: Point3, direction: boolean): Point3 => { + const transform = ({ + p, + anchor, + direction, + }: { p: Point3; anchor: Point3; direction: boolean }): Point3 => { let result = { ...p } // Fold distal regions first, then carry them with each proximal fold. for (const b of distalFirst) { @@ -135,25 +139,37 @@ export function createPcbFold( } // For a fixed flat mount this is an affine rigid transform R*p + t. // Its inverse is R^T*(p-t), even when different folded regions overlap. - const inverse = (p: Point3, anchor: Point3, direction: boolean): Point3 => { + const inverse = ({ + p, + anchor, + direction, + }: { p: Point3; anchor: Point3; direction: boolean }): Point3 => { const origin = direction ? { x: 0, y: 0, z: 0 } - : transform({ x: 0, y: 0, z: 0 }, anchor, false) + : transform({ + p: { x: 0, y: 0, z: 0 }, + anchor: anchor, + direction: false, + }) const d = { x: p.x - origin.x, y: p.y - origin.y, z: p.z - origin.z } const axes = [ { x: 1, y: 0, z: 0 }, { x: 0, y: 1, z: 0 }, { x: 0, y: 0, z: 1 }, - ].map((v) => transform(v, anchor, true)) + ].map((v) => transform({ p: v, anchor: anchor, direction: true })) const dot = (v: Point3) => v.x * d.x + v.y * d.y + v.z * d.z return { x: dot(axes[0]!), y: dot(axes[1]!), z: dot(axes[2]!) } } return { bends, - inversePoint: (p, anchor) => inverse(p, anchor, false), - inverseDirection: (p, anchor) => inverse(p, anchor, true), - point: (p, anchor = p) => transform(p, anchor, false), - direction: (p, anchor) => transform(p, anchor, true), + inversePoint: (p, anchor) => + inverse({ p: p, anchor: anchor, direction: false }), + inverseDirection: (p, anchor) => + inverse({ p: p, anchor: anchor, direction: true }), + point: (p, anchor = p) => + transform({ p: p, anchor: anchor, direction: false }), + direction: (p, anchor) => + transform({ p: p, anchor: anchor, direction: true }), assertRigid(points, label) { for (const b of bends) { const ds = points.map((p) => p.x * b.nx + p.y * b.ny) diff --git a/lib/polygon.ts b/lib/polygon.ts index afcbed1..fefc9b6 100644 --- a/lib/polygon.ts +++ b/lib/polygon.ts @@ -4,11 +4,15 @@ import { boundsOfTriangles } from "./surface" /** Extrude a simple polygon in board-local Circuit JSON (+Z up, mm). * Used for stiffeners and uncut board surfaces; face labels survive folding. */ -export function extrudePolygon( - outline: { x: number; y: number }[], - bottom: number, - top: number, -): SurfaceMesh { +export function extrudePolygon({ + outline, + bottom, + top, +}: { + outline: { x: number; y: number }[] + bottom: number + top: number +}): SurfaceMesh { if ( outline.length < 3 || !Number.isFinite(bottom) || @@ -19,19 +23,25 @@ export function extrudePolygon( throw new Error( "A finite polygon and positive extrusion thickness are required", ) - const area = outline.reduce((s, a, i) => { + let area = 0 + for (const [i, a] of outline.entries()) { const b = outline[(i + 1) % outline.length]! - return s + a.x * b.y - b.x * a.y - }, 0) + area += a.x * b.y - b.x * a.y + } if (Math.abs(area) < 1e-12) throw new Error("Degenerate polygon") const polygon = area > 0 ? outline : [...outline].reverse() const triangles: Triangle[] = [] - const add = ( - a: Point3, - b: Point3, - c: Point3, - pcbFace: Triangle["pcbFace"], - ) => { + const add = ({ + a, + b, + c, + pcbFace, + }: { + a: Point3 + b: Point3 + c: Point3 + pcbFace: Triangle["pcbFace"] + }) => { const u = { x: b.x - a.x, y: b.y - a.y, z: b.z - a.z }, v = { x: c.x - a.x, y: c.y - a.y, z: c.z - a.z } const n = { @@ -54,14 +64,34 @@ export function extrudePolygon( (typeof polygon)[number], (typeof polygon)[number], ] - add({ ...a, z: top }, { ...b, z: top }, { ...c, z: top }, "top") - add({ ...c, z: bottom }, { ...b, z: bottom }, { ...a, z: bottom }, "bottom") + add({ + a: { ...a, z: top }, + b: { ...b, z: top }, + c: { ...c, z: top }, + pcbFace: "top", + }) + add({ + a: { ...c, z: bottom }, + b: { ...b, z: bottom }, + c: { ...a, z: bottom }, + pcbFace: "bottom", + }) } for (let i = 0; i < polygon.length; i++) { const a = polygon[i]!, b = polygon[(i + 1) % polygon.length]! - add({ ...a, z: bottom }, { ...b, z: bottom }, { ...b, z: top }, "side") - add({ ...a, z: bottom }, { ...b, z: top }, { ...a, z: top }, "side") + add({ + a: { ...a, z: bottom }, + b: { ...b, z: bottom }, + c: { ...b, z: top }, + pcbFace: "side", + }) + add({ + a: { ...a, z: bottom }, + b: { ...b, z: top }, + c: { ...a, z: top }, + pcbFace: "side", + }) } return { triangles, boundingBox: boundsOfTriangles(triangles) } } diff --git a/lib/stiffener.ts b/lib/stiffener.ts index c8871ee..ca94fb3 100644 --- a/lib/stiffener.ts +++ b/lib/stiffener.ts @@ -5,11 +5,15 @@ import { boundsOfTriangles } from "./surface" /** Stiffener vertices stay board-local (+Z up, mm). Stiffeners are rigid and may * not cross a bend zone. Includes the adhesive spacing from the board surface. */ -export function createStiffenerMesh( - stiffener: PcbStiffener, - boardThickness: number, - fold?: PcbFold, -) { +export function createStiffenerMesh({ + stiffener, + boardThickness, + fold, +}: { + stiffener: PcbStiffener + boardThickness: number + fold?: PcbFold +}) { const adhesive = stiffener.adhesive_thickness ?? 0 if ( !Number.isFinite(adhesive) || @@ -44,11 +48,11 @@ export function createStiffenerMesh( })() const near = boardThickness / 2 + adhesive, far = near + stiffener.thickness - const mesh = extrudePolygon( - outline, - stiffener.layer === "top" ? near : -far, - stiffener.layer === "top" ? far : -near, - ) + const mesh = extrudePolygon({ + outline: outline, + bottom: stiffener.layer === "top" ? near : -far, + top: stiffener.layer === "top" ? far : -near, + }) if (!fold) return mesh fold.assertRigid( mesh.triangles.flatMap((t) => t.vertices), diff --git a/lib/surface.ts b/lib/surface.ts index 6d7aa48..22c7114 100644 --- a/lib/surface.ts +++ b/lib/surface.ts @@ -30,12 +30,17 @@ export function foldSurfaceMesh( }) const { min, max } = mesh.boundingBox type Vertex = { p: Point3; uv: { u: number; v: number } } - const split = ( - polygon: Vertex[], - nx: number, - ny: number, - d: number, - ): Vertex[][] => { + const split = ({ + polygon, + nx, + ny, + d, + }: { + polygon: Vertex[] + nx: number + ny: number + d: number + }): Vertex[][] => { const distances = polygon.map(({ p }) => nx * p.x + ny * p.y - d) if (Math.min(...distances) >= -EPS || Math.max(...distances) <= EPS) return [polygon] @@ -86,7 +91,9 @@ export function foldSurfaceMesh( })), ] for (const { b, d } of planes) - polygons = polygons.flatMap((p) => split(p, b.nx, b.ny, d)) + polygons = polygons.flatMap((p) => + split({ polygon: p, nx: b.nx, ny: b.ny, d: d }), + ) for (const polygon of polygons) { for (const b of fold.bends) { const mid = diff --git a/tests/cad.test.ts b/tests/cad.test.ts index 7b17ec7..c8f51ea 100644 --- a/tests/cad.test.ts +++ b/tests/cad.test.ts @@ -2,6 +2,8 @@ import { expect, test } from "bun:test" import { createPcbFold, transformCadComponent, + transformCadComponentPlacement, + type CadComponentPlacement, transformCircuitJsonCadComponents, rotateVector, type CadComponentWithFoldState, @@ -25,7 +27,7 @@ const close = (a: { x: number; y: number; z: number }, b: typeof a) => { } const base: CadComponentWithFoldState = { type: "cad_component", - cad_component_id: "cad", + cad_component_id: "cadComponent", source_component_id: "src", pcb_component_id: "pcb", position: { x: 20, y: 7, z: 0.5 }, @@ -34,7 +36,7 @@ const base: CadComponentWithFoldState = { anchor_alignment: "center", } -test("CAD pose forward/inverse, all layers, oblique axes, arbitrary rotations and gimbal lock", () => { +test("CAD component placement forward/inverse, all layers, oblique axes, arbitrary rotations and gimbal lock", () => { for (const angle of [-130, 90, 180]) for (const bottom of [false, true]) for (const z of [0, 37, 90, 180, 270]) { @@ -49,7 +51,7 @@ test("CAD pose forward/inverse, all layers, oblique axes, arbitrary rotations an ], 0.15, ) - const cad = { + const cadComponent = { ...base, position: { x: 20, y: 17, z: bottom ? -0.5 : 0.5 }, rotation: { x: bottom ? 180 : 17, y: 29, z }, @@ -59,11 +61,19 @@ test("CAD pose forward/inverse, all layers, oblique axes, arbitrary rotations an boardCenter: { x: 5, y: 3 }, flatMount: { x: 20, y: 17 }, } - const folded = transformCadComponent(cad, ctx, true) + const folded = transformCadComponent( + { cadComponent: cadComponent, foldPcbs: true }, + ctx, + ) expect(folded.is_on_folded_board).toBe(true) - expect(transformCadComponent(folded, ctx, true)).toBe(folded) - const restored = transformCadComponent(folded, ctx, false) - close(restored.position, cad.position) + expect( + transformCadComponent({ cadComponent: folded, foldPcbs: true }, ctx), + ).toBe(folded) + const restored = transformCadComponent( + { cadComponent: folded, foldPcbs: false }, + ctx, + ) + close(restored.position, cadComponent.position) for (const v of [ { x: 1, y: 0, z: 0 }, { x: 0, y: 1, z: 0 }, @@ -71,19 +81,21 @@ test("CAD pose forward/inverse, all layers, oblique axes, arbitrary rotations an ]) close( rotateVector(v, restored.rotation!), - rotateVector(v, cad.rotation), + rotateVector(v, cadComponent.rotation), ) } const fold = createPcbFold([bend], 0.15) const ctx = { fold, boardCenter: { x: 0, y: 0 }, flatMount: { x: 6, y: 3 } } const moved = transformCadComponent( { - ...base, - position: { x: 6, y: 3, z: 0.4 }, - rotation: { x: 0, y: 0, z: 0 }, + cadComponent: { + ...base, + position: { x: 6, y: 3, z: 0.4 }, + rotation: { x: 0, y: 0, z: 0 }, + }, + foldPcbs: true, }, ctx, - true, ) // At a +90-degree bend, height becomes -X and distance past the tangent becomes +Z. close(moved.position, { @@ -96,14 +108,18 @@ test("CAD pose forward/inverse, all layers, oblique axes, arbitrary rotations an y: 0, z: 0, }) - close(transformCadComponent(moved, ctx, false).position, { - x: 6, - y: 3, - z: 0.4, - }) + close( + transformCadComponent({ cadComponent: moved, foldPcbs: false }, ctx) + .position, + { + x: 6, + y: 3, + z: 0.4, + }, + ) }) -test("mixed Circuit JSON poses normalize without mutating PCB data and reject orphan folded CAD", () => { +test("mixed Circuit JSON placements normalize without mutating PCB data and reject orphan folded CAD", () => { const json = [ { type: "pcb_board", @@ -142,16 +158,16 @@ test("mixed Circuit JSON poses normalize without mutating PCB data and reject or }) test("surface subdivision keeps top UVs tied to the flat sheet and spans the arc", () => { - const mesh = extrudePolygon( - [ + const mesh = extrudePolygon({ + outline: [ { x: -5, y: -1 }, { x: 6, y: -1 }, { x: 6, y: 1 }, { x: -5, y: 1 }, ], - -0.075, - 0.075, - ) + bottom: -0.075, + top: 0.075, + }) const folded = foldSurfaceMesh(mesh, createPcbFold([bend], 0.15)) expect(folded.triangles.length).toBeGreaterThan(mesh.triangles.length) expect(folded.boundingBox.max.z).toBeCloseTo(2 + 6 - Math.PI / 2, 5) @@ -184,19 +200,28 @@ test("four bends stack three distinct mounts and each inverse recovers its own d surface = fold.point(mount) close(surface, { x: -pitch, y: 2, z: i * 6 }) close(fold.inversePoint(surface, mount), mount) - const cad = { + const cadComponent = { ...base, position: { ...mount, z: 0.3 }, rotation: { x: 0, y: 0, z: 37 }, } const ctx = { fold, boardCenter: { x: 0, y: 0 }, flatMount: mount } - const posed = transformCadComponent(cad, ctx, true) - close(posed.position, { + const foldedCadComponent = transformCadComponent( + { cadComponent: cadComponent, foldPcbs: true }, + ctx, + ) + close(foldedCadComponent.position, { x: -pitch, y: 2, z: i * 6 + (i === 1 ? -0.3 : 0.3), }) - close(transformCadComponent(posed, ctx, false).position, cad.position) + close( + transformCadComponent( + { cadComponent: foldedCadComponent, foldPcbs: false }, + ctx, + ).position, + cadComponent.position, + ) } } }) @@ -253,3 +278,32 @@ test("ordinary PCB data is unchanged when folding is requested", () => { json, ) }) + +test("CAD component placements can be folded before a component id exists", () => { + const cadComponentPlacement: CadComponentPlacement = { + position: base.position, + rotation: base.rotation, + } + const context = { + fold: createPcbFold([bend], 0.15), + boardCenter: { x: 0, y: 0 }, + flatMount: { x: 20, y: 7 }, + } + const folded = transformCadComponentPlacement( + { cadComponentPlacement: cadComponentPlacement, foldPcbs: true }, + context, + ) + expect(folded.is_on_folded_board).toBe(true) + expect(folded).not.toHaveProperty("cad_component_id") + expect(folded.position.z).toBeGreaterThan(10) + const restored = transformCadComponentPlacement( + { cadComponentPlacement: folded, foldPcbs: false }, + context, + ) + close(restored.position, cadComponentPlacement.position) + close( + rotateVector({ x: 1, y: 0, z: 0 }, restored.rotation!), + rotateVector({ x: 1, y: 0, z: 0 }, cadComponentPlacement.rotation!), + ) + expect(cadComponentPlacement.is_on_folded_board).toBeUndefined() +})