From 4d3fa839b3663308ee56b258c6cc5254026aaed8 Mon Sep 17 00:00:00 2001 From: seveibar Date: Thu, 1 Oct 2026 20:43:23 -0700 Subject: [PATCH] Support independent and nested PCB bend axes --- lib/finite-bend-region.ts | 47 +++ lib/pcb-fold.ts | 110 +++++-- .../nonparallel-flat-reference.snap.png | Bin 0 -> 8282 bytes .../nonparallel-independent-arms.snap.png | Bin 0 -> 9570 bytes .../nonparallel-nested-body.snap.png | Bin 0 -> 9731 bytes tests/fixtures/two-arm-board.ts | 39 +++ tests/nonparallel-bends-visual.test.ts | 82 +++++ tests/nonparallel-bends.test.ts | 310 ++++++++++++++++++ 8 files changed, 554 insertions(+), 34 deletions(-) create mode 100644 tests/__snapshots__/nonparallel-flat-reference.snap.png create mode 100644 tests/__snapshots__/nonparallel-independent-arms.snap.png create mode 100644 tests/__snapshots__/nonparallel-nested-body.snap.png create mode 100644 tests/fixtures/two-arm-board.ts create mode 100644 tests/nonparallel-bends-visual.test.ts create mode 100644 tests/nonparallel-bends.test.ts diff --git a/lib/finite-bend-region.ts b/lib/finite-bend-region.ts index b9abc18..a2403cd 100644 --- a/lib/finite-bend-region.ts +++ b/lib/finite-bend-region.ts @@ -209,3 +209,50 @@ export function polygonsTouch(a: readonly Point2[], b: readonly Point2[]) { } return false } + +/** Test full containment in board-local XY (mm), including shared boundaries. + * Split each contained edge at boundary crossings so a chord across a concavity + * cannot pass this test merely because its endpoints are inside. + */ +export function polygonContainsPolygon( + boundary: readonly Point2[], + polygon: readonly Point2[], +) { + if (polygon.some((p) => !pointInPolygon(p, boundary))) return false + for (let i = 0; i < polygon.length; i++) { + const a = polygon[i]!, + b = polygon[(i + 1) % polygon.length]! + const dx = b.x - a.x, + dy = b.y - a.y + const lengthSquared = dx * dx + dy * dy + if (lengthSquared < EPS * EPS) continue + const crossings = [0, 1] + for (let j = 0; j < boundary.length; j++) { + const c = boundary[j]!, + d = boundary[(j + 1) % boundary.length]! + const ex = d.x - c.x, + ey = d.y - c.y + const denominator = dx * ey - dy * ex + const cx = c.x - a.x, + cy = c.y - a.y + if (Math.abs(denominator) < EPS) { + if (Math.abs(cx * dy - cy * dx) > EPS) continue + for (const p of [c, d]) { + const t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSquared + if (t > 0 && t < 1) crossings.push(t) + } + } else { + const t = (cx * ey - cy * ex) / denominator + const u = (cx * dy - cy * dx) / denominator + if (t > 0 && t < 1 && u >= -EPS && u <= 1 + EPS) crossings.push(t) + } + } + crossings.sort((a, b) => a - b) + for (let j = 1; j < crossings.length; j++) { + const t = (crossings[j - 1]! + crossings[j]!) / 2 + if (!pointInPolygon({ x: a.x + t * dx, y: a.y + t * dy }, boundary)) + return false + } + } + return true +} diff --git a/lib/pcb-fold.ts b/lib/pcb-fold.ts index 8ec6d58..e874496 100644 --- a/lib/pcb-fold.ts +++ b/lib/pcb-fold.ts @@ -7,6 +7,7 @@ import { import { getFiniteBendRegion, pointInPolygon, + polygonContainsPolygon, polygonsTouch, } from "./finite-bend-region" /** Circuit JSON bend geometry, board-local +Z up, millimeters. */ @@ -53,9 +54,79 @@ export interface PcbFoldOptions { outline?: readonly Point2[] } -/** Parallel bends with the same moving direction form an ordered fold chain. +/** Order board-local bends (+Z up, mm) from parent to child. A finite moving + * region may be independent or nested wholly beyond a parent's curved strip. + * Child folds then occur first and are carried rigidly by their parent folds. + */ +function orderPcbBends(bends: Bend[]): Bend[] { + const sorted = [...bends].sort( + (a, b) => + a.start - b.start || + a.nx - b.nx || + a.ny - b.ny || + a.id.localeCompare(b.id), + ) + const parents = new Map() + const overlap = (bend: Bend): never => { + throw new PcbFoldError({ + code: "overlapping_bend_zones", + message: + "Overlapping PCB bend zones or incompatible moving regions are not supported", + bendId: bend.id, + }) + } + for (let i = 0; i < sorted.length; i++) + for (let j = i + 1; j < sorted.length; j++) { + const a = sorted[i]!, + b = sorted[j]! + if (a.movingOutline && b.movingOutline) { + if (!polygonsTouch(a.movingOutline, b.movingOutline)) continue + const aContainsB = polygonContainsPolygon( + a.movingOutline, + b.movingOutline, + ) + const bContainsA = polygonContainsPolygon( + b.movingOutline, + a.movingOutline, + ) + if (aContainsB === bContainsA) overlap(b) + const parent = aContainsB ? a : b, + child = aContainsB ? b : a + if ( + child.movingOutline!.some( + (p) => p.x * parent.nx + p.y * parent.ny < parent.end - EPS, + ) + ) + overlap(child) + parents.set(child, [...(parents.get(child) ?? []), parent]) + } else { + // Without an outline, preserve the legacy infinite-line fold chain. + if (Math.abs(a.nx - b.nx) > EPS || Math.abs(a.ny - b.ny) > EPS) + throw new PcbFoldError({ + code: "nonparallel_bends", + message: + "Nonparallel PCB bends or bends with opposite moving directions require a board outline to resolve their moving regions", + }) + if (b.start < a.end - EPS) overlap(b) + parents.set(b, [...(parents.get(b) ?? []), a]) + } + } + const ordered: Bend[] = [], + visited = new Set() + const visit = (bend: Bend) => { + if (visited.has(bend)) return + for (const parent of parents.get(bend) ?? []) visit(parent) + visited.add(bend) + ordered.push(bend) + } + for (const bend of sorted) visit(bend) + return ordered +} + +/** Independent finite regions and rigidly nested regions can bend about any axis. * Input endpoints and returned transforms are board-local Circuit JSON (+Z up, mm). - * Ordering comes from geometry, never from Circuit JSON array order. + * Ordering comes from region containment, never from Circuit JSON array order. + * Without a board outline, bends must remain parallel with one moving direction. */ export function createPcbFold( records: PcbBendRecord[], @@ -131,37 +202,8 @@ export function createPcbFold( return geometry }) .filter((b) => Math.abs(b.angle) > EPS) - const first = bends[0] - if ( - first && - bends.some( - (b) => Math.abs(b.nx - first.nx) > EPS || Math.abs(b.ny - first.ny) > EPS, - ) - ) { - 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++) - for (let j = i + 1; j < bends.length; j++) { - const a = bends[i]!, - b = bends[j]! - if ( - b.start < a.end - EPS && - (!a.movingOutline || - !b.movingOutline || - polygonsTouch(a.movingOutline, b.movingOutline)) - ) - throw new PcbFoldError({ - code: "overlapping_bend_zones", - message: "Overlapping PCB bend zones are not supported", - bendId: b.id, - }) - } - const distalFirst = [...bends].reverse() + const proximalFirst = orderPcbBends(bends) + const distalFirst = [...proximalFirst].reverse() const transform = ({ p, anchor, @@ -225,7 +267,7 @@ export function createPcbFold( return { x: dot(axes[0]!), y: dot(axes[1]!), z: dot(axes[2]!) } } return { - bends, + bends: proximalFirst, inversePoint: (p, anchor) => inverse({ p: p, anchor: anchor, direction: false }), inverseDirection: (p, anchor) => diff --git a/tests/__snapshots__/nonparallel-flat-reference.snap.png b/tests/__snapshots__/nonparallel-flat-reference.snap.png new file mode 100644 index 0000000000000000000000000000000000000000..9d4c0ff58ba429ceaea14d63e10be2ef95c76e77 GIT binary patch literal 8282 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 literal 0 HcmV?d00001 diff --git a/tests/__snapshots__/nonparallel-independent-arms.snap.png b/tests/__snapshots__/nonparallel-independent-arms.snap.png new file mode 100644 index 0000000000000000000000000000000000000000..c9c5ce51c7b5c1bc0ddfdd78c8ea682a92245fbd GIT binary patch literal 9570 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 literal 0 HcmV?d00001 diff --git a/tests/__snapshots__/nonparallel-nested-body.snap.png b/tests/__snapshots__/nonparallel-nested-body.snap.png new file mode 100644 index 0000000000000000000000000000000000000000..2a59125c3dba030f3bd199a15cef316dc0fcae55 GIT binary patch literal 9731 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/two-arm-board.ts b/tests/fixtures/two-arm-board.ts new file mode 100644 index 0000000..c642482 --- /dev/null +++ b/tests/fixtures/two-arm-board.ts @@ -0,0 +1,39 @@ +import type { PcbBendRecord, Point2 } from "../../lib" + +/** Board-local XY, +Z above, mm; same outline as the core two-arm regression. */ +export const twoArmOutline: Point2[] = [ + { x: -20, y: -10 }, + { x: 20, y: -10 }, + { x: 20, y: -4 }, + { x: 40, y: -4 }, + { x: 40, y: 4 }, + { x: 20, y: 4 }, + { x: 20, y: 10 }, + { x: 4, y: 10 }, + { x: 4, y: 30 }, + { x: -4, y: 30 }, + { x: -4, y: 10 }, + { x: -20, y: 10 }, +] + +export const topArmBend: PcbBendRecord = { + type: "pcb_bend", + pcb_bend_id: "top_arm", + pcb_board_id: "board", + start: { x: -4, y: 20 }, + end: { x: 4, y: 20 }, + bend_angle: 90, + bend_radius: 1, + bend_side: "left", +} + +export const rightArmBend: PcbBendRecord = { + ...topArmBend, + pcb_bend_id: "right_arm", + start: { x: 30, y: -4 }, + end: { x: 30, y: 4 }, + bend_side: "right", +} + +export const topMount = { x: 0, y: 25, z: 0.06 } +export const rightMount = { x: 35, y: 0, z: 0.06 } diff --git a/tests/nonparallel-bends-visual.test.ts b/tests/nonparallel-bends-visual.test.ts new file mode 100644 index 0000000..3be239d --- /dev/null +++ b/tests/nonparallel-bends-visual.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from "bun:test" +import { + createPcbFold, + extrudePolygon, + foldRigidMesh, + foldSurfaceMesh, +} from "../lib" +import "./fixtures/png-matcher" +import { renderSurfaceMeshes } from "./fixtures/render-surface-meshes" +import { + rightArmBend, + rightMount, + topArmBend, + topMount, + twoArmOutline, +} from "./fixtures/two-arm-board" + +test("different bend axes fold independent arms and carry a nested arm with its moving body", async () => { + const board = extrudePolygon({ + outline: twoArmOutline, + bottom: -0.06, + top: 0.06, + }) + const mounts = [topMount, rightMount, { x: -10, y: 0, z: 0.06 }] + const blocks = mounts.map(({ x, y }) => + 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.6, + }), + ) + const cases = [ + { name: "nonparallel-flat-reference", bends: [] }, + { name: "nonparallel-independent-arms", bends: [topArmBend, rightArmBend] }, + { + // Exact original core fixture: the right tip is fixed, while the main body + // and already-folded top arm move with the vertical left-side bend. + name: "nonparallel-nested-body", + bends: [topArmBend, { ...rightArmBend, bend_side: "left" as const }], + }, + ] + const colors: [number, number, number, number][] = [ + [0.8, 0.12, 0.1, 1], + [0.08, 0.3, 0.8, 1], + [0.12, 0.55, 0.18, 1], + ] + for (const { name, bends } of cases) { + const fold = createPcbFold(bends, 0.12, { outline: twoArmOutline }) + const png = await renderSurfaceMeshes( + [ + { mesh: foldSurfaceMesh(board, fold), color: [0.85, 0.52, 0.12, 1] }, + ...blocks.map((mesh, i) => ({ + mesh: foldRigidMesh(mesh, fold, { + flatAnchor: mounts[i]!, + mount: mounts[i]!, + }), + color: colors[i]!, + })), + ], + { + // Board-local right-handed frame: +X right, +Y top, +Z above, mm. + camPos: + name === "nonparallel-nested-body" ? [105, -100, 85] : [-75, -70, 65], + lookAt: name === "nonparallel-nested-body" ? [32, 6, 24] : [8, 7, 8], + up: "z+", + fov: 35, + debugPoints: mounts.map((mount, i) => ({ + label: ["TOP ARM", "RIGHT ARM", "BODY"][i]!, + position: fold.point({ ...mount, z: 0.8 }, mount), + })), + debugFontSize: 20, + debugLabelColor: [0.08, 0.1, 0.15], + }, + ) + await expect(png).toMatchPngSnapshot(import.meta.path, name) + } +}) diff --git a/tests/nonparallel-bends.test.ts b/tests/nonparallel-bends.test.ts new file mode 100644 index 0000000..4cbff93 --- /dev/null +++ b/tests/nonparallel-bends.test.ts @@ -0,0 +1,310 @@ +import { expect, test } from "bun:test" +import { + createPcbFold, + extrudePolygon, + foldRigidMesh, + foldSurfaceMesh, + tryCreatePcbFold, + type PcbBendRecord, + type Point3, +} from "../lib" +import { polygonContainsPolygon } from "../lib/finite-bend-region" +import { + rightArmBend, + rightMount, + topArmBend, + topMount, + twoArmOutline, +} from "./fixtures/two-arm-board" + +const close = (actual: Point3, expected: Point3) => { + for (const coordinate of ["x", "y", "z"] as const) + expect(actual[coordinate]).toBeCloseTo(expected[coordinate], 7) +} +const basis: Point3[] = [ + { x: 1, y: 0, z: 0 }, + { x: 0, y: 1, z: 0 }, + { x: 0, y: 0, z: 1 }, +] +const reverseBend = (bend: PcbBendRecord): PcbBendRecord => ({ + ...bend, + start: bend.end, + end: bend.start, + bend_side: bend.bend_side === "left" ? "right" : "left", +}) + +test("independent arms bend about different axes with stable geometry and rigid inverse transforms", () => { + const original = JSON.stringify([topArmBend, rightArmBend, twoArmOutline]) + let referenceMesh: ReturnType | undefined + for (const reversedOutline of [false, true]) + for (const reversedEndpoints of [false, true]) + for (const reversedRecords of [false, true]) { + let records = [topArmBend, rightArmBend] + if (reversedEndpoints) records = records.map(reverseBend) + if (reversedRecords) records.reverse() + const outline = reversedOutline + ? [...twoArmOutline].reverse() + : twoArmOutline + const fold = createPcbFold(records, 0.12, { outline }) + // Quarter-circle tangencies give these positions independently of fold.point. + close(fold.point(topMount), { + x: 0, + y: 21 - Math.PI / 4 - 0.06, + z: 6 - Math.PI / 4, + }) + close(fold.point(rightMount), { + x: 31 - Math.PI / 4 - 0.06, + y: 0, + z: 6 - Math.PI / 4, + }) + close(fold.point({ x: 0, y: 0, z: 0.06 }), { x: 0, y: 0, z: 0.06 }) + close(fold.direction(basis[2]!, topMount), { x: 0, y: -1, z: 0 }) + close(fold.direction(basis[2]!, rightMount), { x: -1, y: 0, z: 0 }) + for (const mount of [topMount, rightMount]) { + fold.assertRigid([mount], "arm mount") + close(fold.inversePoint(fold.point(mount), mount), mount) + for (const direction of basis) + close( + fold.inverseDirection(fold.direction(direction, mount), mount), + direction, + ) + } + const mesh = foldSurfaceMesh( + extrudePolygon({ outline: twoArmOutline, bottom: -0.06, top: 0.06 }), + fold, + ) + expect(mesh.boundingBox.max.z).toBeCloseTo(11 - Math.PI / 4, 7) + if (referenceMesh) expect(mesh).toEqual(referenceMesh) + else referenceMesh = mesh + } + expect(JSON.stringify([topArmBend, rightArmBend, twoArmOutline])).toBe( + original, + ) +}) + +test("the original left-side body fold carries the already-folded top arm", () => { + const bodyBend = { ...rightArmBend, bend_side: "left" as const } + for (const records of [ + [topArmBend, bodyBend], + [bodyBend, topArmBend], + ]) { + const fold = createPcbFold(records, 0.12, { outline: twoArmOutline }) + expect(fold.bends.map((bend) => bend.id)).toEqual(["right_arm", "top_arm"]) + close(fold.point(rightMount), rightMount) + close(fold.point(topMount), { + x: 35, + y: 21 - Math.PI / 4 - 0.06, + z: 31 - Math.PI / 4, + }) + close(fold.point({ x: 0, y: 0, z: 0.06 }), { + x: 29 + Math.PI / 4 + 0.06, + y: 0, + z: 31 - Math.PI / 4, + }) + const expectedBasis = [ + { x: 0, y: 0, z: -1 }, + { x: 1, y: 0, z: 0 }, + { x: 0, y: -1, z: 0 }, + ] + for (let i = 0; i < basis.length; i++) + close(fold.direction(basis[i]!, topMount), expectedBasis[i]!) + for (const mount of [topMount, rightMount]) { + close(fold.inversePoint(fold.point(mount), mount), mount) + for (const direction of basis) + close( + fold.inverseDirection(fold.direction(direction, mount), mount), + direction, + ) + } + const mountMesh = extrudePolygon({ + outline: [ + { x: -1, y: 24.5 }, + { x: 1, y: 24.5 }, + { x: 1, y: 25.5 }, + { x: -1, y: 25.5 }, + ], + bottom: 0.06, + top: 0.6, + }) + const rigid = foldRigidMesh(mountMesh, fold, { + flatAnchor: topMount, + mount: topMount, + }) + for (const triangle of rigid.triangles) + for (const p of triangle.vertices) { + expect(p.x).toBeGreaterThanOrEqual(34.5 - 1e-7) + expect(p.x).toBeLessThanOrEqual(35.5 + 1e-7) + expect(p.z).toBeGreaterThan(29) + } + } +}) + +test("negative independent folds retain their selected moving regions", () => { + const fold = createPcbFold( + [{ ...topArmBend, bend_angle: -90 }, rightArmBend], + 0.12, + { outline: twoArmOutline }, + ) + close(fold.point(topMount), { + x: 0, + y: 21 - Math.PI / 4 + 0.06, + z: -6 + Math.PI / 4, + }) + close(fold.point(rightMount), { + x: 31 - Math.PI / 4 - 0.06, + y: 0, + z: 6 - Math.PI / 4, + }) +}) + +test("parallel fold chains remain geometric and work beside an independent axis", () => { + const topDistal = { + ...topArmBend, + pcb_bend_id: "top_distal", + bend_angle: -90, + start: { x: -4, y: 26 }, + end: { x: 4, y: 26 }, + } + const chain = createPcbFold([topArmBend, topDistal], 0.12, { + outline: twoArmOutline, + }) + const point = { x: 0, y: 29, z: 0.06 } + // Two opposite quarter circles restore the original orientation. Their arc + // tangencies leave this distal point translated by (0, -4-pi/2, 8-pi/2). + const expected = { x: 0, y: 25 - Math.PI / 2, z: 8 - Math.PI / 2 + 0.06 } + close(chain.point(point), expected) + for (const direction of basis) + close(chain.direction(direction, point), direction) + for (const records of [ + [topDistal, rightArmBend, topArmBend], + [topArmBend, topDistal, rightArmBend], + ]) { + const fold = createPcbFold(records, 0.12, { outline: twoArmOutline }) + close(fold.point(point), expected) + close(fold.point(rightMount), { + x: 31 - Math.PI / 4 - 0.06, + y: 0, + z: 6 - Math.PI / 4, + }) + } +}) + +test("opposite moving directions fold both ends of a rectangular board", () => { + const outline = [ + { x: -20, y: -4 }, + { x: 20, y: -4 }, + { x: 20, y: 4 }, + { x: -20, y: 4 }, + ] + const right = { + ...rightArmBend, + start: { x: 10, y: -4 }, + end: { x: 10, y: 4 }, + } + const left = { + ...right, + pcb_bend_id: "left_arm", + start: { x: -10, y: -4 }, + end: { x: -10, y: 4 }, + bend_side: "left" as const, + } + for (const bends of [ + [left, right], + [right, left], + ]) { + const fold = createPcbFold(bends, 0.12, { outline }) + close(fold.point({ x: 15, y: 0, z: 0.06 }), { + x: 11 - Math.PI / 4 - 0.06, + y: 0, + z: 6 - Math.PI / 4, + }) + close(fold.point({ x: -15, y: 0, z: 0.06 }), { + x: -11 + Math.PI / 4 + 0.06, + y: 0, + z: 6 - Math.PI / 4, + }) + close(fold.point({ x: 0, y: 0, z: 0.06 }), { x: 0, y: 0, z: 0.06 }) + close(fold.direction(basis[2]!, { x: -15, y: 0, z: 0 }), { + x: 1, + y: 0, + z: 0, + }) + close(fold.direction(basis[2]!, { x: 15, y: 0, z: 0 }), { + x: -1, + y: 0, + z: 0, + }) + } +}) + +test("crossing regions and nested curves inside a parent bend strip fail explicitly", () => { + const rectangle = [ + { x: -20, y: -10 }, + { x: 20, y: -10 }, + { x: 20, y: 10 }, + { x: -20, y: 10 }, + ] + const across = { + ...topArmBend, + start: { x: -20, y: 0 }, + end: { x: 20, y: 0 }, + } + const vertical = { + ...rightArmBend, + start: { x: 0, y: -10 }, + end: { x: 0, y: 10 }, + } + const crossing = tryCreatePcbFold([across, vertical], 0.12, { + outline: rectangle, + }) + expect(crossing.ok).toBe(false) + if (crossing.ok) throw new Error("Expected incompatible intersecting regions") + expect(crossing.issue.code).toBe("overlapping_bend_zones") + expect(() => createPcbFold([topArmBend, rightArmBend], 0.12)).toThrow( + "board outline", + ) + const stripOverlap = tryCreatePcbFold( + [ + topArmBend, + { + ...topArmBend, + pcb_bend_id: "close", + start: { x: -4, y: 20.5 }, + end: { x: 4, y: 20.5 }, + }, + ], + 0.12, + { outline: twoArmOutline }, + ) + expect(stripOverlap.ok).toBe(false) + if (stripOverlap.ok) throw new Error("Expected overlapping curved strips") + expect(stripOverlap.issue.code).toBe("overlapping_bend_zones") +}) + +test("containment checks edges through concavities, not only their endpoints", () => { + const notched = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + { x: 3, y: 10 }, + { x: 3, y: 4 }, + { x: 2, y: 4 }, + { x: 2, y: 10 }, + { x: 0, y: 10 }, + ] + const crossingNotch = [ + { x: 1, y: 5 }, + { x: 9, y: 5 }, + { x: 9, y: 6 }, + { x: 1, y: 6 }, + ] + expect(polygonContainsPolygon(notched, crossingNotch)).toBe(false) + expect( + polygonContainsPolygon(notched, [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 3 }, + { x: 0, y: 3 }, + ]), + ).toBe(true) +})