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 0000000..6efdb80 Binary files /dev/null and b/tests/__snapshots__/fold-results-rigid-placement.snap.png differ 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") +})