From 1c2155230af64ed8b873363fd4563907d63ce0d5 Mon Sep 17 00:00:00 2001 From: seveibar Date: Thu, 1 Oct 2026 10:47:38 -0700 Subject: [PATCH 1/2] Support finite PCB bend regions from board outlines --- README.md | 15 ++- lib/cad.ts | 7 +- lib/finite-bend-region.ts | 198 ++++++++++++++++++++++++++++++++ lib/pcb-fold.ts | 61 ++++++++-- lib/surface.ts | 1 + lib/types.ts | 4 + tests/finite-bend.test.ts | 235 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 509 insertions(+), 12 deletions(-) create mode 100644 lib/finite-bend-region.ts create mode 100644 tests/finite-bend.test.ts diff --git a/README.md b/README.md index 583e35b..6c9a891 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ surface points and bend endpoints are board-local. CAD positions and PCB mounts are in global Circuit JSON coordinates; board center translation is applied once. Equivalent Euler triples at gimbal lock may differ, but the pose round-trips. -- `createPcbFold(bends, thickness)`: finite-radius neutral-surface deformation; +- `createPcbFold(bends, thickness, { outline })`: finite-radius neutral-surface deformation; `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. @@ -31,8 +31,17 @@ Equivalent Euler triples at gimbal lock may differ, but the pose round-trips. polygon extrusion and rigid stiffener geometry including adhesive spacing. - `transformCadComponent` / `getCadFoldContext`: single-component pose helpers. -Initial scope is parallel, non-overlapping bend chains sharing a moving direction. -Bend axes must span the board cross-section; rigid mounts and stiffeners cannot +Provide a simple, board-local outline to make each finite bend segment select the +connected region it cuts off. A bend must cross that region from boundary to +boundary. The curved strip must stay in one cross-section; folds whose moving +region wraps behind its proximal tangent are unsupported. Widened distal tips +are included, and independent tails can have bends at the same position. Omitting +the outline retains the original infinite-line deformation. `getCadFoldContext` +uses the Circuit JSON board outline automatically, translating it to board-local XY. + +Initial scope is parallel bend chains sharing a moving direction. Overlapping +bend zones are allowed only on disjoint regions. Without an outline, bend axes +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. diff --git a/lib/cad.ts b/lib/cad.ts index 685ce16..029cc7c 100644 --- a/lib/cad.ts +++ b/lib/cad.ts @@ -154,7 +154,12 @@ export function getCadFoldContext( e.type === "pcb_bend" && e.pcb_board_id === board.pcb_board_id, ) return { - fold: createPcbFold(bends, board.thickness ?? 1.6), + fold: createPcbFold(bends, board.thickness ?? 1.6, { + outline: board.outline?.map((p) => ({ + x: p.x - board.center.x, + y: p.y - board.center.y, + })), + }), boardCenter: board.center, flatMount: pcb.center, defaultRotation: { diff --git a/lib/finite-bend-region.ts b/lib/finite-bend-region.ts new file mode 100644 index 0000000..2e8aa01 --- /dev/null +++ b/lib/finite-bend-region.ts @@ -0,0 +1,198 @@ +import type { Point2 } from "./types" + +const EPS = 1e-7 +type BoundaryCrossing = { point: Point2; edge: number; fraction: number } +const samePoint = (a: Point2, b: Point2) => + Math.hypot(a.x - b.x, a.y - b.y) < EPS + +/** Includes boundary points. Input points are board-local XY in millimeters. */ +export function pointInPolygon( + point: Point2, + polygon: readonly Point2[], + includeBoundary = true, +) { + let inside = false + for (let i = 0; i < polygon.length; i++) { + const a = polygon[i]!, + b = polygon[(i + 1) % polygon.length]! + const cross = (point.x - a.x) * (b.y - a.y) - (point.y - a.y) * (b.x - a.x) + if ( + Math.abs(cross) < EPS * Math.max(1, Math.hypot(b.x - a.x, b.y - a.y)) && + point.x >= Math.min(a.x, b.x) - EPS && + point.x <= Math.max(a.x, b.x) + EPS && + point.y >= Math.min(a.y, b.y) - EPS && + point.y <= Math.max(a.y, b.y) + EPS + ) + return includeBoundary + if ( + a.y > point.y !== b.y > point.y && + point.x < a.x + ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + ) + inside = !inside + } + return inside +} + +function lineCrossings( + outline: readonly Point2[], + nx: number, + ny: number, + d: number, +) { + const crossings: BoundaryCrossing[] = [] + for (let edge = 0; edge < outline.length; edge++) { + const a = outline[edge]!, + b = outline[(edge + 1) % outline.length]! + const da = a.x * nx + a.y * ny - d, + db = b.x * nx + b.y * ny - d + if (Math.abs(da) < EPS && Math.abs(db) < EPS) continue + if ((da > EPS && db > EPS) || (da < -EPS && db < -EPS)) continue + if (Math.abs(da - db) < EPS) continue + const fraction = Math.max(0, Math.min(1, da / (da - db))) + const point = { + x: a.x + (b.x - a.x) * fraction, + y: a.y + (b.y - a.y) * fraction, + } + if (crossings.some((crossing) => samePoint(crossing.point, point))) continue + crossings.push( + fraction > 1 - EPS + ? { point: b, edge: (edge + 1) % outline.length, fraction: 0 } + : { point, edge, fraction }, + ) + } + return crossings.sort( + (a, b) => + -ny * a.point.x + nx * a.point.y - (-ny * b.point.x + nx * b.point.y), + ) +} + +function movingPolygon( + outline: readonly Point2[], + a: BoundaryCrossing, + b: BoundaryCrossing, + nx: number, + ny: number, +) { + const polygon: Point2[] = [] + for (let edge = 0; edge < outline.length; edge++) { + polygon.push(outline[edge]!) + for (const crossing of [a, b] + .filter((c) => c.edge === edge) + .sort((a, b) => a.fraction - b.fraction)) + if (crossing.fraction > EPS) polygon.push(crossing.point) + } + const start = polygon.findIndex((p) => samePoint(p, a.point)) + const end = polygon.findIndex((p) => samePoint(p, b.point)) + if (start < 0 || end < 0) + throw new Error("Cannot resolve PCB bend boundary crossings") + const path = (from: number, to: number) => { + const points = [polygon[from]!] + while (from !== to) { + from = (from + 1) % polygon.length + points.push(polygon[from]!) + } + return points + } + const area = outline.reduce((sum, p, i) => { + const next = outline[(i + 1) % outline.length]! + return sum + p.x * next.y - next.x * p.y + }, 0) + // The closing edge b->a has its interior on the left for a CCW outline. + const side = + Math.sign(area) * + ((b.point.y - a.point.y) * nx - (b.point.x - a.point.x) * ny) + return side > 0 ? path(start, end) : path(end, start) +} + +/** Select the portion cut off by one finite bend chord, including the proximal + * half of its curved strip. Outline and endpoints are board-local XY, +Z up, mm. + * Simple outlines with one connected cross-section through the bend are supported. + */ +export function getFiniteBendRegion({ + outline, + nx, + ny, + center, + proximal, + axisMin, + axisMax, +}: { + outline: readonly Point2[] + nx: number + ny: number + center: number + proximal: number + axisMin: number + axisMax: number +}): Point2[] { + const finiteCrossings = lineCrossings(outline, nx, ny, center).filter( + ({ point }) => { + const along = -ny * point.x + nx * point.y + return along >= axisMin - EPS && along <= axisMax + EPS + }, + ) + if (finiteCrossings.length !== 2) + throw new Error( + "A finite PCB bend must cut one board cross-section from boundary to boundary", + ) + 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") + const centerRegion = movingPolygon(outline, a, b, nx, ny) + const alongMiddle = -ny * middle.x + nx * middle.y + const proximalCrossings = lineCrossings(outline, nx, ny, proximal) + for (let i = 0; i + 1 < proximalCrossings.length; i++) { + const left = proximalCrossings[i]!, + right = proximalCrossings[i + 1]! + const lo = -ny * left.point.x + nx * left.point.y + const hi = -ny * right.point.x + nx * right.point.y + if (alongMiddle <= lo + EPS || alongMiddle >= hi - EPS) continue + const midpoint = { + x: (left.point.x + right.point.x) / 2, + y: (left.point.y + right.point.y) / 2, + } + if (!pointInPolygon(midpoint, outline, false)) continue + const region = movingPolygon(outline, left, right, nx, ny) + if ( + region.some((p) => p.x * nx + p.y * ny < proximal - EPS) || + centerRegion.some((p) => !pointInPolygon(p, region)) || + region.some( + (p) => + p.x * nx + p.y * ny >= center - EPS && + !pointInPolygon(p, centerRegion), + ) + ) + break + return region + } + throw new Error( + "The finite PCB bend zone must remain in one connected board cross-section", + ) +} + +export function polygonsTouch(a: readonly Point2[], b: readonly Point2[]) { + if ( + a.some((p) => pointInPolygon(p, b)) || + b.some((p) => pointInPolygon(p, a)) + ) + return true + const cross = (p: Point2, q: Point2, r: Point2) => + (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x) + for (let i = 0; i < a.length; i++) + for (let j = 0; j < b.length; j++) { + const p = a[i]!, + q = a[(i + 1) % a.length]!, + r = b[j]!, + s = b[(j + 1) % b.length]! + if ( + cross(p, q, r) * cross(p, q, s) < -EPS && + cross(r, s, p) * cross(r, s, q) < -EPS + ) + return true + } + return false +} diff --git a/lib/pcb-fold.ts b/lib/pcb-fold.ts index 1ca3258..3d578da 100644 --- a/lib/pcb-fold.ts +++ b/lib/pcb-fold.ts @@ -1,4 +1,9 @@ -import type { Point3 } from "./types" +import type { Point2, Point3 } from "./types" +import { + getFiniteBendRegion, + pointInPolygon, + polygonsTouch, +} from "./finite-bend-region" /** Circuit JSON bend geometry, board-local +Z up, millimeters. */ export interface PcbBendRecord { type: "pcb_bend" @@ -24,6 +29,7 @@ interface Bend { radius: number axisMin: number axisMax: number + movingOutline?: readonly Point2[] } const EPS = 1e-7 @@ -37,6 +43,11 @@ export interface PcbFold { assertRigid(points: Point3[], label: string): void } +export interface PcbFoldOptions { + /** Simple board outline, board-local XY in millimeters. Enables finite chords. */ + outline?: readonly Point2[] +} + /** Parallel bends with the same moving direction form an ordered fold chain. * Input endpoints and returned transforms are board-local Circuit JSON (+Z up, mm). * Ordering comes from geometry, never from Circuit JSON array order. @@ -44,9 +55,18 @@ export interface PcbFold { export function createPcbFold( records: PcbBendRecord[], thickness: number, + { outline }: PcbFoldOptions = {}, ): PcbFold { if (!Number.isFinite(thickness) || thickness < 0) throw new Error("Board thickness must be finite and nonnegative") + if ( + outline && + (outline.length < 3 || + outline.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y))) + ) + throw new Error( + "A finite board outline with at least three points is required", + ) const bends: Bend[] = records .map((b) => { const { start, end, bend_angle: degrees, bend_radius: radius } = b @@ -71,7 +91,7 @@ export function createPcbFold( const angle = (degrees * Math.PI) / 180 const width = radius * Math.abs(angle) const center = start.x * nx + start.y * ny - return { + const geometry: Bend = { id: b.pcb_bend_id, nx, ny, @@ -88,6 +108,17 @@ export function createPcbFold( -ny * end.x + nx * end.y, ), } + if (outline && Math.abs(angle) > EPS) + geometry.movingOutline = getFiniteBendRegion({ + outline, + nx, + ny, + center, + proximal: geometry.start, + axisMin: geometry.axisMin, + axisMax: geometry.axisMax, + }) + return geometry }) .filter((b) => Math.abs(b.angle) > EPS) const first = bends[0] @@ -102,10 +133,18 @@ export function createPcbFold( ) } bends.sort((a, b) => a.start - b.start) - for (let i = 1; i < bends.length; i++) { - if (bends[i]!.start < bends[i - 1]!.end - EPS) - throw new Error("Overlapping PCB bend zones are not supported") - } + 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 Error("Overlapping PCB bend zones are not supported") + } const distalFirst = [...bends].reverse() const transform = ({ p, @@ -115,6 +154,7 @@ export function createPcbFold( let result = { ...p } // Fold distal regions first, then carry them with each proximal fold. for (const b of distalFirst) { + if (b.movingOutline && !pointInPolygon(anchor, b.movingOutline)) continue const width = b.end - b.start const s = anchor.x * b.nx + anchor.y * b.ny - b.start const q = Math.max(0, Math.min(width, s)) @@ -172,8 +212,13 @@ export function createPcbFold( transform({ p: p, anchor: anchor, direction: true }), assertRigid(points, label) { for (const b of bends) { - const ds = points.map((p) => p.x * b.nx + p.y * b.ny) - if (Math.max(...ds) > b.start + EPS && Math.min(...ds) < b.end - EPS) + const width = b.end - b.start + const progress = points.map((p) => + b.movingOutline && !pointInPolygon(p, b.movingOutline) + ? 0 + : 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}`) } }, diff --git a/lib/surface.ts b/lib/surface.ts index 22c7114..3c66cab 100644 --- a/lib/surface.ts +++ b/lib/surface.ts @@ -96,6 +96,7 @@ export function foldSurfaceMesh( ) for (const polygon of polygons) { for (const b of fold.bends) { + if (b.movingOutline) continue const mid = polygon.reduce((sum, { p }) => sum + p.x * b.nx + p.y * b.ny, 0) / polygon.length diff --git a/lib/types.ts b/lib/types.ts index 0a1477f..3b43776 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,3 +1,7 @@ +export interface Point2 { + x: number + y: number +} export interface Point3 { x: number y: number diff --git a/tests/finite-bend.test.ts b/tests/finite-bend.test.ts new file mode 100644 index 0000000..58f5ab4 --- /dev/null +++ b/tests/finite-bend.test.ts @@ -0,0 +1,235 @@ +import { expect, test } from "bun:test" +import { + createPcbFold, + createStiffenerMesh, + extrudePolygon, + foldSurfaceMesh, + transformCircuitJsonCadComponents, + type PcbBendRecord, + type Point2, + type Point3, +} from "../lib" +import type { AnyCircuitElement, PcbStiffenerRect } from "circuit-json" + +const outline: Point2[] = [ + { x: -20, y: -10 }, + { x: 20, y: -10 }, + { x: 20, y: 30 }, + { x: 12, y: 30 }, + { x: 12, y: 10 }, + { x: -12, y: 10 }, + { x: -12, y: 30 }, + { x: -20, y: 30 }, +] +const leftBend: PcbBendRecord = { + type: "pcb_bend", + pcb_bend_id: "left", + pcb_board_id: "board", + start: { x: -20, y: 20 }, + end: { x: -12, y: 20 }, + bend_angle: 90, + bend_radius: 1, + bend_side: "left", +} +const close = (actual: Point3, expected: Point3) => { + for (const coordinate of ["x", "y", "z"] as const) + expect(actual[coordinate]).toBeCloseTo(expected[coordinate], 7) +} + +test("a finite chord folds the left tail while the neighboring tail and its bend-zone mounts stay flat", () => { + const fold = createPcbFold([leftBend], 0.12, { outline }) + const left = { x: -16, y: 25, z: 0.2 }, + right = { x: 16, y: 25, z: 0.2 } + close(fold.point(left), { + x: -16, + y: 20 + 1 - Math.PI / 4 - 0.2, + z: 5 + 1 - Math.PI / 4, + }) + close(fold.point(right), right) + close(fold.direction({ x: 0, y: 0, z: 1 }, right), { x: 0, y: 0, z: 1 }) + close(fold.inversePoint(fold.point(left), left), left) + expect(() => + fold.assertRigid([{ x: 16, y: 20, z: 0 }], "right mount"), + ).not.toThrow() + expect(() => + fold.assertRigid([{ x: -16, y: 20, z: 0 }], "left mount"), + ).toThrow("left mount intersects") + const proximal = { x: -16, y: 20 - Math.PI / 8, z: 0 } + expect(fold.point(proximal).z).toBeGreaterThan(0) + // Keep the old full-line mode for consumers that have not provided an outline. + expect(createPcbFold([leftBend], 0.12).point(right).z).toBeGreaterThan(5) +}) + +test("independent tails can share a bend line and result is invariant to winding, endpoint direction, and record order", () => { + const rightBend = { + ...leftBend, + pcb_bend_id: "right", + start: { x: 12, y: 20 }, + end: { x: 20, y: 20 }, + bend_angle: -90, + } + for (const boundary of [outline, [...outline].reverse()]) + for (const records of [ + [leftBend, rightBend], + [rightBend, leftBend], + ]) + for (const reverse of [false, true]) { + const bends = reverse + ? records.map((b) => ({ + ...b, + start: b.end, + end: b.start, + bend_side: "right" as const, + })) + : records + const fold = createPcbFold(bends, 0.12, { outline: boundary }) + expect(fold.point({ x: -16, y: 25, z: 0 }).z).toBeCloseTo( + 6 - Math.PI / 4, + 7, + ) + expect(fold.point({ x: 16, y: 25, z: 0 }).z).toBeCloseTo( + -6 + Math.PI / 4, + 7, + ) + close(fold.point({ x: 0, y: 0, z: 0 }), { x: 0, y: 0, z: 0 }) + } +}) + +test("the complete connected tip folds when it widens beyond the bend endpoints", () => { + const widened: Point2[] = [ + { x: -20, y: -10 }, + { x: 20, y: -10 }, + { x: 20, y: 10 }, + { x: -12, y: 10 }, + { x: -12, y: 23 }, + { x: -5, y: 26 }, + { x: -5, y: 30 }, + { x: -25, y: 30 }, + { x: -25, y: 26 }, + { x: -20, y: 23 }, + ] + const fold = createPcbFold([leftBend], 0.12, { outline: widened }) + for (const x of [-23, -16, -7]) { + const point = { x, y: 28, z: 0 } + close(fold.point(point), { x, y: 21 - Math.PI / 4, z: 9 - Math.PI / 4 }) + } +}) + +test("surface subdivision and stiffeners use the same finite region as CAD mounts", () => { + const fold = createPcbFold([leftBend], 0.12, { outline }) + const mesh = foldSurfaceMesh( + extrudePolygon({ outline, bottom: -0.06, top: 0.06 }), + fold, + ) + const rightTailVertices = mesh.triangles + .flatMap((triangle) => triangle.vertices) + .filter((p) => p.x > 12 && p.y > 20) + expect(rightTailVertices.length).toBeGreaterThan(0) + expect(rightTailVertices.every((p) => Math.abs(p.z) <= 0.06 + 1e-7)).toBe( + true, + ) + expect(mesh.boundingBox.max.z).toBeCloseTo(11 - Math.PI / 4, 7) + const stiffener = { + type: "pcb_stiffener", + pcb_stiffener_id: "right stiffener", + pcb_board_id: "board", + shape: "rect", + center: { x: 16, y: 20 }, + width: 2, + height: 2, + layer: "bottom", + material: "polyimide", + thickness: 0.2, + } satisfies PcbStiffenerRect + const flat = createStiffenerMesh({ stiffener, boardThickness: 0.12 }) + expect( + createStiffenerMesh({ stiffener, boardThickness: 0.12, fold }), + ).toEqual(flat) + expect(() => + createStiffenerMesh({ + stiffener: { ...stiffener, center: { x: -16, y: 20 } }, + boardThickness: 0.12, + fold, + }), + ).toThrow("intersects") +}) + +test("Circuit JSON resolves world-space outlines to board-local finite folds and round-trips both tails", () => { + const center = { x: 100, y: -70 } + const json = [ + { + type: "pcb_board", + pcb_board_id: "board", + center, + thickness: 0.12, + outline: outline.map((p) => ({ x: p.x + center.x, y: p.y + center.y })), + }, + leftBend, + ...[-16, 16].flatMap((x, i) => [ + { + type: "pcb_component", + pcb_component_id: `pcb${i}`, + center: { x: x + center.x, y: 25 + center.y }, + layer: "top", + }, + { + type: "cad_component", + cad_component_id: `cad${i}`, + pcb_component_id: `pcb${i}`, + source_component_id: `src${i}`, + position: { x: x + center.x, y: 25 + center.y, z: 0.2 }, + rotation: { x: 0, y: 0, z: 0 }, + }, + ]), + ] as AnyCircuitElement[] + const folded = transformCircuitJsonCadComponents(json, { foldPcbs: true }) + const cad = folded.filter((e) => e.type === "cad_component") + expect(cad[0]!.position.z).toBeGreaterThan(5) + close(cad[1]!.position, { x: 116, y: -45, z: 0.2 }) + const restored = transformCircuitJsonCadComponents(folded, { + foldPcbs: false, + }).filter((e) => e.type === "cad_component") + for (let i = 0; i < restored.length; i++) + close( + restored[i]!.position, + json.filter((e) => e.type === "cad_component")[i]!.position, + ) +}) + +test("incomplete segments, multiple cross-sections and overlapping zones fail explicitly", () => { + expect(() => + createPcbFold([{ ...leftBend, start: { x: -19, y: 20 } }], 0.12, { + outline, + }), + ).toThrow("boundary to boundary") + expect(() => + createPcbFold([{ ...leftBend, end: { x: 20, y: 20 } }], 0.12, { outline }), + ).toThrow("one board cross-section") + expect(() => + createPcbFold([leftBend, { ...leftBend, pcb_bend_id: "duplicate" }], 0.12, { + outline, + }), + ).toThrow("Overlapping") + const atBranch = { + ...leftBend, + start: { x: -20, y: 10.3 }, + end: { x: -12, y: 10.3 }, + } + expect(() => createPcbFold([atBranch], 0.12, { outline })).toThrow( + "one connected board cross-section", + ) + const shorterRightTail = outline.map((p) => + p.x > 0 && p.y === 30 ? { ...p, y: 20 } : p, + ) + expect(() => + createPcbFold([leftBend], 0.12, { outline: shorterRightTail }), + ).not.toThrow() + const boundaryBend = { + ...leftBend, + start: { x: -20, y: 30 }, + end: { x: -12, y: 30 }, + } + expect(() => createPcbFold([boundaryBend], 0.12, { outline })).toThrow( + "board interior", + ) +}) From 8deadc4a6bba23243050bb1ecbe621ca89733c5e Mon Sep 17 00:00:00 2001 From: seveibar Date: Thu, 1 Oct 2026 12:48:12 -0700 Subject: [PATCH 2/2] test: add PoppyGL snapshots for finite bend tails --- .gitignore | 1 + package.json | 3 + .../finite-bend-flat-reference.snap.png | Bin 0 -> 8066 bytes .../finite-bend-left-tail-folded.snap.png | Bin 0 -> 8690 bytes tests/finite-bend-visual.test.ts | 71 +++++++++++ tests/fixtures/png-matcher.ts | 110 ++++++++++++++++++ tests/fixtures/render-surface-meshes.ts | 59 ++++++++++ 7 files changed, 244 insertions(+) create mode 100644 tests/__snapshots__/finite-bend-flat-reference.snap.png create mode 100644 tests/__snapshots__/finite-bend-left-tail-folded.snap.png create mode 100644 tests/finite-bend-visual.test.ts create mode 100644 tests/fixtures/png-matcher.ts create mode 100644 tests/fixtures/render-surface-meshes.ts diff --git a/.gitignore b/.gitignore index 07d597f..7fca2c1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ dist/ bun.lock bun.lockb +tests/**/__snapshots__/*.diff.png diff --git a/package.json b/package.json index 0a5100d..576488d 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,12 @@ "circuit-json": ">=0.0.499" }, "devDependencies": { + "@tscircuit/alphabet": "0.0.23", "@types/bun": "^1.3.0", "@types/earcut": "^3.0.0", "circuit-json": "^0.0.499", + "looks-same": "10.0.1", + "poppygl": "0.0.29", "typescript": "^5.9.2", "tsup": "^8.5.0" }, diff --git a/tests/__snapshots__/finite-bend-flat-reference.snap.png b/tests/__snapshots__/finite-bend-flat-reference.snap.png new file mode 100644 index 0000000000000000000000000000000000000000..1574aec0b1f3b0f79a5ad3b0b009cbe6d20c4e79 GIT binary patch literal 8066 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 literal 0 HcmV?d00001 diff --git a/tests/finite-bend-visual.test.ts b/tests/finite-bend-visual.test.ts new file mode 100644 index 0000000..eadbe3a --- /dev/null +++ b/tests/finite-bend-visual.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "bun:test" +import { + createPcbFold, + extrudePolygon, + foldSurfaceMesh, + type PcbBendRecord, +} from "../lib" +import "./fixtures/png-matcher" +import { renderSurfaceMeshes } from "./fixtures/render-surface-meshes" + +test("the finite bend folds RL's tail while RR's tail remains flat", async () => { + // Exact board outline and bend from tscircuit/tscircuit#5277, in board-local mm. + const outline = [ + { x: -20, y: -10 }, + { x: 20, y: -10 }, + { x: 20, y: 30 }, + { x: 12, y: 30 }, + { x: 12, y: 10 }, + { x: -12, y: 10 }, + { x: -12, y: 30 }, + { x: -20, y: 30 }, + ] + const bend: PcbBendRecord = { + type: "pcb_bend", + pcb_bend_id: "left_tail_bend", + pcb_board_id: "flex_board", + start: { x: -20, y: 20 }, + end: { x: -12, y: 20 }, + bend_angle: 90, + bend_radius: 1, + bend_side: "left", + } + const board = extrudePolygon({ outline, bottom: -0.06, top: 0.06 }) + // Colored mounting blocks at the issue's resistor positions make each tail + // easy to identify. They exercise the same surface deformation as the board. + const mounts = [-16, 16].map((x) => + extrudePolygon({ + outline: [ + { x: x - 1, y: 24.5 }, + { x: x + 1, y: 24.5 }, + { x: x + 1, y: 25.5 }, + { x: x - 1, y: 25.5 }, + ], + bottom: 0.06, + top: 0.6, + }), + ) + + for (const folded of [false, true]) { + const fold = createPcbFold(folded ? [bend] : [], 0.12, { outline }) + const png = await renderSurfaceMeshes( + [ + { mesh: foldSurfaceMesh(board, fold), color: [0.85, 0.52, 0.12, 1] }, + { mesh: foldSurfaceMesh(mounts[0]!, fold), color: [0.8, 0.12, 0.1, 1] }, + { mesh: foldSurfaceMesh(mounts[1]!, fold), color: [0.08, 0.3, 0.8, 1] }, + ], + { + debugPoints: [ + { label: "RL", position: fold.point({ x: -16, y: 25, z: 0.6 }) }, + { label: "RR", position: fold.point({ x: 16, y: 25, z: 0.6 }) }, + ], + debugFontSize: 20, + debugLabelColor: [0.08, 0.1, 0.15], + }, + ) + await expect(png).toMatchPngSnapshot( + import.meta.path, + folded ? "finite-bend-left-tail-folded" : "finite-bend-flat-reference", + ) + } +}) diff --git a/tests/fixtures/png-matcher.ts b/tests/fixtures/png-matcher.ts new file mode 100644 index 0000000..dd9d359 --- /dev/null +++ b/tests/fixtures/png-matcher.ts @@ -0,0 +1,110 @@ +import { expect, type MatcherResult } from "bun:test" +import * as fs from "node:fs" +import * as path from "node:path" +import looksSame from "looks-same" + +/** + * Matcher for PNG snapshot testing. + * Uses tscircuit/poppygl's tests/fixtures/png-matcher.ts conventions. + * + * Usage: + * expect(pngBuffer).toMatchPngSnapshot(import.meta.path, "optionalName"); + */ +async function toMatchPngSnapshot( + // biome-ignore lint/suspicious/noExplicitAny: bun doesn't expose + this: any, + receivedMaybePromise: Buffer | Uint8Array | Promise, + testPathOriginal: string, + pngName?: string, +): Promise { + const received = await receivedMaybePromise + const testPath = testPathOriginal + .replace(/\.test\.tsx?$/, "") + .replace(/\.test\.ts$/, "") + const snapshotDir = path.join(path.dirname(testPath), "__snapshots__") + const snapshotName = pngName + ? `${pngName}.snap.png` + : `${path.basename(testPath)}.snap.png` + const filePath = path.join(snapshotDir, snapshotName) + + if (!fs.existsSync(snapshotDir)) { + fs.mkdirSync(snapshotDir, { recursive: true }) + } + + const updateSnapshot = + process.argv.includes("--update-snapshots") || + process.argv.includes("-u") || + Boolean(process.env["BUN_UPDATE_SNAPSHOTS"]) + const forceUpdate = Boolean(process.env["FORCE_BUN_UPDATE_SNAPSHOTS"]) + + const fileExists = fs.existsSync(filePath) + + if (!fileExists) { + console.log("Writing PNG snapshot to", filePath) + fs.writeFileSync(filePath, received) + return { + message: () => `PNG snapshot created at ${filePath}`, + pass: true, + } + } + + const existingSnapshot = fs.readFileSync(filePath) + + const result: any = await looksSame( + Buffer.from(received), + Buffer.from(existingSnapshot), + { + strict: false, + tolerance: 2, + }, + ) + + if (updateSnapshot) { + if (!forceUpdate && result.equal) { + return { + message: () => "PNG snapshot matches", + pass: true, + } + } + console.log("Updating PNG snapshot at", filePath) + fs.writeFileSync(filePath, received) + return { + message: () => `PNG snapshot updated at ${filePath}`, + pass: true, + } + } + + if (result.equal) { + return { + message: () => "PNG snapshot matches", + pass: true, + } + } + + const diffPath = filePath.replace(/\.snap\.png$/, ".diff.png") + await looksSame.createDiff({ + reference: Buffer.from(existingSnapshot), + current: Buffer.from(received), + diff: diffPath, + highlightColor: "#ff00ff", + }) + + return { + message: () => `PNG snapshot does not match. Diff saved at ${diffPath}`, + pass: false, + } +} + +// Register the matcher globally for Bun's expect +expect.extend({ + toMatchPngSnapshot: toMatchPngSnapshot as any, +}) + +declare module "bun:test" { + interface Matchers { + toMatchPngSnapshot( + testPath: string, + pngName?: string, + ): Promise + } +} diff --git a/tests/fixtures/render-surface-meshes.ts b/tests/fixtures/render-surface-meshes.ts new file mode 100644 index 0000000..c834bde --- /dev/null +++ b/tests/fixtures/render-surface-meshes.ts @@ -0,0 +1,59 @@ +import { + encodePNG, + renderDrawCalls, + type DrawCall, + type RenderOptionsInput, +} from "poppygl" +import type { SurfaceMesh } from "../../lib" + +/** Render flex-utils geometry directly in its right-handed board-local frame: + * +X right, +Y top, +Z above, millimeters. No viewer/exporter transform is applied. + * Camera positions and debug points use that same frame. + */ +export async function renderSurfaceMeshes( + meshes: { + mesh: SurfaceMesh + color: DrawCall["material"]["baseColorFactor"] + }[], + options: RenderOptionsInput, +) { + const drawCalls: DrawCall[] = meshes.map(({ mesh, color }) => ({ + positions: new Float32Array( + mesh.triangles.flatMap((triangle) => + triangle.vertices.flatMap(({ x, y, z }) => [x, y, z]), + ), + ), + normals: new Float32Array( + mesh.triangles.flatMap(({ normal: { x, y, z } }) => [ + x, + y, + z, + x, + y, + z, + x, + y, + z, + ]), + ), + uvs: null, + indices: null, + model: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]), + material: { baseColorFactor: color, baseColorTexture: null }, + })) + const { bitmap } = renderDrawCalls(drawCalls, { + width: 1000, + height: 760, + camPos: [55, -65, 60], + lookAt: [0, 9, 2], + up: "z+", + fov: 35, + cull: false, + backgroundColor: "#f4f5f7", + ambient: 0.45, + lightDir: [-0.4, -0.5, -1], + grid: false, + ...options, + }) + return encodePNG(bitmap) +}