Assemblies, constraint solving, and kinematic joints
SkillDev toolsThis 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).
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Assemblies, constraint solving, and kinematic joints skill
What this skill tells your AI
The instructions your AI receives, as published by andymai/brepjs in .claude/skills/assembly-solver/SKILL.md and read by ahel’s review.
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
AssemblyNodetree (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 flatShapeOptions[], not anAssemblyNode, 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(thePOSITIONING_TYPESset) with both entities present.entityAis the reference,entityBis the dependent. - Anchors sit at the origin: any node that is never a dependent (a chain root), plus any explicit
fixednode. - 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 astype(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)(fromfaceFns.ts); non-null (a cylindrical/axial face) →axis, elseplaneviafaceCenter+normalAt.faceAxisreturning null is the axial-vs-planar discriminator. - edge →
LINEtype →axisalong its tangent; elsecurveAxis(circular edge, e.g. a bore rim) →axis; elsenull. - 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
casein thesolveTranslationalswitch (with a per-pair solve function like the existingsolvePlanePair,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 singlenumberoverrides the primary DOF only, an array overrides positionally. - Forward kinematics:
forwardKinematics(assembly, jointValues?)returns a worldJointPosefor every node, keyed by child name, resolved topologically:childWorld = parentWorld ∘ jointTransform ∘ offset?. Roots sit at origin.mechanismDOFsums open-chain DOFs (closed-loop Grübler/Kutzbach is future work). - Add a joint type: write a constructor building
dofsviamakeDof+buildJoint. FK/IK differentiate throughdofs, 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— unwrappingResult, adding aBrepErrorCode.kernel-abstraction— whethercreateXCAFDocument/writeXCAFToSTEPare supported per kernel.adding-operations— the export surfaces +function-lookup.mdgate when adding a mate/joint function.writing-tests— the test skeleton.memory-and-disposal— theAssemblyExporterKernelHandleandusing.debugging-geometry— for fused-compound "assemblies", which are a different thing entirely.
Signals
- GitHub stars
- 101
- Forks
- 9
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
assembly-solver- Source
- github.com/andymai/brepjs