Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions lib/finite-bend-region.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
110 changes: 76 additions & 34 deletions lib/pcb-fold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import {
getFiniteBendRegion,
pointInPolygon,
polygonContainsPolygon,
polygonsTouch,
} from "./finite-bend-region"
/** Circuit JSON bend geometry, board-local +Z up, millimeters. */
Expand Down Expand Up @@ -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<Bend, Bend[]>()
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<Bend>()
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[],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) =>
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 39 additions & 0 deletions tests/fixtures/two-arm-board.ts
Original file line number Diff line number Diff line change
@@ -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 }
82 changes: 82 additions & 0 deletions tests/nonparallel-bends-visual.test.ts
Original file line number Diff line number Diff line change
@@ -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)
}
})
Loading
Loading