From dbd2471ab2214fe919841550408e6d848f7ac1e4 Mon Sep 17 00:00:00 2001 From: seveibar Date: Thu, 1 Oct 2026 15:53:36 -0700 Subject: [PATCH] Expose explicit results for unsupported PCB folds --- README.md | 13 + lib/finite-bend-region.ts | 27 +- lib/fold-result.ts | 39 +++ lib/index.ts | 6 + lib/pcb-fold.ts | 65 ++++- lib/rigid.ts | 49 ++++ lib/stiffener.ts | 92 +++--- lib/surface.ts | 23 +- .../fold-results-rigid-placement.snap.png | Bin 0 -> 10357 bytes tests/fold-results-visual.test.ts | 88 ++++++ tests/fold-results.test.ts | 273 ++++++++++++++++++ 11 files changed, 613 insertions(+), 62 deletions(-) create mode 100644 lib/fold-result.ts create mode 100644 lib/rigid.ts create mode 100644 tests/__snapshots__/fold-results-rigid-placement.snap.png create mode 100644 tests/fold-results-visual.test.ts create mode 100644 tests/fold-results.test.ts diff --git a/README.md b/README.md index 6c9a891..c24be4e 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ 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. +- `foldRigidMesh(mesh, fold, { flatAnchor, label, mount })`: carry rigid geometry + and normals with one flat anchor. An optional PCB mount is validated separately + from model extents, including models offset away from their mount. - `extrudePolygon({ outline, bottom, top })` / `createStiffenerMesh(...)`: shared polygon extrusion and rigid stiffener geometry including adhesive spacing. - `transformCadComponent` / `getCadFoldContext`: single-component pose helpers. @@ -45,6 +48,16 @@ must span the full board cross-section. Rigid mounts and stiffeners cannot lie in bend zones. Unsupported geometry throws. This package has no Three.js, GLTF, DOM, native addon, model loader or rendering dependency. +Renderers can use `tryCreatePcbFold`, `tryFoldSurfaceMesh`, `tryFoldRigidMesh`, and +`tryFoldStiffenerMesh` to handle expected folding limitations explicitly. These +return `{ ok: true, value }` or `{ ok: false, issue }`, where `issue` has a stable +`code`, a `message`, and an optional `bendId`. For stiffeners, construct the flat +mesh once, then call `tryFoldStiffenerMesh(mesh, stiffener, fold)` to preserve the +shared outline-average anchor. The caller chooses whether to retain flat geometry +and how to report the issue. Strict APIs throw `PcbFoldError` carrying the same +issue and retain their existing messages. Malformed flat geometry and unexpected +errors still throw through the result APIs; CAD pose normalization remains strict. + 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/finite-bend-region.ts b/lib/finite-bend-region.ts index 2e8aa01..b9abc18 100644 --- a/lib/finite-bend-region.ts +++ b/lib/finite-bend-region.ts @@ -1,4 +1,5 @@ import type { Point2 } from "./types" +import { PcbFoldError } from "./fold-result" const EPS = 1e-7 type BoundaryCrossing = { point: Point2; edge: number; fraction: number } @@ -109,6 +110,7 @@ function movingPolygon( * Simple outlines with one connected cross-section through the bend are supported. */ export function getFiniteBendRegion({ + bendId, outline, nx, ny, @@ -117,6 +119,7 @@ export function getFiniteBendRegion({ axisMin, axisMax, }: { + bendId?: string outline: readonly Point2[] nx: number ny: number @@ -132,16 +135,23 @@ export function getFiniteBendRegion({ }, ) if (finiteCrossings.length !== 2) - throw new Error( - "A finite PCB bend must cut one board cross-section from boundary to boundary", - ) + throw new PcbFoldError({ + code: "invalid_finite_bend_region", + message: + "A finite PCB bend must cut one board cross-section from boundary to boundary", + bendId, + }) const [a, b] = finiteCrossings as [BoundaryCrossing, BoundaryCrossing] const middle = { x: (a.point.x + b.point.x) / 2, y: (a.point.y + b.point.y) / 2, } if (!pointInPolygon(middle, outline, false)) - throw new Error("A finite PCB bend must pass through the board interior") + throw new PcbFoldError({ + code: "invalid_finite_bend_region", + message: "A finite PCB bend must pass through the board interior", + bendId, + }) const centerRegion = movingPolygon(outline, a, b, nx, ny) const alongMiddle = -ny * middle.x + nx * middle.y const proximalCrossings = lineCrossings(outline, nx, ny, proximal) @@ -169,9 +179,12 @@ export function getFiniteBendRegion({ break return region } - throw new Error( - "The finite PCB bend zone must remain in one connected board cross-section", - ) + throw new PcbFoldError({ + code: "invalid_finite_bend_region", + message: + "The finite PCB bend zone must remain in one connected board cross-section", + bendId, + }) } export function polygonsTouch(a: readonly Point2[], b: readonly Point2[]) { diff --git a/lib/fold-result.ts b/lib/fold-result.ts new file mode 100644 index 0000000..7e1c5df --- /dev/null +++ b/lib/fold-result.ts @@ -0,0 +1,39 @@ +/** Expected reasons that valid flat geometry cannot be folded. */ +export interface PcbFoldIssue { + code: + | "invalid_bend_geometry" + | "unsupported_bend_geometry" + | "nonparallel_bends" + | "overlapping_bend_zones" + | "invalid_finite_bend_region" + | "incomplete_bend_cross_section" + | "rigid_bend_zone_intersection" + message: string + bendId?: string +} + +/** Strict folding APIs throw this error for expected folding limitations. + * Invalid flat meshes and unexpected failures remain ordinary exceptions. + */ +export class PcbFoldError extends Error { + constructor(public readonly issue: PcbFoldIssue) { + super(issue.message) + this.name = "PcbFoldError" + } +} + +export type PcbFoldResult = + | { ok: true; value: T } + | { ok: false; issue: PcbFoldIssue } + +/** Internal bridge between strict and result APIs. Never swallow model, + * geometry, or programming errors that are not declared folding limitations. + */ +export function capturePcbFoldResult(fold: () => T): PcbFoldResult { + try { + return { ok: true, value: fold() } + } catch (error) { + if (!(error instanceof PcbFoldError)) throw error + return { ok: false, issue: error.issue } + } +} diff --git a/lib/index.ts b/lib/index.ts index 37a6d0e..a467c77 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -5,3 +5,9 @@ export * from "./cad" export * from "./polygon" export * from "./stiffener" +export * from "./rigid" +export { + PcbFoldError, + type PcbFoldIssue, + type PcbFoldResult, +} from "./fold-result" diff --git a/lib/pcb-fold.ts b/lib/pcb-fold.ts index 3d578da..8ec6d58 100644 --- a/lib/pcb-fold.ts +++ b/lib/pcb-fold.ts @@ -1,4 +1,9 @@ import type { Point2, Point3 } from "./types" +import { + capturePcbFoldResult, + PcbFoldError, + type PcbFoldResult, +} from "./fold-result" import { getFiniteBendRegion, pointInPolygon, @@ -76,15 +81,19 @@ export function createPcbFold( radius <= thickness / 2 || !["left", "right"].includes(b.bend_side) ) { - throw new Error( - `Invalid PCB bend ${b.pcb_bend_id}: finite geometry and radius greater than half the board thickness are required`, - ) + throw new PcbFoldError({ + code: "invalid_bend_geometry", + message: `Invalid PCB bend ${b.pcb_bend_id}: finite geometry and radius greater than half the board thickness are required`, + bendId: b.pcb_bend_id, + }) } const length = Math.hypot(end.x - start.x, end.y - start.y) if (length < EPS || Math.abs(degrees) > 180) - throw new Error( - `Unsupported PCB bend ${b.pcb_bend_id}: distinct endpoints and angles within ±180 degrees are required`, - ) + throw new PcbFoldError({ + code: "unsupported_bend_geometry", + message: `Unsupported PCB bend ${b.pcb_bend_id}: distinct endpoints and angles within ±180 degrees are required`, + bendId: b.pcb_bend_id, + }) const sign = b.bend_side === "right" ? 1 : -1 const nx = (sign * (end.y - start.y)) / length const ny = (-sign * (end.x - start.x)) / length @@ -110,6 +119,7 @@ export function createPcbFold( } if (outline && Math.abs(angle) > EPS) geometry.movingOutline = getFiniteBendRegion({ + bendId: b.pcb_bend_id, outline, nx, ny, @@ -128,9 +138,11 @@ export function createPcbFold( (b) => Math.abs(b.nx - first.nx) > EPS || Math.abs(b.ny - first.ny) > EPS, ) ) { - throw new Error( - "Folded PCB rendering currently requires parallel bends with the same moving direction", - ) + throw new PcbFoldError({ + code: "nonparallel_bends", + message: + "Folded PCB rendering currently requires parallel bends with the same moving direction", + }) } bends.sort((a, b) => a.start - b.start) for (let i = 0; i < bends.length; i++) @@ -143,14 +155,22 @@ export function createPcbFold( !b.movingOutline || polygonsTouch(a.movingOutline, b.movingOutline)) ) - throw new Error("Overlapping PCB bend zones are not supported") + throw new PcbFoldError({ + code: "overlapping_bend_zones", + message: "Overlapping PCB bend zones are not supported", + bendId: b.id, + }) } const distalFirst = [...bends].reverse() const transform = ({ p, anchor, direction, - }: { p: Point3; anchor: Point3; direction: boolean }): Point3 => { + }: { + 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) { @@ -183,7 +203,11 @@ export function createPcbFold( p, anchor, direction, - }: { p: Point3; anchor: Point3; direction: boolean }): Point3 => { + }: { + p: Point3 + anchor: Point3 + direction: boolean + }): Point3 => { const origin = direction ? { x: 0, y: 0, z: 0 } : transform({ @@ -219,8 +243,23 @@ export function createPcbFold( : Math.max(0, Math.min(width, p.x * b.nx + p.y * b.ny - b.start)), ) if (Math.max(...progress) > EPS && Math.min(...progress) < width - EPS) - throw new Error(`${label} intersects PCB bend zone ${b.id}`) + throw new PcbFoldError({ + code: "rigid_bend_zone_intersection", + message: `${label} intersects PCB bend zone ${b.id}`, + bendId: b.id, + }) } }, } } + +/** Construct a board-local (+Z up, mm) fold, reporting expected folding + * limitations as data. Malformed board geometry and unexpected errors throw. + */ +export function tryCreatePcbFold( + records: PcbBendRecord[], + thickness: number, + options: PcbFoldOptions = {}, +): PcbFoldResult { + return capturePcbFoldResult(() => createPcbFold(records, thickness, options)) +} diff --git a/lib/rigid.ts b/lib/rigid.ts new file mode 100644 index 0000000..0f7c35d --- /dev/null +++ b/lib/rigid.ts @@ -0,0 +1,49 @@ +import { capturePcbFoldResult, type PcbFoldResult } from "./fold-result" +import type { PcbFold } from "./pcb-fold" +import { boundsOfTriangles } from "./surface" +import type { Point3, SurfaceMesh, Triangle } from "./types" + +export interface RigidFoldOptions { + /** Board-local flat point (+Z up, mm) selecting one rigid fold transform. */ + flatAnchor: Point3 + label?: string + /** Optional PCB mount, independently checked even if a model offset moves + * all mesh vertices outside the bend zone. Board-local +Z up, mm. + */ + mount?: Point3 +} + +/** Carry a rigid mesh with its PCB in board-local Circuit JSON (+Z up, mm). + * Vertices are points; normals are directions and do not receive translation. + * Preserve triangle metadata and UVs, and leave the input mesh untouched. + */ +export function foldRigidMesh( + mesh: T, + fold: PcbFold, + { flatAnchor, label = "Rigid mesh", mount }: RigidFoldOptions, +): T { + if (mount) fold.assertRigid([mount], `${label} mount`) + fold.assertRigid( + mesh.triangles.flatMap((t) => t.vertices), + label, + ) + const triangles = mesh.triangles.map((t) => ({ + ...t, + vertices: t.vertices.map((p) => + fold.point(p, flatAnchor), + ) as Triangle["vertices"], + normal: fold.direction(t.normal, flatAnchor), + })) + return { ...mesh, triangles, boundingBox: boundsOfTriangles(triangles) } +} + +/** Report a rigid mesh/mount intersecting a bend zone as data. Invalid mesh + * data and unexpected transformation failures throw; no flat policy is chosen. + */ +export function tryFoldRigidMesh( + mesh: T, + fold: PcbFold, + options: RigidFoldOptions, +): PcbFoldResult { + return capturePcbFoldResult(() => foldRigidMesh(mesh, fold, options)) +} diff --git a/lib/stiffener.ts b/lib/stiffener.ts index ca94fb3..eb9ba37 100644 --- a/lib/stiffener.ts +++ b/lib/stiffener.ts @@ -1,7 +1,39 @@ import type { PcbStiffener } from "circuit-json" import type { PcbFold } from "./pcb-fold" import { extrudePolygon } from "./polygon" -import { boundsOfTriangles } from "./surface" +import { foldRigidMesh, tryFoldRigidMesh } from "./rigid" +import type { PcbFoldResult } from "./fold-result" +import type { Point2, SurfaceMesh } from "./types" + +function getStiffenerOutline(stiffener: PcbStiffener): Point2[] { + if (stiffener.shape === "polygon") return stiffener.outline + if (!(stiffener.width > 0 && stiffener.height > 0)) + throw new Error("Invalid stiffener dimensions") + const a = ((stiffener.rotation ?? 0) * Math.PI) / 180 + return [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ].map(([x, y]) => ({ + x: + stiffener.center.x + + ((x! * stiffener.width) / 2) * Math.cos(a) - + ((y! * stiffener.height) / 2) * Math.sin(a), + y: + stiffener.center.y + + ((x! * stiffener.width) / 2) * Math.sin(a) + + ((y! * stiffener.height) / 2) * Math.cos(a), + })) +} + +function getStiffenerAnchor(outline: Point2[]) { + return { + x: outline.reduce((s, p) => s + p.x, 0) / outline.length, + y: outline.reduce((s, p) => s + p.y, 0) / outline.length, + z: 0, + } +} /** 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. */ @@ -23,29 +55,7 @@ export function createStiffenerMesh({ !["top", "bottom"].includes(stiffener.layer) ) throw new Error(`Invalid PCB stiffener ${stiffener.pcb_stiffener_id}`) - const outline = - stiffener.shape === "polygon" - ? stiffener.outline - : (() => { - if (!(stiffener.width > 0 && stiffener.height > 0)) - throw new Error("Invalid stiffener dimensions") - const a = ((stiffener.rotation ?? 0) * Math.PI) / 180 - return [ - [-1, -1], - [1, -1], - [1, 1], - [-1, 1], - ].map(([x, y]) => ({ - x: - stiffener.center.x + - ((x! * stiffener.width) / 2) * Math.cos(a) - - ((y! * stiffener.height) / 2) * Math.sin(a), - y: - stiffener.center.y + - ((x! * stiffener.width) / 2) * Math.sin(a) + - ((y! * stiffener.height) / 2) * Math.cos(a), - })) - })() + const outline = getStiffenerOutline(stiffener) const near = boardThickness / 2 + adhesive, far = near + stiffener.thickness const mesh = extrudePolygon({ @@ -54,19 +64,23 @@ export function createStiffenerMesh({ top: stiffener.layer === "top" ? far : -near, }) if (!fold) return mesh - fold.assertRigid( - mesh.triangles.flatMap((t) => t.vertices), - stiffener.pcb_stiffener_id, - ) - const anchor = { - x: outline.reduce((s, p) => s + p.x, 0) / outline.length, - y: outline.reduce((s, p) => s + p.y, 0) / outline.length, - z: 0, - } - const triangles = mesh.triangles.map((t) => ({ - ...t, - vertices: t.vertices.map((p) => fold.point(p, anchor)) as typeof t.vertices, - normal: fold.direction(t.normal, anchor), - })) - return { ...mesh, triangles, boundingBox: boundsOfTriangles(triangles) } + return foldRigidMesh(mesh, fold, { + flatAnchor: getStiffenerAnchor(outline), + label: stiffener.pcb_stiffener_id, + }) +} + +/** Fold an already-created flat stiffener mesh in board-local +Z-up mm. + * Retains createStiffenerMesh's outline-average anchor and reports bend-zone + * collisions as data, allowing callers to reuse the same flat mesh on failure. + */ +export function tryFoldStiffenerMesh( + mesh: T, + stiffener: PcbStiffener, + fold: PcbFold, +): PcbFoldResult { + return tryFoldRigidMesh(mesh, fold, { + flatAnchor: getStiffenerAnchor(getStiffenerOutline(stiffener)), + label: stiffener.pcb_stiffener_id, + }) } diff --git a/lib/surface.ts b/lib/surface.ts index 3c66cab..1b2ba84 100644 --- a/lib/surface.ts +++ b/lib/surface.ts @@ -1,5 +1,10 @@ import type { Point3, Triangle, SurfaceMesh } from "./types" import type { PcbFold } from "./pcb-fold" +import { + capturePcbFoldResult, + PcbFoldError, + type PcbFoldResult, +} from "./fold-result" const EPS = 1e-7 export function boundsOfTriangles(triangles: Triangle[]) { const points = triangles.flatMap((t) => t.vertices) @@ -108,9 +113,11 @@ export function foldSurfaceMesh( return along < b.axisMin - EPS || along > b.axisMax + EPS }) ) - throw new Error( - `PCB bend ${b.id} must span the full board cross-section through its bend zone`, - ) + throw new PcbFoldError({ + code: "incomplete_bend_cross_section", + message: `PCB bend ${b.id} must span the full board cross-section through its bend zone`, + bendId: b.id, + }) } for (let i = 1; i + 1 < polygon.length; i++) { const verts = [polygon[0]!, polygon[i]!, polygon[i + 1]!] @@ -139,3 +146,13 @@ export function foldSurfaceMesh( } return { ...mesh, triangles, boundingBox: boundsOfTriangles(triangles) } } + +/** Tessellate and fold a board-local surface (+Z up, mm), reporting expected + * unsupported cross-sections as data. Unexpected deformation failures throw. + */ +export function tryFoldSurfaceMesh( + mesh: T, + fold: PcbFold, +): PcbFoldResult { + return capturePcbFoldResult(() => foldSurfaceMesh(mesh, fold)) +} diff --git a/tests/__snapshots__/fold-results-rigid-placement.snap.png b/tests/__snapshots__/fold-results-rigid-placement.snap.png new file mode 100644 index 0000000000000000000000000000000000000000..6efdb804cfb33eb6084a8b500e5b41eaf2442c14 GIT binary patch literal 10357 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 literal 0 HcmV?d00001 diff --git a/tests/fold-results-visual.test.ts b/tests/fold-results-visual.test.ts new file mode 100644 index 0000000..c4e2101 --- /dev/null +++ b/tests/fold-results-visual.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from "bun:test" +import { + extrudePolygon, + tryCreatePcbFold, + tryFoldRigidMesh, + tryFoldSurfaceMesh, + type PcbBendRecord, +} from "../lib" +import "./fixtures/png-matcher" +import { renderSurfaceMeshes } from "./fixtures/render-surface-meshes" + +test("a valid board and rigid CAD fold while an invalid rigid placement can stay flat", async () => { + const outline = [ + { x: -20, y: -10 }, + { x: 20, y: -10 }, + { x: 20, y: 10 }, + { x: -20, y: 10 }, + ] + const bend: PcbBendRecord = { + type: "pcb_bend", + pcb_bend_id: "bend", + pcb_board_id: "board", + start: { x: 0, y: -10 }, + end: { x: 0, y: 10 }, + bend_angle: 90, + bend_radius: 1, + bend_side: "right", + } + const fold = tryCreatePcbFold([bend], 0.12, { outline }) + if (!fold.ok) throw new Error(fold.issue.message) + const board = tryFoldSurfaceMesh( + extrudePolygon({ outline, bottom: -0.06, top: 0.06 }), + fold.value, + ) + if (!board.ok) throw new Error(board.issue.message) + const block = (x: number, y: number) => + extrudePolygon({ + outline: [ + { x: x - 1, y: y - 0.5 }, + { x: x + 1, y: y - 0.5 }, + { x: x + 1, y: y + 0.5 }, + { x: x - 1, y: y + 0.5 }, + ], + bottom: 0.06, + top: 0.9, + }) + const goodMount = { x: 12, y: 4, z: 0 } + const good = tryFoldRigidMesh(block(12, 4), fold.value, { + flatAnchor: goodMount, + mount: goodMount, + label: "Valid CAD", + }) + if (!good.ok) throw new Error(good.issue.message) + const invalidMesh = block(0, -4) + const badMount = { x: 0, y: -4, z: 0 } + const bad = tryFoldRigidMesh(invalidMesh, fold.value, { + flatAnchor: badMount, + mount: badMount, + label: "Invalid CAD", + }) + expect(bad.ok).toBe(false) + if (bad.ok) throw new Error("Expected bend-zone placement issue") + expect(bad.issue.code).toBe("rigid_bend_zone_intersection") + const png = await renderSurfaceMeshes( + [ + { mesh: board.value, color: [0.85, 0.52, 0.12, 1] }, + { mesh: good.value, color: [0.08, 0.3, 0.8, 1] }, + { mesh: invalidMesh, color: [0.8, 0.12, 0.1, 1] }, + ], + { + camPos: [-50, -55, 45], + lookAt: [-2, 0, 6], + debugPoints: [ + { + label: "valid fold", + position: fold.value.point({ ...goodMount, z: 1.2 }, goodMount), + }, + { label: "bend-zone issue", position: { ...badMount, z: 1.2 } }, + ], + debugFontSize: 20, + debugLabelColor: [0.08, 0.1, 0.15], + }, + ) + await expect(png).toMatchPngSnapshot( + import.meta.path, + "fold-results-rigid-placement", + ) +}) diff --git a/tests/fold-results.test.ts b/tests/fold-results.test.ts new file mode 100644 index 0000000..5a01b10 --- /dev/null +++ b/tests/fold-results.test.ts @@ -0,0 +1,273 @@ +import { expect, test } from "bun:test" +import { + createPcbFold, + createStiffenerMesh, + extrudePolygon, + foldRigidMesh, + PcbFoldError, + tryCreatePcbFold, + tryFoldRigidMesh, + tryFoldStiffenerMesh, + tryFoldSurfaceMesh, + type PcbBendRecord, + type PcbFoldIssue, + type Triangle, +} from "../lib" +import type { PcbStiffener } from "circuit-json" + +const bend: PcbBendRecord = { + type: "pcb_bend", + pcb_bend_id: "bend", + pcb_board_id: "board", + start: { x: 0, y: -10 }, + end: { x: 0, y: 10 }, + bend_angle: 90, + bend_radius: 2, + bend_side: "right", +} +const outline = [ + { x: -20, y: -10 }, + { x: 20, y: -10 }, + { x: 20, y: 10 }, + { x: -20, y: 10 }, +] +const rigid = () => + extrudePolygon({ + outline: [ + { x: 8, y: 2 }, + { x: 10, y: 2 }, + { x: 10, y: 3 }, + { x: 8, y: 3 }, + ], + bottom: 0.3, + top: 0.9, + }) + +test("fold construction reports typed limitations while strict APIs retain their messages", () => { + const cases: { records: PcbBendRecord[]; code: PcbFoldIssue["code"] }[] = [ + { + records: [{ ...bend, bend_radius: 0 }], + code: "invalid_bend_geometry", + }, + { + records: [{ ...bend, end: bend.start }], + code: "unsupported_bend_geometry", + }, + { + records: [ + bend, + { + ...bend, + pcb_bend_id: "oblique", + start: { x: -10, y: 0 }, + end: { x: 10, y: 0 }, + }, + ], + code: "nonparallel_bends", + }, + { + records: [bend, { ...bend, pcb_bend_id: "overlap" }], + code: "overlapping_bend_zones", + }, + ] + for (const { records, code } of cases) { + const result = tryCreatePcbFold(records, 0.12) + expect(result.ok).toBe(false) + if (result.ok) throw new Error("Expected unsupported fold") + expect(result.issue.code).toBe(code) + expect(() => createPcbFold(records, 0.12)).toThrow(PcbFoldError) + expect(() => createPcbFold(records, 0.12)).toThrow(result.issue.message) + } + const partial = tryCreatePcbFold( + [{ ...bend, start: { x: 0, y: -1 }, end: { x: 0, y: 1 } }], + 0.12, + { outline }, + ) + expect(partial).toEqual({ + ok: false, + issue: { + code: "invalid_finite_bend_region", + message: + "A finite PCB bend must cut one board cross-section from boundary to boundary", + bendId: "bend", + }, + }) + const valid = tryCreatePcbFold([bend], 0.12, { outline }) + expect(valid.ok).toBe(true) + if (!valid.ok) throw new Error(valid.issue.message) + expect(valid.value.point({ x: 10, y: 2, z: 0 }).z).toBeGreaterThan(10) +}) + +test("result APIs reject malformed flat board data and propagate unexpected failures", () => { + expect(() => tryCreatePcbFold([bend], -1)).toThrow("Board thickness") + expect(() => tryCreatePcbFold([bend], 0.12, { outline: [] })).toThrow( + "finite board outline", + ) + const failure = new Error("Unexpected geometry failure") + const brokenRecord = { + ...bend, + get bend_angle(): number { + throw failure + }, + } + expect(() => tryCreatePcbFold([brokenRecord], 0.12)).toThrow(failure) + const fold = createPcbFold([bend], 0.12) + const brokenFold = { + ...fold, + point() { + throw failure + }, + } + expect(() => tryFoldSurfaceMesh(rigid(), brokenFold)).toThrow(failure) + expect(() => + tryFoldRigidMesh(rigid(), brokenFold, { + flatAnchor: { x: 9, y: 2.5, z: 0 }, + }), + ).toThrow(failure) +}) + +test("legacy surface cross-section limitations are explicit folding results", () => { + const fold = createPcbFold( + [{ ...bend, start: { x: 0, y: -1 }, end: { x: 0, y: 1 } }], + 0.12, + ) + const board = extrudePolygon({ outline, bottom: -0.06, top: 0.06 }) + const before = JSON.stringify(board) + expect(tryFoldSurfaceMesh(board, fold)).toEqual({ + ok: false, + issue: { + code: "incomplete_bend_cross_section", + message: + "PCB bend bend must span the full board cross-section through its bend zone", + bendId: "bend", + }, + }) + expect(JSON.stringify(board)).toBe(before) +}) + +test("rigid mesh output carries points and normals, preserves metadata, and leaves inputs unchanged", () => { + const fold = createPcbFold([bend], 0.12) + const mesh = { + ...rigid(), + source: "off-axis CAD", + triangles: rigid().triangles.map((triangle) => ({ + ...triangle, + material: "red", + uvs: [ + { u: 0, v: 0 }, + { u: 1, v: 0 }, + { u: 0, v: 1 }, + ] as Triangle["uvs"], + })), + } + const input = JSON.stringify(mesh) + // Beyond this 2 mm quarter-circle: x = 2 - pi/2 - flat z, + // z = flat x + 2 - pi/2. This expectation comes from the arc tangency. + const folded = foldRigidMesh(mesh, fold, { + flatAnchor: { x: 9, y: 2.5, z: 0 }, + label: "CAD", + mount: { x: 9, y: 2.5, z: 0 }, + }) + expect(folded.source).toBe(mesh.source) + for (let i = 0; i < mesh.triangles.length; i++) { + const original = mesh.triangles[i]! + const output = folded.triangles[i]! + expect(output.material).toBe("red") + expect(output.uvs).toEqual(original.uvs) + for (let vertex = 0; vertex < 3; vertex++) { + const p = original.vertices[vertex]! + const moved = output.vertices[vertex]! + expect(moved.x).toBeCloseTo(2 - Math.PI / 2 - p.z, 7) + expect(moved.y).toBeCloseTo(p.y, 7) + expect(moved.z).toBeCloseTo(p.x + 2 - Math.PI / 2, 7) + } + expect(output.normal.x).toBeCloseTo(-original.normal.z, 7) + expect(output.normal.y).toBeCloseTo(original.normal.y, 7) + expect(output.normal.z).toBeCloseTo(original.normal.x, 7) + } + expect(folded.boundingBox.min.x).toBeCloseTo(2 - Math.PI / 2 - 0.9, 7) + expect(folded.boundingBox.max.z).toBeCloseTo(12 - Math.PI / 2, 7) + expect(JSON.stringify(mesh)).toBe(input) +}) + +test("CAD mount validation is independent of model offsets and rigid extent validation", () => { + const fold = createPcbFold([bend], 0.12) + const mesh = rigid() + const anchor = { x: 0, y: 2.5, z: 0 } + // The offset model is entirely beyond the bend zone, but its PCB mount is not. + expect( + tryFoldRigidMesh(mesh, fold, { + flatAnchor: anchor, + mount: anchor, + label: "Offset CAD", + }), + ).toEqual({ + ok: false, + issue: { + code: "rigid_bend_zone_intersection", + message: "Offset CAD mount intersects PCB bend zone bend", + bendId: "bend", + }, + }) + expect(tryFoldRigidMesh(mesh, fold, { flatAnchor: anchor }).ok).toBe(true) + const crossing = extrudePolygon({ + outline: [ + { x: -3, y: 2 }, + { x: 3, y: 2 }, + { x: 3, y: 3 }, + { x: -3, y: 3 }, + ], + bottom: 0.06, + top: 0.3, + }) + const result = tryFoldRigidMesh(crossing, fold, { + flatAnchor: { x: 8, y: 2, z: 0 }, + label: "Crossing CAD", + }) + expect(result.ok).toBe(false) + if (result.ok) throw new Error("Expected crossing rigid mesh") + expect(result.issue.message).toBe( + "Crossing CAD intersects PCB bend zone bend", + ) +}) + +test("already-created stiffeners preserve the shared outline anchor and invalid flat shapes still reject", () => { + const fold = createPcbFold([bend], 0.12) + const stiffener: PcbStiffener = { + type: "pcb_stiffener", + pcb_stiffener_id: "stiffener", + pcb_board_id: "board", + shape: "polygon", + outline: [ + { x: 8, y: -2 }, + { x: 10, y: -2 }, + { x: 11, y: -1 }, + { x: 10, y: 2 }, + { x: 8, y: 1 }, + ], + layer: "bottom", + material: "polyimide", + thickness: 0.2, + } + const flat = createStiffenerMesh({ stiffener, boardThickness: 0.12 }) + const result = tryFoldStiffenerMesh(flat, stiffener, fold) + expect(result.ok).toBe(true) + if (!result.ok) throw new Error(result.issue.message) + expect(result.value).toEqual( + createStiffenerMesh({ stiffener, boardThickness: 0.12, fold }), + ) + expect(() => + createStiffenerMesh({ + stiffener: { ...stiffener, outline: [] }, + boardThickness: 0.12, + fold, + }), + ).toThrow("finite polygon") + expect(() => + createStiffenerMesh({ + stiffener: { ...stiffener, thickness: -1 }, + boardThickness: 0.12, + fold, + }), + ).toThrow("Invalid PCB stiffener") +})