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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ 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.
- `extrudePolygon(outline, bottom, top)` / `createStiffenerMesh(...)`: shared
- `extrudePolygon({ outline, bottom, top })` / `createStiffenerMesh(...)`: shared
polygon extrusion and rigid stiffener geometry including adhesive spacing.
- `transformCadComponent` / `getCadFoldContext`: single-component pose helpers.

Expand All @@ -37,3 +37,5 @@ lie in bend zones. Unsupported geometry throws. This package has no Three.js,
GLTF, DOM, native addon, model loader or rendering dependency.

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.
81 changes: 55 additions & 26 deletions lib/cad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,61 +52,83 @@ export interface CadFoldContext {
defaultRotation?: Point3
}

/** Pure, idempotent CAD pose conversion. Position is global Circuit JSON +Z-up
export type CadComponentPlacement = Pick<
CadComponentWithFoldState,
"position" | "rotation" | "is_on_folded_board"
>

/** Pure, idempotent CAD component placement conversion. Position is global Circuit JSON +Z-up
* millimeters; rotation is XYZ degrees. Model-origin/scale/normal fields stay
* model-local. The PCB mount selects the inverse when assembled regions overlap.
*/
export function transformCadComponent<T extends CadComponentWithFoldState>(
cad: T,
{ cadComponent, foldPcbs }: { cadComponent: T; foldPcbs: boolean },
context: CadFoldContext,
folded: boolean,
): T {
if ((cad.is_on_folded_board === true) === folded) return cad
return transformCadComponentPlacement(
{ cadComponentPlacement: cadComponent, foldPcbs },
context,
)
}

/** Transform a CAD component placement before a circuit-json record/id exists. Coordinates
* and rotations use the same world-space convention as transformCadComponent.
*/
export function transformCadComponentPlacement<T extends CadComponentPlacement>(
{
cadComponentPlacement,
foldPcbs,
}: { cadComponentPlacement: T; foldPcbs: boolean },
context: CadFoldContext,
): T {
if ((cadComponentPlacement.is_on_folded_board === true) === foldPcbs)
return cadComponentPlacement
const { fold, boardCenter, flatMount } = context
const anchor = {
x: flatMount.x - boardCenter.x,
y: flatMount.y - boardCenter.y,
z: 0,
}
fold.assertRigid([anchor], cad.cad_component_id)
fold.assertRigid([anchor], "CAD mount")
const point = {
x: cad.position.x - boardCenter.x,
y: cad.position.y - boardCenter.y,
z: cad.position.z,
x: cadComponentPlacement.position.x - boardCenter.x,
y: cadComponentPlacement.position.y - boardCenter.y,
z: cadComponentPlacement.position.z,
}
const direction = folded ? fold.direction : fold.inverseDirection
const transformed = folded
const direction = foldPcbs ? fold.direction : fold.inverseDirection
const transformed = foldPcbs
? fold.point(point, anchor)
: fold.inversePoint(point, anchor)
const rotation = cad.rotation ??
const rotation = cadComponentPlacement.rotation ??
context.defaultRotation ?? { x: 0, y: 0, z: 0 }
const basis = axes.map((v) => direction(rotateVector(v, rotation), anchor))
return {
...cad,
...cadComponentPlacement,
position: {
x: transformed.x + boardCenter.x,
y: transformed.y + boardCenter.y,
z: transformed.z,
},
rotation: rotationFromAxes(basis),
is_on_folded_board: folded,
is_on_folded_board: foldPcbs,
}
}

