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 0000000..9d4c0ff Binary files /dev/null and b/tests/__snapshots__/nonparallel-flat-reference.snap.png differ diff --git a/tests/__snapshots__/nonparallel-independent-arms.snap.png b/tests/__snapshots__/nonparallel-independent-arms.snap.png new file mode 100644 index 0000000..c9c5ce5 Binary files /dev/null and b/tests/__snapshots__/nonparallel-independent-arms.snap.png differ diff --git a/tests/__snapshots__/nonparallel-nested-body.snap.png b/tests/__snapshots__/nonparallel-nested-body.snap.png new file mode 100644 index 0000000..2a59125 Binary files /dev/null and b/tests/__snapshots__/nonparallel-nested-body.snap.png differ 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) +})