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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ Equivalent Euler triples at gimbal lock may differ, but the pose round-trips.
`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.
- `foldRigidMesh(mesh, fold, { flatAnchor, label, mount })`: carry rigid geometry
and normals with one flat anchor. An optional PCB mount is validated separately
from model extents, including models offset away from their mount.
- `extrudePolygon({ outline, bottom, top })` / `createStiffenerMesh(...)`: shared
polygon extrusion and rigid stiffener geometry including adhesive spacing.
- `transformCadComponent` / `getCadFoldContext`: single-component pose helpers.
Expand All @@ -45,6 +48,16 @@ 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.

Renderers can use `tryCreatePcbFold`, `tryFoldSurfaceMesh`, `tryFoldRigidMesh`, and
`tryFoldStiffenerMesh` to handle expected folding limitations explicitly. These
return `{ ok: true, value }` or `{ ok: false, issue }`, where `issue` has a stable
`code`, a `message`, and an optional `bendId`. For stiffeners, construct the flat
mesh once, then call `tryFoldStiffenerMesh(mesh, stiffener, fold)` to preserve the
shared outline-average anchor. The caller chooses whether to retain flat geometry
and how to report the issue. Strict APIs throw `PcbFoldError` carrying the same
issue and retain their existing messages. Malformed flat geometry and unexpected
errors still throw through the result APIs; CAD pose normalization remains strict.

Run `bun test`, `bun run typecheck`, and `bun run build`.

`transformCadComponent({ cadComponent, foldPcbs }, context)` transforms an existing record. `transformCadComponentPlacement({ cadComponentPlacement, foldPcbs }, context)` transforms its position and rotation before a record exists. Both use `CadFoldContext` as the second argument.
27 changes: 20 additions & 7 deletions lib/finite-bend-region.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Point2 } from "./types"
import { PcbFoldError } from "./fold-result"

const EPS = 1e-7
type BoundaryCrossing = { point: Point2; edge: number; fraction: number }
Expand Down Expand Up @@ -109,6 +110,7 @@ function movingPolygon(
* Simple outlines with one connected cross-section through the bend are supported.
*/
export function getFiniteBendRegion({
bendId,
outline,
nx,
ny,
Expand All @@ -117,6 +119,7 @@ export function getFiniteBendRegion({
axisMin,
axisMax,
}: {
bendId?: string
outline: readonly Point2[]
nx: number
ny: number
Expand All @@ -132,16 +135,23 @@ export function getFiniteBendRegion({
},
)
if (finiteCrossings.length !== 2)
throw new Error(
"A finite PCB bend must cut one board cross-section from boundary to boundary",
)
throw new PcbFoldError({
code: "invalid_finite_bend_region",
message:
"A finite PCB bend must cut one board cross-section from boundary to boundary",
bendId,
})
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")
throw new PcbFoldError({
code: "invalid_finite_bend_region",
message: "A finite PCB bend must pass through the board interior",
bendId,
})
const centerRegion = movingPolygon(outline, a, b, nx, ny)
const alongMiddle = -ny * middle.x + nx * middle.y
const proximalCrossings = lineCrossings(outline, nx, ny, proximal)
Expand Down Expand Up @@ -169,9 +179,12 @@ export function getFiniteBendRegion({
break
return region
}
throw new Error(
"The finite PCB bend zone must remain in one connected board cross-section",
)
throw new PcbFoldError({
code: "invalid_finite_bend_region",
message:
"The finite PCB bend zone must remain in one connected board cross-section",
bendId,
})
}

export function polygonsTouch(a: readonly Point2[], b: readonly Point2[]) {
Expand Down
39 changes: 39 additions & 0 deletions lib/fold-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/** Expected reasons that valid flat geometry cannot be folded. */
export interface PcbFoldIssue {
code:
| "invalid_bend_geometry"
| "unsupported_bend_geometry"
| "nonparallel_bends"
| "overlapping_bend_zones"
| "invalid_finite_bend_region"
| "incomplete_bend_cross_section"
| "rigid_bend_zone_intersection"
message: string
bendId?: string
}

/** Strict folding APIs throw this error for expected folding limitations.
* Invalid flat meshes and unexpected failures remain ordinary exceptions.
*/
export class PcbFoldError extends Error {
constructor(public readonly issue: PcbFoldIssue) {
super(issue.message)
this.name = "PcbFoldError"
}
}

export type PcbFoldResult<T> =
| { ok: true; value: T }
| { ok: false; issue: PcbFoldIssue }

/** Internal bridge between strict and result APIs. Never swallow model,
* geometry, or programming errors that are not declared folding limitations.
*/
export function capturePcbFoldResult<T>(fold: () => T): PcbFoldResult<T> {
try {
return { ok: true, value: fold() }
} catch (error) {
if (!(error instanceof PcbFoldError)) throw error
return { ok: false, issue: error.issue }
}
}
6 changes: 6 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ export * from "./cad"
export * from "./polygon"

export * from "./stiffener"
export * from "./rigid"
export {
PcbFoldError,
type PcbFoldIssue,
type PcbFoldResult,
} from "./fold-result"
65 changes: 52 additions & 13 deletions lib/pcb-fold.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { Point2, Point3 } from "./types"
import {
capturePcbFoldResult,
PcbFoldError,
type PcbFoldResult,
} from "./fold-result"
import {
getFiniteBendRegion,
pointInPolygon,
Expand Down Expand Up @@ -76,15 +81,19 @@ export function createPcbFold(
radius <= thickness / 2 ||
!["left", "right"].includes(b.bend_side)
) {
throw new Error(
`Invalid PCB bend ${b.pcb_bend_id}: finite geometry and radius greater than half the board thickness are required`,
)
throw new PcbFoldError({
code: "invalid_bend_geometry",
message: `Invalid PCB bend ${b.pcb_bend_id}: finite geometry and radius greater than half the board thickness are required`,
bendId: b.pcb_bend_id,
})
}
const length = Math.hypot(end.x - start.x, end.y - start.y)
if (length < EPS || Math.abs(degrees) > 180)
throw new Error(
`Unsupported PCB bend ${b.pcb_bend_id}: distinct endpoints and angles within ±180 degrees are required`,
)
throw new PcbFoldError({
code: "unsupported_bend_geometry",
message: `Unsupported PCB bend ${b.pcb_bend_id}: distinct endpoints and angles within ±180 degrees are required`,
bendId: b.pcb_bend_id,
})
const sign = b.bend_side === "right" ? 1 : -1
const nx = (sign * (end.y - start.y)) / length
const ny = (-sign * (end.x - start.x)) / length
Expand All @@ -110,6 +119,7 @@ export function createPcbFold(
}
if (outline && Math.abs(angle) > EPS)
geometry.movingOutline = getFiniteBendRegion({
bendId: b.pcb_bend_id,
outline,
nx,
ny,
Expand All @@ -128,9 +138,11 @@ export function createPcbFold(
(b) => Math.abs(b.nx - first.nx) > EPS || Math.abs(b.ny - first.ny) > EPS,
)
) {
throw new Error(
"Folded PCB rendering currently requires parallel bends with the same moving direction",
)
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++)
Expand All @@ -143,14 +155,22 @@ export function createPcbFold(
!b.movingOutline ||
polygonsTouch(a.movingOutline, b.movingOutline))
)
throw new Error("Overlapping PCB bend zones are not supported")
throw new PcbFoldError({
code: "overlapping_bend_zones",
message: "Overlapping PCB bend zones are not supported",
bendId: b.id,
})
}
const distalFirst = [...bends].reverse()
const transform = ({
p,
anchor,
direction,
}: { p: Point3; anchor: Point3; direction: boolean }): Point3 => {
}: {
p: Point3
anchor: Point3
direction: boolean
}): Point3 => {
let result = { ...p }
// Fold distal regions first, then carry them with each proximal fold.
for (const b of distalFirst) {
Expand Down Expand Up @@ -183,7 +203,11 @@ export function createPcbFold(
p,
anchor,
direction,
}: { p: Point3; anchor: Point3; direction: boolean }): Point3 => {
}: {
p: Point3
anchor: Point3
direction: boolean
}): Point3 => {
const origin = direction
? { x: 0, y: 0, z: 0 }
: transform({
Expand Down Expand Up @@ -219,8 +243,23 @@ export function createPcbFold(
: 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}`)
throw new PcbFoldError({
code: "rigid_bend_zone_intersection",
message: `${label} intersects PCB bend zone ${b.id}`,
bendId: b.id,
})
}
},
}
}