/** Resolve an explicit board reference, then subcircuit ownership. An ambiguous
* multi-board association fails rather than guessing from folded coordinates. */
export function getCadFoldContext(
cad: CadComponentWithFoldState,
cadComponent: CadComponentWithFoldState,
json: AnyCircuitElement[],
): CadFoldContext | undefined {
const pcb = json.find(
(e) =>
e.type === "pcb_component" && e.pcb_component_id === cad.pcb_component_id,
e.type === "pcb_component" &&
e.pcb_component_id === cadComponent.pcb_component_id,
)
if (pcb?.type !== "pcb_component") {
if (cad.is_on_folded_board)
if (cadComponent.is_on_folded_board)
throw new Error(
`Folded CAD ${cad.cad_component_id} requires its flat pcb_component reference`,
`Folded CAD ${cadComponent.cad_component_id} requires its flat pcb_component reference`,
)
return undefined
}
Expand All @@ -125,7 +147,7 @@ export function getCadFoldContext(
: undefined
if (!board)
throw new Error(
`Cannot unambiguously resolve board for CAD ${cad.cad_component_id}`,
`Cannot unambiguously resolve board for CAD ${cadComponent.cad_component_id}`,
)
const bends = json.filter(
(e): e is PcbBendRecord =>
Expand All @@ -136,7 +158,7 @@ export function getCadFoldContext(
boardCenter: board.center,
flatMount: pcb.center,
defaultRotation: {
x: (cad.layer ?? pcb.layer) === "bottom" ? 180 : 0,
x: (cadComponent.layer ?? pcb.layer) === "bottom" ? 180 : 0,
y: 0,
z: 0,
},
Expand All @@ -145,20 +167,27 @@ export function getCadFoldContext(

/** Transform CAD records only; PCB traces/components, bends, stiffeners, source
* records and the caller's input array are left untouched. Mixed flat/folded CAD
* is normalized record-by-record, so repeated calls cannot double-fold a pose. */
* is normalized record-by-record, so repeated calls cannot double-fold a placement. */
export function transformCircuitJsonCadComponents<T extends AnyCircuitElement>(
json: T[],
options: { foldPcbs: boolean },
): T[] {
const hasBends = json.some((element) => element.type === "pcb_bend")
return json.map((element) => {
if (element.type !== "cad_component") return element
const cad = element as CadComponentWithFoldState
if (!hasBends && !cad.is_on_folded_board) return element
if ((cad.is_on_folded_board === true) === options.foldPcbs) return element
const context = getCadFoldContext(cad, json)
if (!context || (!context.fold.bends.length && !cad.is_on_folded_board))
const cadComponent = element as CadComponentWithFoldState
if (!hasBends && !cadComponent.is_on_folded_board) return element
if ((cadComponent.is_on_folded_board === true) === options.foldPcbs)
return element
const context = getCadFoldContext(cadComponent, json)
if (
!context ||
(!context.fold.bends.length && !cadComponent.is_on_folded_board)
)
return element
return transformCadComponent(cad, context, options.foldPcbs) as T
return transformCadComponent(
{ cadComponent: cadComponent, foldPcbs: options.foldPcbs },
context,
) as T
})
}
32 changes: 24 additions & 8 deletions lib/pcb-fold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,11 @@ export function createPcbFold(
throw new Error("Overlapping PCB bend zones are not supported")
}
const distalFirst = [...bends].reverse()
const transform = (p: Point3, anchor: Point3, direction: boolean): Point3 => {
const transform = ({
p,
anchor,
direction,
}: { 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 @@ -135,25 +139,37 @@ export function createPcbFold(
}
// For a fixed flat mount this is an affine rigid transform R*p + t.
// Its inverse is R^T*(p-t), even when different folded regions overlap.
const inverse = (p: Point3, anchor: Point3, direction: boolean): Point3 => {
const inverse = ({
p,
anchor,
direction,
}: { p: Point3; anchor: Point3; direction: boolean }): Point3 => {
const origin = direction
? { x: 0, y: 0, z: 0 }
: transform({ x: 0, y: 0, z: 0 }, anchor, false)
: transform({
p: { x: 0, y: 0, z: 0 },
anchor: anchor,
direction: false,
})
const d = { x: p.x - origin.x, y: p.y - origin.y, z: p.z - origin.z }
const axes = [
{ x: 1, y: 0, z: 0 },
{ x: 0, y: 1, z: 0 },
{ x: 0, y: 0, z: 1 },
].map((v) => transform(v, anchor, true))
].map((v) => transform({ p: v, anchor: anchor, direction: true }))
const dot = (v: Point3) => v.x * d.x + v.y * d.y + v.z * d.z
return { x: dot(axes[0]!), y: dot(axes[1]!), z: dot(axes[2]!) }
}
return {
bends,
inversePoint: (p, anchor) => inverse(p, anchor, false),
inverseDirection: (p, anchor) => inverse(p, anchor, true),
point: (p, anchor = p) => transform(p, anchor, false),
direction: (p, anchor) => transform(p, anchor, true),
inversePoint: (p, anchor) =>
inverse({ p: p, anchor: anchor, direction: false }),
inverseDirection: (p, anchor) =>
inverse({ p: p, anchor: anchor, direction: true }),
point: (p, anchor = p) =>
transform({ p: p, anchor: anchor, direction: false }),
direction: (p, anchor) =>
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)
Expand Down
66 changes: 48 additions & 18 deletions lib/polygon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import { boundsOfTriangles } from "./surface"

/** Extrude a simple polygon in board-local Circuit JSON (+Z up, mm).
* Used for stiffeners and uncut board surfaces; face labels survive folding. */
export function extrudePolygon(
outline: { x: number; y: number }[],
bottom: number,
top: number,
): SurfaceMesh {
export function extrudePolygon({
outline,
bottom,
top,
}: {
outline: { x: number; y: number }[]
bottom: number
top: number
}): SurfaceMesh {
if (
outline.length < 3 ||
!Number.isFinite(bottom) ||
Expand All @@ -19,19 +23,25 @@ export function extrudePolygon(
throw new Error(
"A finite polygon and positive extrusion thickness are required",
)
const area = outline.reduce((s, a, i) => {
let area = 0
for (const [i, a] of outline.entries()) {
const b = outline[(i + 1) % outline.length]!
return s + a.x * b.y - b.x * a.y
}, 0)
area += a.x * b.y - b.x * a.y
}
if (Math.abs(area) < 1e-12) throw new Error("Degenerate polygon")
const polygon = area > 0 ? outline : [...outline].reverse()
const triangles: Triangle[] = []
const add = (
a: Point3,
b: Point3,
c: Point3,
pcbFace: Triangle["pcbFace"],
) => {
const add = ({
a,
b,
c,
pcbFace,
}: {
a: Point3
b: Point3
c: Point3
pcbFace: Triangle["pcbFace"]
}) => {
const u = { x: b.x - a.x, y: b.y - a.y, z: b.z - a.z },
v = { x: c.x - a.x, y: c.y - a.y, z: c.z - a.z }
const n = {
Expand All @@ -54,14 +64,34 @@ export function extrudePolygon(
(typeof polygon)[number],
(typeof polygon)[number],
]
add({ ...a, z: top }, { ...b, z: top }, { ...c, z: top }, "top")
add({ ...c, z: bottom }, { ...b, z: bottom }, { ...a, z: bottom }, "bottom")
add({
a: { ...a, z: top },
b: { ...b, z: top },
c: { ...c, z: top },
pcbFace: "top",
})
add({
a: { ...c, z: bottom },
b: { ...b, z: bottom },
c: { ...a, z: bottom },
pcbFace: "bottom",
})
}
for (let i = 0; i < polygon.length; i++) {
const a = polygon[i]!,
b = polygon[(i + 1) % polygon.length]!
add({ ...a, z: bottom }, { ...b, z: bottom }, { ...b, z: top }, "side")
add({ ...a, z: bottom }, { ...b, z: top }, { ...a, z: top }, "side")
add({
a: { ...a, z: bottom },
b: { ...b, z: bottom },
c: { ...b, z: top },
pcbFace: "side",
})
add({
a: { ...a, z: bottom },
b: { ...b, z: top },
c: { ...a, z: top },
pcbFace: "side",
})
}
return { triangles, boundingBox: boundsOfTriangles(triangles) }
}
24 changes: 14 additions & 10 deletions lib/stiffener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ import { boundsOfTriangles } from "./surface"

/** Stiffener vertices stay board-local (+Z up, mm). Stiffeners are rigid and may
* not cross a bend zone. Includes the adhesive spacing from the board surface. */
export function createStiffenerMesh(
stiffener: PcbStiffener,
boardThickness: number,
fold?: PcbFold,
) {
export function createStiffenerMesh({
stiffener,
boardThickness,
fold,
}: {
stiffener: PcbStiffener
boardThickness: number
fold?: PcbFold
}) {
const adhesive = stiffener.adhesive_thickness ?? 0
if (
!Number.isFinite(adhesive) ||
Expand Down Expand Up @@ -44,11 +48,11 @@ export function createStiffenerMesh(
})()
const near = boardThickness / 2 + adhesive,
far = near + stiffener.thickness
const mesh = extrudePolygon(
outline,
stiffener.layer === "top" ? near : -far,
stiffener.layer === "top" ? far : -near,
)
const mesh = extrudePolygon({
outline: outline,
bottom: stiffener.layer === "top" ? near : -far,
top: stiffener.layer === "top" ? far : -near,
})
if (!fold) return mesh
fold.assertRigid(
mesh.triangles.flatMap((t) => t.vertices),
Expand Down
21 changes: 14 additions & 7 deletions lib/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,17 @@ export function foldSurfaceMesh<T extends SurfaceMesh>(
})
const { min, max } = mesh.boundingBox
type Vertex = { p: Point3; uv: { u: number; v: number } }
const split = (
polygon: Vertex[],
nx: number,
ny: number,
d: number,
): Vertex[][] => {
const split = ({
polygon,
nx,
ny,
d,
}: {
polygon: Vertex[]
nx: number
ny: number
d: number
}): Vertex[][] => {
const distances = polygon.map(({ p }) => nx * p.x + ny * p.y - d)
if (Math.min(...distances) >= -EPS || Math.max(...distances) <= EPS)
return [polygon]
Expand Down Expand Up @@ -86,7 +91,9 @@ export function foldSurfaceMesh<T extends SurfaceMesh>(
})),
]
for (const { b, d } of planes)
polygons = polygons.flatMap((p) => split(p, b.nx, b.ny, d))
polygons = polygons.flatMap((p) =>
split({ polygon: p, nx: b.nx, ny: b.ny, d: d }),
)
for (const polygon of polygons) {
for (const b of fold.bends) {
const mid =
Expand Down
Loading
Loading