Typed, reusable 3D products with connection ports, joined into an editable assembly: a
heating system of boiler, pump and radiators, or a shelf of uprights and boards. One engine,
no domain inside it. Pattern record: product-connection-engine.
Contents
- Why this shape - the layers kept apart
- Vocabulary - the names, and the ones avoided
- Document and frames - schema, port frames, IFC mapping
- Invariants - what holds, and which test holds it
- Operations and diagnostics - pure edits, findings as data
- Rendering and editing - the Threlte layer
- Plausibility, not certification - the safety line
- Honest scope - what it does not do
- Routing: evaluated, not built - when auto-routing earns its place
- Seams outside the core - storage, IFC, telemetry, simulation
- Proof - the tests and pages behind the record
- Emulating it - six steps, core first
Why this shape
A configurator that draws products and lets the user join them tends to grow one tangled component: meshes know about pipes, pipes know about thread sizes, and saving means serialising the scene graph. This pattern keeps seven things apart, so each can change alone:
| Layer | Lives in | Knows nothing about |
|---|---|---|
| Definition — a catalogue entry: geometry, ports, internal passages | the document | where it is placed |
| Occurrence — one placed use of a definition | the document | the other products |
| Port — a typed connection point with a frame | the definition | what it is joined to |
| Coupling — which two ports are joined, mated or by a conduit | the document | the geometry between them |
| Conduit — where the pipe or cable runs (waypoints) | the document | why the ports are compatible |
| Rules — which ports may join | a PortStandardRegistry the caller supplies |
heating, shelving, any domain |
| Rendering — meshes, markers, tubes, gizmo | #lib/components/3d/composition/ |
the rules |
Topology is not geometry: IFC 4.3 makes the same split between IfcRelConnectsPorts and the
element that realises it. The core (#lib/product-composition/) imports no Svelte, $app,
DOM or WebGL object, so an import, an AI tool or a server write can validate a document
exactly as the editor does.
Vocabulary
ProductComposition, ProductDefinition, ProductOccurrence, ProductPort,
PortStandard / PortStandardRegistry, Coupling (mated | conduit), Conduit,
passages, exposedPorts, PortNetwork, CompositionDiagnostic. Each term, and the words
rejected for it, is a row in docs/naming.md.
Document and frames
Schema: src/lib/schemas/product-composition.ts (strict Valibot objects).
ProductComposition { format: 1, id, label?, definitions, occurrences, couplings, conduits }
ProductDefinition { id, label, body, metadata? }
body: { kind: 'geometry', geometry, ports, passages? }
| { kind: 'composition', occurrences, couplings, conduits, exposedPorts }
ProductPort { id, label, standard, direction, gender, frame, capacity?, parameters? }
PortStandard { id, family, label, genders, directions, clocking, matchParameters }
Coupling { id, a, b, mode } // a and b unordered
Conduit { id, coupling, waypoints } // interior points only
- The document is self-contained. It carries every definition it uses. The registry of
port standards is supplied by the caller. "Versioned" means
format; versioning definitions belongs to a storage adapter. - Port frame. +Z points out of the product, +Y is the clocking reference. A mate is a half turn about +Y: the two +Z axes face each other and the +Y axes line up.
- IFC 4.3 mapping. An
IfcDistributionPortAxis points out for a SOURCE port and in for a SINK, and rigidly joined ports carry equal frames. Ours always points out (as SolidWorks Routing and Onshape mate connectors do): IFC Axis = our +Z for a source, −Z for a sink. - Semantics.
directionis the nominal design direction; actual flow is never computed.neutralgender is genderless (flanges, hermaphroditic connectors). Matching is equality onmatchParametersonly — keying such as an IEC 60309 clock position is a match parameter; ratings stay informational. - Determinism. Every number is quantised to 1e-9 before it enters the document, and a
quaternion's sign is fixed after quantising, so a half turn always serialises the same
way.
stringifyCompositionsorts keyed collections and passages: the same edits give the same bytes.
Invariants
| # | Invariant | Held by |
|---|---|---|
| 1 | The core is framework-free and renderer-free: only its schema, Valibot and Three.js math classes | the boundary gate in compose.test.ts |
| 2 | Pure and deterministic: same operations → byte-identical document, ±π included | frame.test.ts, serialize.test.ts |
| 3 | Couplings are well-formed: no self-coupling, no duplicate pair, capacity respected (a mate is exclusive), exposed ports never share an inner port | validate.test.ts, operations.test.ts |
| 4 | Compatibility comes from registry data only; the core names no domain | standards.test.ts; the neutrality gate in compose.test.ts |
| 5 | Mates hold within 1e-6: a connect snaps the moving side's whole cluster, a move re-derives the cluster along a spanning tree (no drift), consistent rings are allowed | operations.test.ts, frame.test.ts |
| 6 | Conduit ends always derive from the live port frames | conduit.test.ts |
| 7 | Removing an occurrence removes its couplings and their conduits | operations.test.ts |
| 8 | Findings are data, never throws; parse is fail-closed | validate.test.ts, serialize.test.ts |
Nested bodies are read-only from the outer level: no operation edits inside a definition. Resolution and networks work on the flattened graph, so passages through a subassembly count.
Operations and diagnostics
Every operation in operations.ts takes a composition and returns
OperationResult { composition, diagnostics }. A refused operation returns its input
unchanged with the reasons. Ids come from the caller or nextId(prefix, existing) — no
Math.random, no Date.
| Operation | Notes |
|---|---|
addOccurrence, removeOccurrence |
add copies a missing definition (and its nested ones) from a catalogue; remove cascades |
moveOccurrence, rotateOccurrence |
the mated cluster follows |
connect, disconnect |
connect runs the same checks validation does, for that one coupling |
setConduitWaypoints, insertWaypoint, removeWaypoint |
refused on a mated coupling |
validateComposition returns findings sorted by (code, subject). Severity is fixed per
code (diagnostics.ts):
| Group | Codes | Severity | Raised by |
|---|---|---|---|
| definition | definition.missing, composition.recursive |
error | validation |
| coupling | coupling.unresolved, coupling.self, coupling.duplicate |
error | validation |
| port | port.incompatible (with its mismatch list), port.capacity |
error | validation |
| port | port.open |
info | validation |
| mate | mate.misaligned |
error | validation |
| conduit | conduit.unresolved, conduit.mode, conduit.duplicate |
error | validation |
| passage | passage.unresolved, passage.self |
error | validation |
| exposed port | exposed-port.unresolved, exposed-port.shared |
error | validation |
| operation | id.duplicate, occurrence.unresolved, waypoint.unresolved |
error | refused operation only |
Rendering and editing
#lib/components/3d/composition/ reads only the core's resolved output:
CompositionCanvas—<svelte:boundary>fallback around an on-demand<Canvas>, exposed asrole="img"with a label that points to the keyboard way.CompositionScene— products, port markers (a larger invisible hit proxy for touch), conduit tubes (aCurvePathof straight runs with a quadratic fillet per corner, the old geometry disposed on rebuild), waypoint handles, and a translate-onlyTransformControls.CompositionEditor(composition-editor.svelte.ts) —$state.rawdocument, selection, an undo stack of snapshots (100, no redo), drag gestures that commit one undo step, dry-run coupling options, JSON import and export.
Two things the installed Threlte does not do for you: @threlte/extras 9.7.1's
TransformControls does not pause CameraControls, so the scene
toggles controls.enabled on drag start and end; and on-demand rendering needs an explicit
invalidate() whenever the document, selection or theme changes.
Colours are tokens: product tones map to theme tokens, port families to --chart-N by
their sorted position, which keeps the renderer as domain-free as the core. Physical tones
(enamel, copper, brass, chrome, steel, glass, rubber) map to the --scene-*
tokens, which keep their colour in every theme.
Surfaces are roles too. A primitive's optional finish (matte, satin, gloss, metal,
brushed) resolves in surface-finish.ts to physically based material parameters and
procedural grey maps (tileable value noise in DataTextures: no download, no DOM, same pixels
every time); a box may round its edges (radius), and torus joins box, cylinder and sphere.
primitive-geometry.ts shares one geometry per distinct shape across occurrences.
Light and setting are the renderer's, never the document's: a generated studio environment
(RoomEnvironment through PMREM), a shadow-casting key light aimed at the composition,
Khronos PBR Neutral tone mapping. A caller may pass a RoomLayout — floor, back wall, corner,
windows, plaster and tile, a night palette in the dark theme — so wall-hung products read as
hung; with walls, the camera stays in front of them. conduitSurfaces draws a port family's
conduits as a physical run (copper pipe, a slim cable) and keeps the family colour on the
fittings at each end.
Everything the canvas does has a keyboard path in the showcase's inspector: a products list, each product's ports, a picker of compatible partners, nudge and turn buttons, a waypoint list. A status region announces each success and refusal.
Plausibility, not certification
Every verdict means "the declared data fits": thread sizes agree, a mate lines up, a port is not over-used. Nothing sizes a pipe, rates a cable, checks a pressure or approves an installation. The showcase keeps a warning banner on screen that says so, and no copy, diagnostic or record may claim engineering or electrical validity. This rule is held by review, not by a test.
Honest scope
- No constraint solver: mates and port rules only. The editor rotates in steps.
- Ratings (
capacityaside) are informational; there are no range checks. - No collision detection between products or conduits.
Routing: evaluated, not built
Conduits are routed by hand: interior waypoints between two port stubs (CONDUIT_STUB,
0.1 m along each port's +Z). Auto-routing was evaluated and left out of the core. The
baseline when it is needed is bend-penalised A* on a voxel grid around the products, run as
a separate operation that writes waypoints. Build it when a document has obstacles that
matter, more than about ten conduits, or a catalogue of fittings to route through.
Seams outside the core
Each is a caller of the core, not a part of it:
- Storage — persisting documents and versioning definitions.
- IFC / BIM — import and export through the frame mapping above.
- Telemetry — live values bound to occurrences or ports by id.
- Simulation and sizing — flows, pressures, loads; engineering tools own these.
- Auto-routing — see above.
Proof
| Surface | What it shows |
|---|---|
src/lib/product-composition/*.test.ts (8 files) |
frames, standards, serialisation, resolution, networks, validation, operations, conduits |
src/lib/showcases/3d/compose/compose.test.ts |
both fixtures valid; the core names no domain and imports no framework; a six-step scenario replayed on the core |
src/lib/components/3d/composition/{conduit-tube,composition-editor.svelte}.test.ts |
tube geometry; undo, gestures, import |
/showcases/3d/compose |
heating (subassembly, conduits, open ports) and shelving (a ring of mates, GLB sofas) on one engine |
tests/browser/compose.spec.ts |
the scenario by keyboard and a gizmo drag, in Chromium (bun run test:browser, outside validate) |
Emulating it
- Copy the schema and
frame.tsfirst; agree the port frame convention before any geometry. - Define your
PortStandards as data — never asif (thread)in the core. - Port
serialize.tsand its canonical sort, then the operations withOperationResult. - Add
validate.tsand the domain-neutrality gate with your own domain's words. - Write the scenario test on the core before any UI.
- Add rendering last, reading only resolved output; give every canvas action a keyboard path.