/** Construct a board-local (+Z up, mm) fold, reporting expected folding
* limitations as data. Malformed board geometry and unexpected errors throw.
*/
export function tryCreatePcbFold(
records: PcbBendRecord[],
thickness: number,
options: PcbFoldOptions = {},
): PcbFoldResult<PcbFold> {
return capturePcbFoldResult(() => createPcbFold(records, thickness, options))
}
49 changes: 49 additions & 0 deletions lib/rigid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { capturePcbFoldResult, type PcbFoldResult } from "./fold-result"
import type { PcbFold } from "./pcb-fold"
import { boundsOfTriangles } from "./surface"
import type { Point3, SurfaceMesh, Triangle } from "./types"

export interface RigidFoldOptions {
/** Board-local flat point (+Z up, mm) selecting one rigid fold transform. */
flatAnchor: Point3
label?: string
/** Optional PCB mount, independently checked even if a model offset moves
* all mesh vertices outside the bend zone. Board-local +Z up, mm.
*/
mount?: Point3
}

/** Carry a rigid mesh with its PCB in board-local Circuit JSON (+Z up, mm).
* Vertices are points; normals are directions and do not receive translation.
* Preserve triangle metadata and UVs, and leave the input mesh untouched.
*/
export function foldRigidMesh<T extends SurfaceMesh>(
mesh: T,
fold: PcbFold,
{ flatAnchor, label = "Rigid mesh", mount }: RigidFoldOptions,
): T {
if (mount) fold.assertRigid([mount], `${label} mount`)
fold.assertRigid(
mesh.triangles.flatMap((t) => t.vertices),
label,
)
const triangles = mesh.triangles.map((t) => ({
...t,
vertices: t.vertices.map((p) =>
fold.point(p, flatAnchor),
) as Triangle["vertices"],
normal: fold.direction(t.normal, flatAnchor),
}))
return { ...mesh, triangles, boundingBox: boundsOfTriangles(triangles) }
}

/** Report a rigid mesh/mount intersecting a bend zone as data. Invalid mesh
* data and unexpected transformation failures throw; no flat policy is chosen.
*/
export function tryFoldRigidMesh<T extends SurfaceMesh>(
mesh: T,
fold: PcbFold,
options: RigidFoldOptions,
): PcbFoldResult<T> {
return capturePcbFoldResult(() => foldRigidMesh(mesh, fold, options))
}
Loading
Loading