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), + }))