Assemblies, constraint solving, and kinematic joints

SkillDev tools

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).

Available today. Use it from your connected AI after setup.

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.

  1. 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.
  2. Assembly STEP exportexportAssemblySTEP / 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:

  • facefaceAxis(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.
  • edgeLINE type → axis along its tangent; else curveAxis (circular edge, e.g. a bore rim) → axis; else null.
  • pointpoint.

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 typeAllowed (entityA-entityB) pairs
coincident, distanceTRANSLATIONAL_PAIRS: plane-plane, plane-point, point-plane, point-point, axis-axis, axis-point, point-axis
concentricREQUIRED_ENTITIES: axis-axis only
angleREQUIRED_ENTITIES: plane-plane only
fixedone 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

SymptomCauseFix
ASSEMBLY_MATE_INVALID: no mates definedCalled solveAssembly before addMateAdd at least one mate
ASSEMBLY_MATE_INVALID: could not extract geometryA 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 anchorAdd 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 convergedInspect 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:

  1. Add the key to TRANSLATIONAL_PAIRS.
  2. 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).

JointDOFsDefault ranges
revolute1 rotation-180..180
prismatic1 translation (ignores axis.origin)0..100
cylindricalrotation + slide on one axisrot -180..180, trans 0..100
planaru-trans, v-trans, rotation about normalu/v -100..100, rot -180..180
sphericalx, 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.tsjointsFromDH(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.tsexportURDF 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 brepjsexcept 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.

Signals

GitHub stars
101
Forks
9
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
assembly-solver
Source
github.com/andymai/brepjs