| name | assembly-solver |
| description | This skill should be used when working on brepjs assemblies, constraint solving, or kinematics — debugging a solve that returns ASSEMBLY_NOT_CONVERGED / ASSEMBLY_MATE_INVALID / ASSEMBLY_SOLVE_FAILED, adding or extending a mate or solver entity-pair, adding a joint type, or driving a mechanism. Trigger phrases include "assembly won't converge", "Unsupported constraint types", "solveAssembly returns Err", "add a mate/joint/constraint type", "extend TRANSLATIONAL_PAIRS", "solveMate: unsupported entity pair escaped filter", "revolute/prismatic/cylindrical/planar/spherical joint", "forwardKinematics", "inverse kinematics / IK target", "DH table / Denavit-Hartenberg", "export/import URDF", or "positioned STEP export from an assembly". Not for boolean fuse/cut compounds (that is debugging-geometry). |
Assemblies, constraint solving, and kinematic joints
Covers two Layer-2 subsystems: the immutable assembly tree with its analytical constraint solver, and drivable kinematic joints (forward/inverse kinematics, DH, URDF). All of it is pure data + math with no kernel calls, except the STEP export at the very end.
Two unrelated meanings of "assembly" — do not conflate
This is the number-one source of confusion. There are two "assembly" APIs that share a word and nothing else.
- Assembly tree + mates + joints — an immutable
AssemblyNode tree (src/operations/assemblyFns.ts), constraint mates (src/operations/mateFns.ts), and kinematic joints (src/operations/jointFns.ts). Pure data + math. This is what the constraint solver and all the kinematics operate on.
- Assembly STEP export —
exportAssemblySTEP / createAssembly / exportSTEP (src/operations/exporterFns.ts, src/operations/exporters.ts). An XCAF colored/named multi-shape STEP writer. It takes a flat ShapeOptions[], not an AssemblyNode, and never reads mates, joints, or solved transforms.
There is no bridge between them. A solved tree's transforms are not fed into exportAssemblySTEP. To write a positioned STEP, apply the solved transforms to the shapes and pass them as ShapeOptions[].
The solver mental model
Read CLAUDE.md's "Assembly solver composes constraints down a chain" gotcha first — it is the authoritative one-liner. solveConstraints(nodes, constraints) in src/kernel/solverAdapter.ts is the analytical (closed-form, non-iterative) core. Its algorithm:
- Every node starts at origin/identity.
- A positioning mate is one of
coincident, distance, angle, concentric (the POSITIONING_TYPES set) with both entities present. entityA is the reference, entityB is the dependent.
- Anchors sit at the origin: any node that is never a dependent (a chain root), plus any explicit
fixed node.
- Well-typed mates resolve in topological rounds: a mate places its dependent once its reference is already placed, solving against the reference's solved world-space pose (rotation included), so multi-body chains compose.
- Diagnostics: an entity-type mismatch is pushed as
type(a-b) and dropped; a mate whose reference never resolves (cycle/dangling) is pushed as type(unanchored). converged = unsupported.length === 0.
mateFns.solveAssembly wraps this: it extracts geometry, calls solveConstraints, and maps the result to a Result<AssemblySolveResult>.
From MateEntity to a solver entity type
A MateEntity is { node, face?, edge?, point? }. extractEntity in mateFns.ts maps it to a solver entity typed plane, axis, or point:
- face →
faceAxis(face) (from faceFns.ts); non-null (a cylindrical/axial face) → axis, else plane via faceCenter + normalAt. faceAxis returning null is the axial-vs-planar discriminator.
- edge →
LINE type → axis along its tangent; else curveAxis (circular edge, e.g. a bore rim) → axis; else null.
- point →
point.
If extraction returns null for either entity, solveAssembly fails with ASSEMBLY_MATE_INVALID ("could not extract geometry from mate entities").
Supported constraint pairs
isSupportedPair (in solverAdapter.ts) decides solvability:
| Mate type | Allowed (entityA-entityB) pairs |
|---|
coincident, distance | TRANSLATIONAL_PAIRS: plane-plane, plane-point, point-plane, point-point, axis-axis, axis-point, point-axis |
concentric | REQUIRED_ENTITIES: axis-axis only |
angle | REQUIRED_ENTITIES: plane-plane only |
fixed | one entity, anchors that node at origin |
Both orders are listed for translational pairs, so entities need not be pre-ordered.
Debugging a failed solve
The three error codes live in src/core/errors.ts (ASSEMBLY_MATE_INVALID, ASSEMBLY_SOLVE_FAILED, ASSEMBLY_NOT_CONVERGED). URDF errors instead use the generic VALIDATION_FAILED; STEP export uses STEP_EXPORT_FAILED.
Symptom → cause → fix
| Symptom | Cause | Fix |
|---|
ASSEMBLY_MATE_INVALID: no mates defined | Called solveAssembly before addMate | Add at least one mate |
ASSEMBLY_MATE_INVALID: could not extract geometry | A MateEntity face/edge yielded no plane/axis/point (e.g. a non-line, non-circular edge) | Pick an axial/planar face, a straight or circular edge, or a point |
ASSEMBLY_NOT_CONVERGED: Unsupported constraint types: coincident(axis-plane) … | Entity-type mismatch — pair not in the supported table (the canonical case is coincident(axis-plane)) | Fix the entities so the pair is supported (e.g. axis-axis for concentric, plane-plane for angle) |
ASSEMBLY_NOT_CONVERGED: … concentric(plane-plane) … | concentric/angle got the wrong entity types (they require axis-axis / plane-plane) | Feed the required entity types |
ASSEMBLY_NOT_CONVERGED: … coincident(unanchored) … | Reference never resolved — a mutual-reference cycle (a→b, b→a) or a dangling reference, with no root/fixed anchor | Add a fixed mate to anchor the chain, or break the loop |
ASSEMBLY_NOT_CONVERGED: … solver did not converge (no unsupported list) | Rare: no diagnostics but still not converged | Inspect the mate graph for an unanchored dependent |
ASSEMBLY_SOLVE_FAILED: … | An exception was thrown inside the solve (see below) | Read the wrapped message; likely the solveMate escaped-filter invariant |
The dof in the message is the sum over unsupported mates of UNSUPPORTED_DOF (coincident 3, concentric 4, distance 1, angle 1). tests/mateFns.test.ts has worked examples of every diagnostic, including the axis-plane and the a→b/b→a cycle.
Extending the solver (new mate or entity pair)
The load-bearing invariant: solveMate's default branch throws "solveMate: unsupported entity pair escaped filter" when solveTranslational returns null after isSupportedPair passed — i.e. the pair set and the dispatch switch drifted out of sync (surfaces as ASSEMBLY_SOLVE_FAILED).
To add a translational entity pair (e.g. a new axis-plane handling), update both in solverAdapter.ts:
- Add the key to
TRANSLATIONAL_PAIRS.
- Add the matching
case in the solveTranslational switch (with a per-pair solve function like the existing solvePlanePair, solveConcentric, solveAxisToPoint).
For a new orientation/axis mate, add to REQUIRED_ENTITIES and give it a branch in solveMate; add its type to POSITIONING_TYPES and UNSUPPORTED_DOF. Then surface it through mateFns.ts: extend the MateConstraint union and mateToSolverConstraint, and export from the public surface. See the adding-operations skill for the export/function-lookup.md gate and result-error-handling for adding an error code.
Joints and kinematics
src/operations/jointFns.ts defines five joint types. A Joint connects parent (stationary reference) → child (moving); dofs is the source of truth; value/min/max mirror the primary (first) DOF for single-DOF ergonomics, and axis is the joint's anchor/primary axis (equal to the primary DOF axis only for single-axis joints, not for spherical/planar). Every DOF value is always clamped to [min, max] (makeDof normalizes inverted ranges).
| Joint | DOFs | Default ranges |
|---|
revolute | 1 rotation | -180..180 |
prismatic | 1 translation (ignores axis.origin) | 0..100 |
cylindrical | rotation + slide on one axis | rot -180..180, trans 0..100 |
planar | u-trans, v-trans, rotation about normal | u/v -100..100, rot -180..180 |
spherical | x, y, z rotations about a pivot (Rx·Ry·Rz) | each -180..180 |
- Drive:
setJointValues(joint, number[]) sets per-DOF positionally; setJointValue(joint, n) sets only the primary. jointTransform(joint, value?) returns the child's local pose — a single number overrides the primary DOF only, an array overrides positionally.
- Forward kinematics:
forwardKinematics(assembly, jointValues?) returns a world JointPose for every node, keyed by child name, resolved topologically: childWorld = parentWorld ∘ jointTransform ∘ offset?. Roots sit at origin. mechanismDOF sums open-chain DOFs (closed-loop Grübler/Kutzbach is future work).
- Add a joint type: write a constructor building
dofs via makeDof + buildJoint. FK/IK differentiate through dofs, so kinematics stays joint-agnostic automatically.
Driving: inverse kinematics and trajectories
src/operations/ikFns.ts. inverseKinematics(assembly, endEffector, target, options?) is damped-least-squares over a numerically-differentiated Jacobian of forwardKinematics — joint-type agnostic. IKTarget is { position, rotation? }; omit rotation for position-only (m=3 vs m=6). Options: maxIterations (200), tolerance (1e-5), damping (0.05), seed, tip. Gotcha: the finite-difference Jacobian steps inward from a bound, because forwardKinematics clamps each DOF — a forward +eps at a limit would give a zero column and trap the solver.
jointTrajectory(assembly, from, to, steps) samples a straight line in joint space, returning steps + 1 samples (both endpoints inclusive). Joints absent from from/to hold their stored value.
DH and URDF interchange
src/operations/dhFns.ts — jointsFromDH(rows, {base?}) builds a serial revolute/prismatic chain from a distal DH table. Each row → one joint; the variable is θ (revolute) or d (prismatic) about/along +z, and the fixed link geometry Rz(θ)·Tz(d)·Tx(a)·Rx(α) rides on Joint.offset so each row contributes exactly one DOF.
src/operations/urdfFns.ts — exportURDF round-trips only revolute/prismatic; it errors (VALIDATION_FAILED) on any multi-DOF joint or any joint carrying an offset (so DH chains cannot exportURDF). Revolute limits convert degrees↔radians on the boundary; prismatic emits a zero origin (brepjs prismatic FK ignores axis.origin). importURDF(xml) is a regex reader: continuous → revolute -180..180, and fixed/floating/planar joints are skipped (their links still listed).
Assembly STEP / XCAF export
exportAssemblySTEP(shapes: ShapeOptions[], { unit?, modelUnit? }) → Result<Blob> (MIME application/STEP). ShapeOptions is { shape, color?, alpha?, name? }. The OOP twin createAssembly returns a disposable AssemblyExporter (a KernelHandle); exportSTEP disposes it via Symbol.dispose. See src/operations/README.md for units/colors — do not restate them. This path needs the kernel's createXCAFDocument / writeXCAFToSTEP; assembly-STEP XCAF is a known occt-wasm divergence, so check the kernel-abstraction skill for current support. For handle disposal see memory-and-disposal.
Where things are exported (asymmetry to know)
Everything is in src/operations.ts and re-exported from root brepjs — except mates: addMate, solveAssembly, MateConstraint, MateEntity, AssemblySolveResult are exported only from root brepjs (src/index.ts), not from brepjs/operations. The solver core (solveConstraints, SolverEntity, SolverConstraint) is not barrelled at all — import it directly from @/kernel/solverAdapter.js, as the tests do.
Additional resources
tests/mateFns.test.ts — every solve + diagnostic case; tests/jointFns.test.ts, tests/dhFns.test.ts, tests/ikFns.test.ts, tests/urdfFns.test.ts, tests/assemblyFns.test.ts — usage examples.
src/operations/README.md — the STEP/XCAF export details (units, colors, disposal).
Sibling skills
result-error-handling — unwrapping Result, adding a BrepErrorCode.
kernel-abstraction — whether createXCAFDocument/writeXCAFToSTEP are supported per kernel.
adding-operations — the export surfaces + function-lookup.md gate when adding a mate/joint function.
writing-tests — the test skeleton.
memory-and-disposal — the AssemblyExporter KernelHandle and using.
debugging-geometry — for fused-compound "assemblies", which are a different thing entirely.