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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ node_modules/
dist/
bun.lock
bun.lockb
tests/**/__snapshots__/*.diff.png
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,25 @@ 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.
- `extrudePolygon({ outline, bottom, top })` / `createStiffenerMesh(...)`: shared
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.

Expand Down
7 changes: 6 additions & 1 deletion lib/cad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
198 changes: 198 additions & 0 deletions lib/finite-bend-region.ts
Original file line number Diff line number Diff line change
@@ -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
}
61 changes: 53 additions & 8 deletions lib/pcb-fold.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -24,6 +29,7 @@ interface Bend {
radius: number
axisMin: number
axisMax: number
movingOutline?: readonly Point2[]
}
const EPS = 1e-7

Expand All @@ -37,16 +43,30 @@ 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.
*/
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
Expand All @@ -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,
Expand All @@ -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]
Expand All @@ -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,
Expand All @@ -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))
Expand Down Expand Up @@ -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}`)
}
},
Expand Down
1 change: 1 addition & 0 deletions lib/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export function foldSurfaceMesh<T extends SurfaceMesh>(
)
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
Expand Down
4 changes: 4 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export interface Point2 {
x: number
y: number
}
export interface Point3 {
x: number
y: number
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
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
Loading