diff --git a/docs/assembly-explosion.md b/docs/assembly-explosion.md
new file mode 100644
index 0000000000..f42b27e5fa
--- /dev/null
+++ b/docs/assembly-explosion.md
@@ -0,0 +1,114 @@
+# Speculative assembly explosion rendering
+
+**Approval status: pending. This is a draft experiment, not approved for merge.**
+
+| Proposal | Status | Dependency / approval gate |
+| ------------------------------------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------ |
+| [Props stage and move API](https://github.com/tscircuit/props/pull/936) | Draft, awaiting API approval | Approve the public stage/move API before adopting it. |
+| [PoppyGL explosion guide lines](https://github.com/tscircuit/poppygl/pull/49) | Speculative draft, awaiting approval | Opt-in depth-tested guide rendering. |
+| [Core constrained explosion views](https://github.com/tscircuit/core/pull/4631) | Speculative draft, awaiting approval | Requires approval of both upstream drafts and this implementation. |
+
+The core branch pins upstream PR previews for reproducibility. The existing
+dependency-check workflow intentionally rejects preview dependencies before
+merge. After approval, release the upstream packages and replace these pins with
+published versions; do not weaken that merge gate.
+
+## Usage
+
+Use `assembly.explosion.stage` and `assembly.explosion.move` directly in an
+assembly device or subassembly. There is no explosion container, explicit
+collision-check element, `run`, or `detach`.
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+After normal rendering resolves mounting constraints and CAD geometry, derive a
+separate view:
+
+```ts
+await circuit.renderUntilSettled();
+const exploded = getAssemblyExplosion(circuit);
+const lifted = getAssemblyExplosion(circuit, {
+ throughStageRef: "instrument.lift-cover",
+ stageProgress: 0.5,
+});
+// Pass exploded.circuitJson to the ordinary GLTF exporter.
+```
+
+The assembled circuit, PCB positions, input props, and mounting constraints stay
+unchanged. Each call reconstructs the view from assembled geometry, so repeated
+evaluation and backward scrubbing do not accumulate offsets.
+
+All stages enter one stable topological ordering. `nextStageRef` and
+`prevStageRef` are precedence edges; depth-first TSX order breaks ties among
+eligible stages. Short references are relative to the owning assembly;
+qualified references traverse named assembly ancestors, including intermediate
+parts. Root-qualified references can address sibling scopes. Duplicate stage
+paths, unknown stage references, cycles, and missing or ambiguous move targets
+produce named errors. Every target is validated, including stages beyond the
+selected view.
+
+Stages contain ordered moves. `offX`, `offY`, and `offZ` describe one simultaneous
+straight translation in the owning assembly's resolved frame. Multiple moves
+execute sequentially. The optional progress value advances through the selected
+stage's moves in equal portions, leaving previous stages complete; zero shows
+the selected stage's initial pose. Model-less carrier geometry moves with its
+descendants, and child stages operate at the displaced pose. Reference-surface
+centers move with the solids; their normal and tangent directions stay unchanged
+under these translation-only operations.
+
+## PoppyGL preview
+
+Reproduce standalone PNGs, GLBs, and resolved view JSON with:
+
+```sh
+bun scripts/render-assembly-explosion.tsx work/assembly-explosion-preview
+```
+
+The visual regression fixture renders four states of a constrained lamp:
+assembled, lifted cover, cover parked beside the lamp, and cap separately lifted
+from the displaced cover. A second fixture checks local movement against emitted
+reference-frame directions for upright, inverted, and tilted mounting faces.
+
+`lines` contains world-space movement segments, with origins in right-handed
+circuit XYZ (mm, +Z above). Convert points through the same frame boundary as the
+GLTF exporter before passing them to PoppyGL's `explosionLines`. The current
+exporter uses `[-x, z, y]` in its final glTF scene, as established by
+`GltfBuilder.toGltfTranslation` after CAD center conversion. The regression checks
+guide endpoints against actual emitted glTF node translations.
+
+PoppyGL guides are depth-tested and do not write depth. The core fixture uses
+them for lift-then-slide paths and the final cap movement. Guide origins on
+model-less subassemblies use the centroid of emitted descendant CAD origins.
+
+## Remaining work before assembly verification
+
+This prototype renders mechanical CAD translations. It rejects movement of
+targets containing PCB or cable geometry rather than producing a partially
+displaced assembly. Moving folded/flat boards, flexible cables, physical fastening
+constraints, and rotations require further work.
+
+The returned view explicitly reports `collisionCheckStatus: "not_implemented"`.
+There is no swept-solid collision engine or successful assembly-verification
+claim in this draft. A follow-up needs full-path collision checks, intentional
+mating contact rules, missing-geometry/incomplete results, and reproducible
+diagnostics tied to stage and move positions. Guides show declared motion paths;
+they do not certify clearance.
diff --git a/lib/assembly-explosion/get-assembly-explosion.ts b/lib/assembly-explosion/get-assembly-explosion.ts
new file mode 100644
index 0000000000..922f1c6cd4
--- /dev/null
+++ b/lib/assembly-explosion/get-assembly-explosion.ts
@@ -0,0 +1,312 @@
+import type { AnyCircuitElement, CadComponent } from "circuit-json"
+import { mat3, mat4, vec3 } from "gl-matrix"
+import type { RootCircuit } from "lib/RootCircuit"
+import type { PrimitiveComponent } from "lib/components/base-components/PrimitiveComponent"
+import { AssemblyExplosionStage } from "lib/components/primitive-components/AssemblyExplosionStage"
+import type { AssemblyExplosionMove } from "lib/components/primitive-components/AssemblyExplosionMove"
+import {
+ isPositionedAssembly,
+ resolveAssemblyPlacement,
+} from "lib/components/primitive-components/resolve-assembly-placement"
+
+export type AssemblyExplosionStageRef = string
+type SourceComponentId = CadComponent["source_component_id"]
+type AssemblyWorldPoint = CadComponent["position"]
+
+/** Right-handed circuit-world XYZ, +X right, +Y top, +Z above, millimeters.
+ * Vertices are points. Rendering adapters must explicitly convert scene frames.
+ */
+export interface AssemblyExplosionLine {
+ stageRef: AssemblyExplosionStageRef
+ targetRef: string
+ points: AssemblyWorldPoint[]
+}
+
+export interface AssemblyExplosionOptions {
+ /** Inclusive qualified stage name, e.g. LAMP.remove-shade. Omit for all stages. */
+ throughStageRef?: AssemblyExplosionStageRef
+ /** Fraction of the selected stage's ordered moves, 0..1; defaults to 1. */
+ stageProgress?: number
+}
+
+export interface AssemblyExplosionView {
+ circuitJson: AnyCircuitElement[]
+ stages: AssemblyExplosionStageRef[]
+ lines: AssemblyExplosionLine[]
+ /** This speculative view does not establish swept-path collision clearance. */
+ collisionCheckStatus: "not_implemented"
+}
+
+const assemblyPath = (
+ assembly: PrimitiveComponent,
+): AssemblyExplosionStageRef => {
+ const names: string[] = []
+ let ancestor: PrimitiveComponent | null = assembly
+ while (ancestor) {
+ if (
+ [
+ "AssemblyDevice",
+ "AssemblySubassembly",
+ "AssemblyPart",
+ "AssemblyPrintedPart",
+ "AssemblyScreen",
+ "AssemblyMotor",
+ ].includes(ancestor.componentName)
+ ) {
+ names.unshift(ancestor.name || "unnamed assembly")
+ }
+ ancestor = ancestor.parent
+ }
+ return names.join(".")
+}
+
+/** Schedule precedence edges; depth-first authored order only breaks ties.
+ * References are scoped to the stage owner or explicitly qualified from root.
+ */
+export const resolveAssemblyExplosionStages = (
+ roots: readonly PrimitiveComponent[],
+): AssemblyExplosionStage[] => {
+ const stages = roots
+ .flatMap((root) => [root, ...root.getDescendants()])
+ .filter(
+ (part): part is AssemblyExplosionStage =>
+ part instanceof AssemblyExplosionStage,
+ )
+ const stageRef = (stage: AssemblyExplosionStage) =>
+ `${assemblyPath(stage.parent!)}.${stage.name}`
+ const byRef = new Map()
+ for (const stage of stages) {
+ const ref = stageRef(stage)
+ if (byRef.has(ref)) throw new Error(`Duplicate explosion stage "${ref}"`)
+ byRef.set(ref, stage)
+ }
+ const edges = new Map>()
+ const indegrees = new Map(stages.map((stage) => [stage, 0]))
+ for (const stage of stages) {
+ for (const direction of ["prevStageRef", "nextStageRef"] as const) {
+ const ref = stage._parsedProps[direction]
+ if (!ref) continue
+ const ownerPath = assemblyPath(stage.parent!)
+ const resolved =
+ byRef.get(`${ownerPath}.${ref}`) ??
+ (ref.startsWith(`${ownerPath.split(".")[0]}.`)
+ ? byRef.get(ref)
+ : undefined)
+ if (!resolved)
+ throw new Error(
+ `Explosion stage "${stageRef(stage)}" references unknown stage "${ref}"`,
+ )
+ const before = direction === "nextStageRef" ? stage : resolved
+ const after = direction === "nextStageRef" ? resolved : stage
+ const successors = edges.get(before) ?? new Set()
+ if (!successors.has(after)) {
+ successors.add(after)
+ edges.set(before, successors)
+ indegrees.set(after, indegrees.get(after)! + 1)
+ }
+ }
+ }
+ const remaining = new Set(stages)
+ const ordered: AssemblyExplosionStage[] = []
+ while (remaining.size) {
+ const eligible = stages.find(
+ (stage) => remaining.has(stage) && indegrees.get(stage) === 0,
+ )
+ if (!eligible)
+ throw new Error(
+ `Explosion stage ordering cycle: ${[...remaining].map(stageRef).join(", ")}`,
+ )
+ remaining.delete(eligible)
+ ordered.push(eligible)
+ for (const successor of edges.get(eligible) ?? [])
+ indegrees.set(successor, indegrees.get(successor)! - 1)
+ }
+ return ordered
+}
+
+const pointPlus = (
+ point: AssemblyWorldPoint,
+ offset: AssemblyWorldPoint,
+): AssemblyWorldPoint => ({
+ x: point.x + offset.x,
+ y: point.y + offset.y,
+ z: point.z + offset.z,
+})
+const zero = (): AssemblyWorldPoint => ({ x: 0, y: 0, z: 0 })
+
+/** Derive a display view AFTER renderUntilSettled, from finalized mounting
+ * constraints. CAD and reference-frame origins are circuit-world points (mm,
+ * right-handed +Z up). Move offsets are owner-local directions, rotated without
+ * translation. Never change authored props, PCB geometry, or assembled CAD.
+ */
+export function getAssemblyExplosion(
+ circuit: RootCircuit,
+ options: AssemblyExplosionOptions = {},
+): AssemblyExplosionView {
+ if (!circuit.firstChild?.renderPhaseStates.CadModelRender.initialized) {
+ throw new Error("Render the assembly before evaluating explosion stages")
+ }
+ const progress = options.stageProgress ?? 1
+ if (!Number.isFinite(progress) || progress < 0 || progress > 1)
+ throw new Error("Explosion stage progress must be between 0 and 1")
+ const ordered = resolveAssemblyExplosionStages(circuit.children)
+ const stages = ordered.map(
+ (stage) => `${assemblyPath(stage.parent!)}.${stage.name}`,
+ )
+ const stop =
+ options.throughStageRef === undefined
+ ? ordered.length - 1
+ : stages.indexOf(options.throughStageRef)
+ if (options.throughStageRef !== undefined && stop < 0)
+ throw new Error(`Unknown explosion stage "${options.throughStageRef}"`)
+ const assembled = circuit.getCircuitJson()
+ const sourceOffsets = new Map()
+ const partOffsets = new Map()
+ const lines: AssemblyExplosionLine[] = []
+ for (const [stageIndex, stage] of ordered.entries()) {
+ const owner = stage.parent!
+ for (const [moveIndex, instruction] of stage.children.entries()) {
+ const move = instruction as AssemblyExplosionMove
+ const segments = move._parsedProps.targetRef.split(".")
+ let target = owner
+ for (const segment of segments) {
+ const matches = target.children.filter(
+ (part) =>
+ part.name === segment &&
+ [
+ "AssemblyPart",
+ "AssemblyPrintedPart",
+ "AssemblySubassembly",
+ "AssemblyScreen",
+ "AssemblyMotor",
+ ].includes(part.componentName),
+ )
+ if (matches.length !== 1)
+ throw new Error(
+ `Explosion stage "${stages[stageIndex]}" target "${move._parsedProps.targetRef}" matched ${matches.length} assembly parts; expected one`,
+ )
+ target = matches[0]
+ }
+ // Validate every declared target, including stages later than the selected view.
+ if (
+ [target, ...target.getDescendants()].some(
+ (part) =>
+ part.pcb_component_id ||
+ part.componentName === "Board" ||
+ part.componentName === "AssemblyCable",
+ )
+ ) {
+ throw new Error(
+ `Explosion target "${move._parsedProps.targetRef}" includes PCB or cable geometry; this prototype moves mechanical CAD parts only`,
+ )
+ }
+ if (stageIndex > stop) continue
+ const fraction =
+ stageIndex === stop
+ ? Math.max(
+ 0,
+ Math.min(1, progress * stage.children.length - moveIndex),
+ )
+ : 1
+ if (!fraction) continue
+ const placement = isPositionedAssembly(owner)
+ ? resolveAssemblyPlacement(owner)
+ : undefined
+ const frame = placement?.worldTransform
+ ? mat4.clone(placement.worldTransform)
+ : mat4.create()
+ if (placement && !placement.worldTransform) {
+ mat4.rotateZ(frame, frame, (placement.pcbRotation * Math.PI) / 180)
+ if (placement.layer === "bottom") mat4.rotateY(frame, frame, Math.PI)
+ }
+ const displacement = vec3.transformMat3(
+ vec3.create(),
+ [
+ (move._parsedProps.offX ?? 0) * fraction,
+ (move._parsedProps.offY ?? 0) * fraction,
+ (move._parsedProps.offZ ?? 0) * fraction,
+ ],
+ mat3.fromMat4(mat3.create(), frame),
+ )
+ const offset = {
+ x: displacement[0],
+ y: displacement[1],
+ z: displacement[2],
+ }
+ const targetCad = assembled.find(
+ (cad): cad is CadComponent =>
+ cad.type === "cad_component" &&
+ cad.source_component_id === target.source_component_id,
+ )
+ // Model-less containers use the centroid of their emitted CAD origins for
+ // guide placement. Motion frames still come from the constraint resolver.
+ const targetSources = new Set(
+ [target, ...target.getDescendants()].map(
+ (part) => part.source_component_id,
+ ),
+ )
+ const descendantCad = assembled.filter(
+ (cad): cad is CadComponent =>
+ cad.type === "cad_component" &&
+ targetSources.has(cad.source_component_id),
+ )
+ const centroid = descendantCad.length
+ ? {
+ x:
+ descendantCad.reduce((sum, cad) => sum + cad.position.x, 0) /
+ descendantCad.length,
+ y:
+ descendantCad.reduce((sum, cad) => sum + cad.position.y, 0) /
+ descendantCad.length,
+ z:
+ descendantCad.reduce((sum, cad) => sum + cad.position.z, 0) /
+ descendantCad.length,
+ }
+ : undefined
+ const anchor =
+ targetCad?.position ??
+ centroid ??
+ (isPositionedAssembly(target)
+ ? resolveAssemblyPlacement(target).position
+ : undefined)
+ if (!anchor)
+ throw new Error(
+ `Explosion target "${move._parsedProps.targetRef}" has no resolved assembly pose`,
+ )
+ const from = pointPlus(anchor, partOffsets.get(target) ?? zero())
+ if (Math.hypot(offset.x, offset.y, offset.z) > 0)
+ lines.push({
+ stageRef: stages[stageIndex],
+ targetRef: move._parsedProps.targetRef,
+ points: [from, pointPlus(from, offset)],
+ })
+ for (const part of [target, ...target.getDescendants()]) {
+ partOffsets.set(
+ part,
+ pointPlus(partOffsets.get(part) ?? zero(), offset),
+ )
+ if (part.source_component_id)
+ sourceOffsets.set(
+ part.source_component_id,
+ pointPlus(
+ sourceOffsets.get(part.source_component_id) ?? zero(),
+ offset,
+ ),
+ )
+ }
+ }
+ }
+ const circuitJson = structuredClone(assembled).map((record) => {
+ if (
+ record.type !== "cad_component" &&
+ record.type !== "cad_reference_surface"
+ )
+ return record
+ const offset = sourceOffsets.get(record.source_component_id)
+ if (!offset) return record
+ if (record.type === "cad_component")
+ return { ...record, position: pointPlus(record.position, offset) }
+ return { ...record, center: pointPlus(record.center, offset) }
+ })
+ return { circuitJson, stages, lines, collisionCheckStatus: "not_implemented" }
+}
diff --git a/lib/components/index.ts b/lib/components/index.ts
index b3427edf8a..8eed133bb7 100644
--- a/lib/components/index.ts
+++ b/lib/components/index.ts
@@ -73,6 +73,8 @@ export { AssemblyDevice } from "./primitive-components/AssemblyDevice"
export { AssemblyMotor } from "./primitive-components/AssemblyMotor"
export { AssemblyScreen } from "./primitive-components/AssemblyScreen"
export { AssemblySubassembly } from "./primitive-components/AssemblySubassembly"
+export { AssemblyExplosionStage } from "./primitive-components/AssemblyExplosionStage"
+export { AssemblyExplosionMove } from "./primitive-components/AssemblyExplosionMove"
export { AssemblyPart } from "./primitive-components/AssemblyPart"
export { AssemblyReferenceSurface } from "./primitive-components/AssemblyReferenceSurface"
export { EnclosureCutoutAperture } from "./primitive-components/EnclosureCutoutAperture"
diff --git a/lib/components/primitive-components/AssemblyExplosionMove.ts b/lib/components/primitive-components/AssemblyExplosionMove.ts
new file mode 100644
index 0000000000..008b57c766
--- /dev/null
+++ b/lib/components/primitive-components/AssemblyExplosionMove.ts
@@ -0,0 +1,22 @@
+import { assemblyExplosionMoveProps } from "@tscircuit/props"
+import { PrimitiveComponent } from "../base-components/PrimitiveComponent"
+
+/** Straight translation instruction; displacement is resolved after mounting. */
+export class AssemblyExplosionMove extends PrimitiveComponent<
+ typeof assemblyExplosionMoveProps
+> {
+ get config() {
+ return {
+ componentName: "AssemblyExplosionMove",
+ zodProps: assemblyExplosionMoveProps,
+ }
+ }
+
+ override doInitialAssignNameToUnnamedComponents(): void {}
+
+ doInitialSourceRender(): void {
+ if (this.parent?.componentName !== "AssemblyExplosionStage") {
+ throw new Error("An explosion move must belong to an explosion stage")
+ }
+ }
+}
diff --git a/lib/components/primitive-components/AssemblyExplosionStage.ts b/lib/components/primitive-components/AssemblyExplosionStage.ts
new file mode 100644
index 0000000000..d3f572044d
--- /dev/null
+++ b/lib/components/primitive-components/AssemblyExplosionStage.ts
@@ -0,0 +1,34 @@
+import { assemblyExplosionStageProps } from "@tscircuit/props"
+import { PrimitiveComponent } from "../base-components/PrimitiveComponent"
+
+/** Instruction metadata; adds no electrical or CAD geometry. */
+export class AssemblyExplosionStage extends PrimitiveComponent<
+ typeof assemblyExplosionStageProps
+> {
+ get config() {
+ return {
+ componentName: "AssemblyExplosionStage",
+ zodProps: assemblyExplosionStageProps,
+ }
+ }
+
+ doInitialSourceRender(): void {
+ if (
+ this.parent?.componentName !== "AssemblyDevice" &&
+ this.parent?.componentName !== "AssemblySubassembly"
+ ) {
+ throw new Error(
+ `Explosion stage "${this.name}" must belong to an assembly device or subassembly`,
+ )
+ }
+ if (
+ this.children.some(
+ (child) => child.componentName !== "AssemblyExplosionMove",
+ )
+ ) {
+ throw new Error(
+ `Explosion stage "${this.name}" can contain only explosion moves`,
+ )
+ }
+ }
+}
diff --git a/lib/index.ts b/lib/index.ts
index 2bd32a57a8..4bb20958bd 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -39,3 +39,4 @@ import "./fiber/react-jsx-compat"
export { fanoutTracePath, type FanoutTracePath } from "@tscircuit/props"
export { jscad } from "jscad-fiber/headless"
+export * from "./assembly-explosion/get-assembly-explosion"
diff --git a/lib/namespaced-elements/assembly.ts b/lib/namespaced-elements/assembly.ts
index 0efdb287f2..8ff2c58a63 100644
--- a/lib/namespaced-elements/assembly.ts
+++ b/lib/namespaced-elements/assembly.ts
@@ -1,4 +1,6 @@
import type {
+ AssemblyExplosionStagePropsInput,
+ AssemblyExplosionMovePropsInput,
AssemblyCablePropsInput,
AssemblyDevicePropsInput,
AssemblyMotorPropsInput,
@@ -33,6 +35,14 @@ export type AssemblyCadAssemblyJsxProps = AssemblySubassemblyJsxProps
export interface AssemblyCableJsxProps extends AssemblyCablePropsInput {}
export const assembly = {
+ explosion: {
+ stage: createNamespacedElement(
+ "assembly.explosion.stage",
+ ),
+ move: createNamespacedElement(
+ "assembly.explosion.move",
+ ),
+ },
referencesurface: createNamespacedElement(
"assembly.referencesurface",
),
diff --git a/lib/register-catalogue.ts b/lib/register-catalogue.ts
index ad6ac37f9e..dd947167af 100644
--- a/lib/register-catalogue.ts
+++ b/lib/register-catalogue.ts
@@ -10,6 +10,8 @@ extendCatalogue(Components)
extendCatalogue({
"assembly.cable": Components.AssemblyCable,
"assembly.device": Components.AssemblyDevice,
+ "assembly.explosion.stage": Components.AssemblyExplosionStage,
+ "assembly.explosion.move": Components.AssemblyExplosionMove,
"assembly.motor": Components.AssemblyMotor,
"assembly.part": Components.AssemblyPart,
"assembly.referencesurface": Components.AssemblyReferenceSurface,
diff --git a/package.json b/package.json
index 04856fcf95..6bd35d19a9 100644
--- a/package.json
+++ b/package.json
@@ -60,7 +60,7 @@
"@tscircuit/miniflex": "^0.0.4",
"@tscircuit/modelprinter": "^0.0.18",
"@tscircuit/ngspice-spice-engine": "^0.0.20",
- "@tscircuit/props": "^0.0.701",
+ "@tscircuit/props": "https://pkg.pr.new/@tscircuit/props@a773b18",
"@tscircuit/schematic-match-adapt": "^0.0.18",
"@tscircuit/schematic-trace-solver": "https://jscdn.tscircuit.com/@tscircuit/schematic-trace-solver/0.0.230.tgz",
"@tscircuit/solver-utils": "^0.0.16",
@@ -93,7 +93,7 @@
"live-server": "^1.2.2",
"minicssgrid": "^0.0.9",
"pkg-pr-new": "^0.0.37",
- "poppygl": "0.0.34",
+ "poppygl": "https://pkg.pr.new/poppygl@8a05972",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"schematic-symbols": "^0.0.248",
@@ -144,7 +144,7 @@
},
"overrides": {
"@tscircuit/circuit-json-util": "^0.0.122",
- "@tscircuit/props": "^0.0.701",
+ "@tscircuit/props": "https://pkg.pr.new/@tscircuit/props@a773b18",
"circuit-json": "^0.0.528",
"@tscircuit/cableprinter": "^0.0.6"
}
diff --git a/scripts/render-assembly-explosion.tsx b/scripts/render-assembly-explosion.tsx
new file mode 100644
index 0000000000..e05d96a6ca
--- /dev/null
+++ b/scripts/render-assembly-explosion.tsx
@@ -0,0 +1,51 @@
+import { mkdirSync, writeFileSync } from "node:fs"
+import { join } from "node:path"
+import { Circuit, getAssemblyExplosion } from "lib"
+import { convertCircuitJsonToGltf } from "circuit-json-to-gltf"
+import { renderGLTFToPNGFromGLB } from "poppygl"
+import { ExplosionLamp } from "tests/assembly/fixtures/explosion-lamp"
+import { poppyglExplosionLines } from "tests/assembly/fixtures/poppygl-explosion-lines"
+
+// Run: bun scripts/render-assembly-explosion.tsx [output-directory]
+const outputDirectory = process.argv[2] ?? "work/assembly-explosion-preview"
+mkdirSync(outputDirectory, { recursive: true })
+const circuit = new Circuit()
+circuit.add()
+await circuit.renderUntilSettled()
+const views = [
+ {
+ name: "assembled",
+ options: { throughStageRef: "LAMP.lift-cover", stageProgress: 0 },
+ },
+ { name: "lifted", options: { throughStageRef: "LAMP.lift-cover" } },
+ { name: "parked", options: { throughStageRef: "LAMP.park-cover" } },
+ { name: "exploded", options: {} },
+]
+for (const { name, options } of views) {
+ const view = getAssemblyExplosion(circuit, options)
+ const glb = (await convertCircuitJsonToGltf(view.circuitJson, {
+ includeModels: true,
+ format: "glb",
+ showBoundingBoxes: false,
+ })) as ArrayBuffer
+ const png = await renderGLTFToPNGFromGLB(new Uint8Array(glb), {
+ width: 1200,
+ height: 900,
+ supersampling: 2,
+ fov: 40,
+ camPos: [240, 180, 250],
+ lookAt: [-40, 100, 0],
+ backgroundColor: [1, 1, 1],
+ grid: false,
+ explosionLines: poppyglExplosionLines(view),
+ })
+ writeFileSync(join(outputDirectory, `${name}.glb`), new Uint8Array(glb))
+ writeFileSync(join(outputDirectory, `${name}.png`), png)
+ writeFileSync(
+ join(outputDirectory, `${name}.json`),
+ JSON.stringify(view, null, 2),
+ )
+}
+console.log(
+ `Rendered ${views.length} constrained explosion states into ${outputDirectory}`,
+)
diff --git a/tests/assembly/__snapshots__/assembly-explosion-frame-annotated.snap.png b/tests/assembly/__snapshots__/assembly-explosion-frame-annotated.snap.png
new file mode 100644
index 0000000000..3c88e43246
Binary files /dev/null and b/tests/assembly/__snapshots__/assembly-explosion-frame-annotated.snap.png differ
diff --git a/tests/assembly/__snapshots__/assembly-explosion-render-annotated.snap.png b/tests/assembly/__snapshots__/assembly-explosion-render-annotated.snap.png
new file mode 100644
index 0000000000..1b6a2ec8f8
Binary files /dev/null and b/tests/assembly/__snapshots__/assembly-explosion-render-annotated.snap.png differ
diff --git a/tests/assembly/assembly-explosion-frame.test.tsx b/tests/assembly/assembly-explosion-frame.test.tsx
new file mode 100644
index 0000000000..15a2e8ffa1
--- /dev/null
+++ b/tests/assembly/assembly-explosion-frame.test.tsx
@@ -0,0 +1,105 @@
+import { expect, test } from "bun:test"
+import { assembly, getAssemblyExplosion, jscad } from "lib"
+import type { CadComponent, CadReferenceSurface } from "circuit-json"
+import { vec3 } from "gl-matrix"
+import { getTestFixture } from "tests/fixtures/get-test-fixture"
+import { expectAssemblySnapshot } from "./fixtures/expect-assembly-snapshot"
+import { poppyglExplosionLines } from "./fixtures/poppygl-explosion-lines"
+
+test("explosion directions follow emitted mounting frames including tilted and inverted faces", async () => {
+ const panels = []
+ for (const normalDirection of ["z+", "z-", "y+", "y-"] as const) {
+ const { circuit } = getTestFixture()
+ circuit.add(
+
+
+
+
+ }
+ color="#d9a15f"
+ >
+
+
+
+
+
+
+
+
+
+ ,
+ )
+ await circuit.renderUntilSettled()
+ const assembled = circuit.getCircuitJson()
+ const exploded = getAssemblyExplosion(circuit)
+ const carrier = circuit.db.source_component
+ .list()
+ .find((part) => part.name === "CARRIER")!
+ const marker = circuit.db.source_component
+ .list()
+ .find((part) => part.name === "MARKER")!
+ const frame = assembled.find(
+ (record): record is CadReferenceSurface =>
+ record.type === "cad_reference_surface" &&
+ record.source_component_id === carrier.source_component_id &&
+ record.name === "axes",
+ )!
+ const findMarker = (json: typeof assembled) =>
+ json.find(
+ (record): record is CadComponent =>
+ record.type === "cad_component" &&
+ record.source_component_id === marker.source_component_id,
+ )!
+ const from = findMarker(assembled).position
+ const to = findMarker(exploded.circuitJson).position
+ const x = [frame.x_axis.x, frame.x_axis.y, frame.x_axis.z] as const
+ const z = [frame.normal.x, frame.normal.y, frame.normal.z] as const
+ const y = vec3.cross(vec3.create(), z, x)
+ for (const [axis, coordinate] of ["x", "y", "z"].entries()) {
+ expect(
+ to[coordinate as "x" | "y" | "z"] - from[coordinate as "x" | "y" | "z"],
+ ).toBeCloseTo(5 * x[axis] + 7 * y[axis] + 11 * z[axis], 4)
+ }
+ panels.push({
+ title: `Mounted face normal: ${normalDirection}`,
+ circuit: exploded.circuitJson,
+ code: `\n \n \n \n \n \n`,
+ annotation:
+ "Offsets follow the emitted mounting-frame axes; all axes move together.",
+ renderOptions: {
+ camPos: [45, 35, 45] as [number, number, number],
+ poppygl: {
+ lookAt: [0, 5, 0] as [number, number, number],
+ explosionLines: poppyglExplosionLines(exploded),
+ },
+ },
+ })
+ }
+ await expectAssemblySnapshot(import.meta.path, {
+ title:
+ "Explosion moves use resolved constraint frames, not world-axis guesses",
+ panels,
+ columns: 2,
+ })
+})
diff --git a/tests/assembly/assembly-explosion-ordering-errors.test.tsx b/tests/assembly/assembly-explosion-ordering-errors.test.tsx
new file mode 100644
index 0000000000..bd8288a86b
--- /dev/null
+++ b/tests/assembly/assembly-explosion-ordering-errors.test.tsx
@@ -0,0 +1,88 @@
+import { expect, test } from "bun:test"
+import { assembly, getAssemblyExplosion } from "lib"
+import { getTestFixture } from "tests/fixtures/get-test-fixture"
+
+test("invalid ordering and targets fail with named diagnostics even outside the selected view", async () => {
+ const scenarios = [
+ {
+ stages: (
+ <>
+
+ >
+ ),
+ error: 'references unknown stage "missing"',
+ },
+ {
+ stages: (
+ <>
+
+
+ >
+ ),
+ error: "Explosion stage ordering cycle",
+ },
+ {
+ stages: (
+ <>
+
+
+ >
+ ),
+ error: 'Duplicate explosion stage "DEVICE.first"',
+ },
+ {
+ stages: (
+ <>
+
+ >
+ ),
+ error: "Explosion stage ordering cycle",
+ },
+ {
+ stages: (
+ <>
+
+
+
+
+ >
+ ),
+ error: 'target "missing" matched 0 assembly parts',
+ },
+ ]
+ for (const scenario of scenarios) {
+ const { circuit } = getTestFixture()
+ circuit.add(
+ {scenario.stages},
+ )
+ await circuit.renderUntilSettled()
+ expect(() =>
+ getAssemblyExplosion(circuit, {
+ throughStageRef: "DEVICE.first",
+ stageProgress: 0,
+ }),
+ ).toThrow(scenario.error)
+ }
+ const { circuit } = getTestFixture()
+ circuit.add(
+
+
+
+
+ ,
+ )
+ await circuit.renderUntilSettled()
+ // Repeated descriptions of the same edge do not double-count indegrees.
+ expect(getAssemblyExplosion(circuit).stages).toEqual([
+ "DEVICE.first",
+ "DEVICE.last",
+ "DEVICE.middle",
+ ])
+ expect(() =>
+ getAssemblyExplosion(circuit, { throughStageRef: "DEVICE.missing" }),
+ ).toThrow('Unknown explosion stage "DEVICE.missing"')
+ for (const stageProgress of [-1, 2, Number.NaN])
+ expect(() => getAssemblyExplosion(circuit, { stageProgress })).toThrow(
+ "progress must be between 0 and 1",
+ )
+})
diff --git a/tests/assembly/assembly-explosion-render.test.tsx b/tests/assembly/assembly-explosion-render.test.tsx
new file mode 100644
index 0000000000..6d2484e6d4
--- /dev/null
+++ b/tests/assembly/assembly-explosion-render.test.tsx
@@ -0,0 +1,142 @@
+import { expect, test } from "bun:test"
+import { getAssemblyExplosion } from "lib"
+import type { CadComponent } from "circuit-json"
+import { convertCircuitJsonToGltf } from "circuit-json-to-gltf"
+import { getTestFixture } from "tests/fixtures/get-test-fixture"
+import { ExplosionLamp } from "./fixtures/explosion-lamp"
+import { expectAssemblySnapshot } from "./fixtures/expect-assembly-snapshot"
+import { poppyglExplosionLines } from "./fixtures/poppygl-explosion-lines"
+
+test("constraint-mounted cover moves intact before its nested cap explodes", async () => {
+ const { circuit } = getTestFixture()
+ circuit.add()
+ await circuit.renderUntilSettled()
+ const assembled = structuredClone(circuit.getCircuitJson())
+ const initial = getAssemblyExplosion(circuit, {
+ throughStageRef: "LAMP.lift-cover",
+ stageProgress: 0,
+ })
+ const lifted = getAssemblyExplosion(circuit, {
+ throughStageRef: "LAMP.lift-cover",
+ })
+ const parked = getAssemblyExplosion(circuit, {
+ throughStageRef: "LAMP.park-cover",
+ })
+ const exploded = getAssemblyExplosion(circuit)
+ expect(exploded.stages).toEqual([
+ "LAMP.lift-cover",
+ "LAMP.park-cover",
+ "LAMP.STEM.COVER.remove-cap",
+ ])
+ const cadFor = (json: typeof assembled, name: string) => {
+ const source = circuit.db.source_component
+ .list()
+ .find((part) => part.name === name)!
+ return json.find(
+ (cad): cad is CadComponent =>
+ cad.type === "cad_component" &&
+ cad.source_component_id === source.source_component_id,
+ )!
+ }
+ expect(cadFor(assembled, "SHADE").position.z).toBeCloseTo(92)
+ expect(cadFor(assembled, "CAP").position.z).toBeCloseTo(112)
+ for (const name of ["SHADE", "CAP"]) {
+ expect(
+ cadFor(lifted.circuitJson, name).position.z -
+ cadFor(assembled, name).position.z,
+ ).toBeCloseTo(50)
+ expect(
+ cadFor(parked.circuitJson, name).position.x -
+ cadFor(assembled, name).position.x,
+ ).toBeCloseTo(85)
+ }
+ expect(cadFor(exploded.circuitJson, "CAP").position.z).toBeCloseTo(186)
+ expect(cadFor(exploded.circuitJson, "SHADE").position.z).toBeCloseTo(142)
+ expect(cadFor(exploded.circuitJson, "BULB")).toEqual(
+ cadFor(assembled, "BULB"),
+ )
+ expect(initial.circuitJson).toEqual(assembled)
+ expect(circuit.getCircuitJson()).toEqual(assembled)
+ expect(getAssemblyExplosion(circuit)).toEqual(exploded)
+ expect(exploded.collisionCheckStatus).toBe("not_implemented")
+ const capSurface = exploded.circuitJson.find(
+ (record): record is import("circuit-json").CadReferenceSurface =>
+ record.type === "cad_reference_surface" &&
+ record.source_component_id ===
+ cadFor(assembled, "CAP").source_component_id,
+ )!
+ expect(capSurface.center.x).toBeCloseTo(85)
+ expect(capSurface.center.y).toBeCloseTo(0)
+ expect(capSurface.center.z).toBeCloseTo(186)
+ expect(capSurface.normal.z).toBeCloseTo(-1)
+ // Measure the exporter boundary from actual emitted glTF, not a restatement
+ // of core's frame math. The cap's guide endpoint must meet its rendered origin.
+ const gltf = (await convertCircuitJsonToGltf(exploded.circuitJson, {
+ format: "gltf",
+ includeModels: true,
+ })) as { nodes: { name?: string; translation?: number[] }[] }
+ const capNode = gltf.nodes.find((node) => node.name === "CAP")!
+ const capGuideEnd = poppyglExplosionLines(exploded).at(-1)!.points.at(-1)!
+ for (const axis of [0, 1, 2])
+ expect(capGuideEnd[axis]).toBeCloseTo(capNode.translation![axis])
+ await expectAssemblySnapshot(import.meta.path, {
+ title:
+ "Speculative assembly explosion: constrained mounts, then staged motion",
+ columns: 2,
+ panels: [
+ {
+ title: "1. Assembled: reference faces constrain placement",
+ circuit: initial.circuitJson,
+ code: '}>\n \n\n',
+ annotation:
+ "Mounts resolve SHADE at Z=92 mm and CAP at Z=112 mm; no explosion offsets yet.",
+ renderOptions: {
+ camPos: [150, 115, 175],
+ poppygl: { lookAt: [0, 65, 0] },
+ },
+ },
+ {
+ title: "2. Lift the intact cover subassembly",
+ circuit: lifted.circuitJson,
+ code: '\n \n',
+ annotation:
+ "SHADE and CAP move together; BASE, STEM and BULB stay assembled.",
+ renderOptions: {
+ camPos: [180, 140, 200],
+ poppygl: {
+ lookAt: [0, 90, 0],
+ explosionLines: poppyglExplosionLines(lifted),
+ },
+ },
+ },
+ {
+ title: "3. Park the intact cover beside the lamp",
+ circuit: parked.circuitJson,
+ code: '\n \n',
+ annotation:
+ "The declared lift-then-slide path forms an elbow; removed parts remain visible.",
+ renderOptions: {
+ camPos: [240, 170, 250],
+ poppygl: {
+ lookAt: [-40, 90, 0],
+ explosionLines: poppyglExplosionLines(parked),
+ },
+ },
+ },
+ {
+ title: "4. Explode the cap in the displaced cover frame",
+ circuit: exploded.circuitJson,
+ code: '\n ...\n \n \n \n',
+ annotation:
+ "CAP lifts separately to Z=186 mm. Rendering does not certify collision clearance.",
+ renderOptions: {
+ camPos: [240, 180, 250],
+ poppygl: {
+ lookAt: [-40, 100, 0],
+ explosionLines: poppyglExplosionLines(exploded),
+ },
+ },
+ },
+ ],
+ })
+})
diff --git a/tests/assembly/fixtures/explosion-lamp.tsx b/tests/assembly/fixtures/explosion-lamp.tsx
new file mode 100644
index 0000000000..408366088c
--- /dev/null
+++ b/tests/assembly/fixtures/explosion-lamp.tsx
@@ -0,0 +1,73 @@
+import { assembly, jscad } from "lib"
+import { lampBase, LampStem, LampShade } from "./reference-surface-lamp"
+
+/** Local right-handed XYZ, mm, +Z above. Mounts constrain the shade to Z=92
+ * and cap to Z=112. Explosion declarations do not alter those assembled mounts.
+ */
+export function ExplosionLamp() {
+ return (
+
+
+
+
+ }
+ mountedTo="BASE.stem"
+ mountFace="base"
+ >
+
+
+
+
+ }
+ mountedTo="STEM.shade"
+ mountFace="stem"
+ >
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/tests/assembly/fixtures/poppygl-explosion-lines.ts b/tests/assembly/fixtures/poppygl-explosion-lines.ts
new file mode 100644
index 0000000000..349a2f28ff
--- /dev/null
+++ b/tests/assembly/fixtures/poppygl-explosion-lines.ts
@@ -0,0 +1,15 @@
+import type { AssemblyExplosionView } from "lib"
+import type { ExplosionLine } from "poppygl"
+
+/** Boundary paired with circuit-json-to-gltf's CAD center mapping followed by
+ * GltfBuilder.toGltfTranslation: [-center.x, center.y, center.z]. Together these
+ * map circuit-world [x,y,z] to scene [-x,z,y], mm, right-handed +Y up.
+ * Points pick up the complete exporter mapping, not just its intermediate box
+ * center. This matches emitted glTF geometry, without a renderer compensation.
+ */
+export const poppyglExplosionLines = (
+ view: AssemblyExplosionView,
+): ExplosionLine[] =>
+ view.lines.map((line) => ({
+ points: line.points.map((point) => [-point.x, point.z, point.y] as const),
+ }))