From 1127f4e98417fb2da83332178dce6971afb08174 Mon Sep 17 00:00:00 2001
From: Andrii Shylenko <14119286+w1ne@users.noreply.github.com>
Date: Sun, 12 Jul 2026 09:25:22 +0200
Subject: [PATCH] feat: add physical-use-case validation for robot hands
Signed-off-by: Andrii Shylenko <14119286+w1ne@users.noreply.github.com>
---
.../2026-07-09-five-finger-hand-loop.json | 155 +++
.../robot-hand-workflow-comparison/index.html | 186 +++
.../2026-07-08-mesh-conditioned-robot-hand.md | 78 ++
...026-07-09-physical-plausibility-tooling.md | 1092 +++++++++++++++++
...-07-10-five-finger-hand-topology-repair.md | 473 +++++++
.../2026-07-10-five-finger-topology-gate.md | 203 +++
...-11-joint-reaction-and-clevis-structure.md | 607 +++++++++
...026-07-11-pose-bound-static-equilibrium.md | 280 +++++
...6-07-11-simultaneous-grasp-reachability.md | 132 ++
...-07-08-function-first-robot-hand-design.md | 134 ++
...neric-physical-plausibility-gate-design.md | 59 +
...7-08-mesh-conditioned-robot-hand-design.md | 103 ++
...0-five-finger-hand-topology-gate-design.md | 93 ++
...five-finger-hand-topology-repair-design.md | 63 +
...nt-reaction-and-clevis-structure-design.md | 314 +++++
...11-pose-bound-static-equilibrium-design.md | 141 +++
...-simultaneous-grasp-reachability-design.md | 42 +
.../workflow-candidates-comparison.kcad.ts | 191 +++
scripts/robotHandFunctionalRequirements.ts | 108 ++
scripts/robotHandWorkflowCompare.ts | 290 +++++
src/agent/mcp/registry/reviewPipelineTools.ts | 29 +
src/agent/mcp/toolOutputSchemas.ts | 77 ++
src/agent/mcp/tools/designLoop.ts | 85 +-
src/agent/mcp/tools/listApi.ts | 2 +-
src/agent/review/reviewPipeline.ts | 117 +-
src/modeling/api.ts | 19 +-
src/modeling/capture/assembly.ts | 376 +++++-
src/modeling/joints/clevis.test.ts | 93 ++
src/modeling/joints/clevis.ts | 102 ++
src/modeling/joints/index.ts | 33 +-
.../joints/supportedServoRevolute.test.ts | 326 +++++
src/modeling/joints/supportedServoRevolute.ts | 281 +++++
src/modeling/joints/types.ts | 37 +
.../mates/clevisJointStructure.test.ts | 187 +++
src/modeling/mates/clevisJointStructure.ts | 305 +++++
src/modeling/mates/jointLoadCapacity.test.ts | 49 +-
src/modeling/mates/jointLoadCapacity.ts | 69 +-
src/modeling/mates/jointTopology.test.ts | 370 ++++++
src/modeling/mates/jointTopology.ts | 433 +++++++
src/modeling/mates/mate.ts | 34 +-
src/modeling/mates/mechanicalPlausibility.ts | 3 +-
src/modeling/mates/mechanismFitness.ts | 46 +
src/modeling/mates/physicalUseCase.test.ts | 433 +++++++
src/modeling/mates/physicalUseCase.ts | 1073 ++++++++++++++++
.../physicalUseCaseJointCapacity.test.ts | 608 +++++++++
.../mates/physicalUseCaseJointCapacity.ts | 93 ++
.../physicalUseCaseJointReactions.test.ts | 718 +++++++++++
.../mates/physicalUseCaseJointReactions.ts | 1013 +++++++++++++++
.../mates/physicalUseCaseReachability.test.ts | 278 +++++
.../mates/physicalUseCaseReachability.ts | 316 +++++
.../mates/physicalUseCaseStatics.test.ts | 478 ++++++++
src/modeling/mates/physicalUseCaseStatics.ts | 1009 +++++++++++++++
src/modeling/mates/solver.test.ts | 29 +
src/modeling/mates/solver.ts | 69 +-
src/modeling/mates/validator.test.ts | 31 +
src/modeling/mates/validator.ts | 30 +
.../runtime/interferenceClassification.ts | 50 +
src/studio/StudioShell.tsx | 18 +-
.../__tests__/StudioShell.status.test.tsx | 38 +-
.../__tests__/useRecomputeResult.test.tsx | 6 +-
.../components/Layout/StatusBar.test.tsx | 26 +
src/studio/components/Layout/StatusBar.tsx | 12 +-
src/studio/context/GeometryContext.test.tsx | 84 +-
src/studio/context/GeometryContext.tsx | 87 +-
src/studio/hooks/useRecomputeResult.ts | 15 +-
src/studio/types.ts | 18 +-
...ejected-five-finger-kinematic-hand.kcad.ts | 524 ++++++++
...-function-first-bar-grasp-skeleton.kcad.ts | 252 ++++
...d-function-first-three-finger-hand.kcad.ts | 333 +++++
.../examples/fiveFingerKinematicHand.test.ts | 176 +++
.../functionFirstBarGraspSkeleton.test.ts | 108 ++
.../functionFirstThreeFingerHand.test.ts | 71 ++
.../robotHandFunctionalRequirements.test.ts | 50 +
.../robotHandWorkflowCandidateModels.test.ts | 14 +
.../robotHandWorkflowComparison.test.ts | 44 +
.../examples/twoFingerCoupledGripper.test.ts | 13 +-
tests/integration/mcp/designLoop.test.ts | 362 +++++-
.../mcp/physicalUseCaseGate.test.ts | 610 +++++++++
tests/integration/mcp/reviewCad.test.ts | 25 +-
tests/unit/assemblies/assemblyCapture.test.ts | 56 +
.../mcp/designLoopNextActionPrompt.test.ts | 67 +
tests/unit/mcp/reviewCadOutputSchema.test.ts | 43 +
tests/unit/mcp/toolRegistrySchema.test.ts | 10 +
.../unit/review/reviewPipelineStages.test.ts | 1 +
.../interferenceClassification.test.ts | 50 +
.../server/viteReviewLiveEndpoint.test.ts | 188 +++
vite.config.ts | 90 +-
87 files changed, 17202 insertions(+), 234 deletions(-)
create mode 100644 artifacts/robot-hand-design-loop/2026-07-09-five-finger-hand-loop.json
create mode 100644 artifacts/robot-hand-workflow-comparison/index.html
create mode 100644 docs/superpowers/plans/2026-07-08-mesh-conditioned-robot-hand.md
create mode 100644 docs/superpowers/plans/2026-07-09-physical-plausibility-tooling.md
create mode 100644 docs/superpowers/plans/2026-07-10-five-finger-hand-topology-repair.md
create mode 100644 docs/superpowers/plans/2026-07-10-five-finger-topology-gate.md
create mode 100644 docs/superpowers/plans/2026-07-11-joint-reaction-and-clevis-structure.md
create mode 100644 docs/superpowers/plans/2026-07-11-pose-bound-static-equilibrium.md
create mode 100644 docs/superpowers/plans/2026-07-11-simultaneous-grasp-reachability.md
create mode 100644 docs/superpowers/specs/2026-07-08-function-first-robot-hand-design.md
create mode 100644 docs/superpowers/specs/2026-07-08-generic-physical-plausibility-gate-design.md
create mode 100644 docs/superpowers/specs/2026-07-08-mesh-conditioned-robot-hand-design.md
create mode 100644 docs/superpowers/specs/2026-07-10-five-finger-hand-topology-gate-design.md
create mode 100644 docs/superpowers/specs/2026-07-10-five-finger-hand-topology-repair-design.md
create mode 100644 docs/superpowers/specs/2026-07-11-joint-reaction-and-clevis-structure-design.md
create mode 100644 docs/superpowers/specs/2026-07-11-pose-bound-static-equilibrium-design.md
create mode 100644 docs/superpowers/specs/2026-07-11-simultaneous-grasp-reachability-design.md
create mode 100644 examples/robot-hand/workflow-candidates-comparison.kcad.ts
create mode 100644 scripts/robotHandFunctionalRequirements.ts
create mode 100644 scripts/robotHandWorkflowCompare.ts
create mode 100644 src/modeling/joints/supportedServoRevolute.test.ts
create mode 100644 src/modeling/joints/supportedServoRevolute.ts
create mode 100644 src/modeling/mates/clevisJointStructure.test.ts
create mode 100644 src/modeling/mates/clevisJointStructure.ts
create mode 100644 src/modeling/mates/jointTopology.test.ts
create mode 100644 src/modeling/mates/jointTopology.ts
create mode 100644 src/modeling/mates/physicalUseCase.test.ts
create mode 100644 src/modeling/mates/physicalUseCase.ts
create mode 100644 src/modeling/mates/physicalUseCaseJointCapacity.test.ts
create mode 100644 src/modeling/mates/physicalUseCaseJointCapacity.ts
create mode 100644 src/modeling/mates/physicalUseCaseJointReactions.test.ts
create mode 100644 src/modeling/mates/physicalUseCaseJointReactions.ts
create mode 100644 src/modeling/mates/physicalUseCaseReachability.test.ts
create mode 100644 src/modeling/mates/physicalUseCaseReachability.ts
create mode 100644 src/modeling/mates/physicalUseCaseStatics.test.ts
create mode 100644 src/modeling/mates/physicalUseCaseStatics.ts
create mode 100644 src/modeling/runtime/interferenceClassification.ts
create mode 100644 tests/fixtures/robot-hand/rejected-five-finger-kinematic-hand.kcad.ts
create mode 100644 tests/fixtures/robot-hand/rejected-function-first-bar-grasp-skeleton.kcad.ts
create mode 100644 tests/fixtures/robot-hand/rejected-function-first-three-finger-hand.kcad.ts
create mode 100644 tests/integration/examples/fiveFingerKinematicHand.test.ts
create mode 100644 tests/integration/examples/functionFirstBarGraspSkeleton.test.ts
create mode 100644 tests/integration/examples/functionFirstThreeFingerHand.test.ts
create mode 100644 tests/integration/examples/robotHandFunctionalRequirements.test.ts
create mode 100644 tests/integration/examples/robotHandWorkflowCandidateModels.test.ts
create mode 100644 tests/integration/examples/robotHandWorkflowComparison.test.ts
create mode 100644 tests/integration/mcp/physicalUseCaseGate.test.ts
create mode 100644 tests/unit/runtime/interferenceClassification.test.ts
create mode 100644 tests/unit/server/viteReviewLiveEndpoint.test.ts
diff --git a/artifacts/robot-hand-design-loop/2026-07-09-five-finger-hand-loop.json b/artifacts/robot-hand-design-loop/2026-07-09-five-finger-hand-loop.json
new file mode 100644
index 000000000..32a32f224
--- /dev/null
+++ b/artifacts/robot-hand-design-loop/2026-07-09-five-finger-hand-loop.json
@@ -0,0 +1,155 @@
+{
+ "title": "kernelCAD robot hand design loop",
+ "goal": "Produce a robot hand artifact, not a flat grasp fixture, with visible palm, thumb, fingers, supported joints, connected distal fingertips, and deterministic validation evidence.",
+ "source": "tests/fixtures/robot-hand/rejected-five-finger-kinematic-hand.kcad.ts",
+ "attempts": [
+ {
+ "id": "01",
+ "title": "Planar three-finger grasp fixture",
+ "source": "tests/fixtures/robot-hand/rejected-function-first-three-finger-hand.kcad.ts",
+ "status": "rejected",
+ "evidence": {
+ "iso": "/tmp/function-first-hand-preview/iso.png"
+ },
+ "visualFindings": [
+ "Reads as a flat test fixture with a target cylinder, not as a hand.",
+ "Palm is slab-like and the fingers are side-mounted bars rather than recognizable digits.",
+ "Functional aperture evidence was not enough to satisfy the visual and physical hand brief."
+ ],
+ "decision": "Reject as the user-facing robot hand artifact. Keep only as a narrow grasp-test fixture if needed."
+ },
+ {
+ "id": "02",
+ "title": "Five-finger kinematic hand before fingertip repair",
+ "source": "tests/fixtures/robot-hand/rejected-five-finger-kinematic-hand.kcad.ts",
+ "status": "rejected",
+ "evidence": {
+ "front": "/tmp/five-finger-hand-preview/front.png",
+ "iso": "/tmp/five-finger-hand-preview/iso.png"
+ },
+ "visualFindings": [
+ "Reads as a robot hand from the front: palm, four fingers, angled thumb, visible clevis joints, and wrist block are present.",
+ "Black fingertip pads visibly float above the distal links in front and iso views.",
+ "Floating fingertip pads violate the no-stray-or-floating-geometry and attachment-plausibility checks."
+ ],
+ "decision": "Repair the distal phalanx construction before accepting."
+ },
+ {
+ "id": "03",
+ "title": "Five-finger kinematic hand with connected distal fingertips",
+ "source": "tests/fixtures/robot-hand/rejected-five-finger-kinematic-hand.kcad.ts",
+ "status": "rejected-as-working-hand",
+ "evidence": {
+ "front": "/tmp/five-finger-hand-preview-fixed/front.png",
+ "top": "/tmp/five-finger-hand-preview-fixed/top.png",
+ "iso": "/tmp/five-finger-hand-preview-fixed/iso.png"
+ },
+ "visualFindings": [
+ "Front and iso views now show fingertip ends attached to continuous distal links instead of floating black blocks.",
+ "The model reads as a robot hand: palm plate, wrist block, four vertical fingers, angled thumb, visible clevis joints, screws, tendon rods, and distal fingertip pads.",
+ "Canonical views still show a constructed mechanism rather than a single visual mesh or disconnected decorative fragments.",
+ "The model still lacks declared grasp-task evidence: no physicalUseCase contacts, loads, stable parts, or actuator limits prove that it can hold an object."
+ ],
+ "deterministicChecks": [
+ "npx vitest run tests/integration/examples/fiveFingerKinematicHand.test.ts -t \"not accepted as a working hand\" --reporter=dot"
+ ],
+ "remainingCaveats": [
+ "The render preview used no_mechanism_check:true for speed, so the preview images themselves are visual evidence only.",
+ "The example now explicitly fails the working-hand acceptance gate with assembly.physical-use-case.missing until grasp-task evidence is added.",
+ "Next slices should replace decorative/unexplained hardware with load-bearing grasp components, then add physicalUseCase contacts and force/torque limits before any acceptance claim."
+ ]
+ },
+ {
+ "id": "04",
+ "title": "Five-finger hand with declared cylinder grasp task",
+ "source": "tests/fixtures/robot-hand/rejected-five-finger-kinematic-hand.kcad.ts",
+ "status": "rejected-for-unreached-grasp-and-unsupported-actuation",
+ "evidence": {
+ "front": "/tmp/five-finger-hand-grasp-thumb-opposed/front.png",
+ "top": "/tmp/five-finger-hand-grasp-thumb-opposed/top.png",
+ "iso": "/tmp/five-finger-hand-grasp-thumb-opposed/iso.png",
+ "maxCloseFront": "/tmp/five-finger-hand-grasp-max-close/front.png"
+ },
+ "visualFindings": [
+ "The model now declares a grasp-cylinder part and a power-cylinder-grasp physicalUseCase with target load, contacts, actuator limits, and palm reaction path.",
+ "The default and max-close rendered views still show the blue cylinder near the hand rather than actually captured between thumb and opposing fingers.",
+ "The thumb does not visually participate in the grasp, so the declared contact task is not satisfied by the current kinematic geometry.",
+ "The physical-use-case gate now rejects every thumb/index/middle actuatorLimit because those revolute mates have no mechanicalJoint support contract."
+ ],
+ "deterministicChecks": [
+ "npx vitest run tests/integration/examples/fiveFingerKinematicHand.test.ts -t \"declares a cylinder-grasp\" --reporter=dot",
+ "npx tsx -e \"evaluateAndBuildScript(...)\""
+ ],
+ "remainingCaveats": [
+ "Fast physicalUseCase declaration review now fails with assembly.physical-use-case.actuator-support-missing until supported mechanicalJoint contracts exist for driven grasp joints.",
+ "The correct pose-envelope reachability review is currently too slow on this 15-mate model for the interactive loop and was interrupted after several minutes.",
+ "The next implementation should either redesign the thumb/index/middle kinematics around the cylinder workspace or add a fast targeted reachability gate for declared physicalUseCase contacts."
+ ]
+ },
+ {
+ "id": "05",
+ "title": "Five-finger hand with supported MCP grasp-drive slice",
+ "source": "tests/fixtures/robot-hand/rejected-five-finger-kinematic-hand.kcad.ts",
+ "status": "partial-pass-supported-mcp-slice-not-complete-hand",
+ "evidence": {
+ "studioScreenshot": "/tmp/five-finger-supported-mcp-hand-v2.png",
+ "studioUrl": "http://127.0.0.1:5175/studio?script=tests/fixtures/robot-hand/rejected-five-finger-kinematic-hand.kcad.ts"
+ },
+ "visualFindings": [
+ "The model still reads as the five-finger robot hand with palm, four fingers, angled thumb, clevis joints, wrist block, and blue grasp cylinder.",
+ "Three black MCP servo blocks for middle, index, and thumb are now seated against explicit palm-root mounting pads rather than floating in front of the hand.",
+ "The physical use case no longer claims PIP/DIP actuation. Only the three supported MCP mates are listed in actuatorLimits.",
+ "The cylinder is still not visually captured by a convincing thumb/opposing-finger grasp, so this is not accepted as a complete working hand."
+ ],
+ "deterministicChecks": [
+ "npx vitest run tests/integration/examples/fiveFingerKinematicHand.test.ts -t \"supported base knuckle\" --reporter=dot",
+ "npx vitest run tests/integration/examples/fiveFingerKinematicHand.test.ts --reporter=dot",
+ "npx vitest run tests/integration/mcp/physicalUseCaseGate.test.ts --reporter=dot",
+ "npm run typecheck"
+ ],
+ "remainingCaveats": [
+ "Studio initially reported interferences: 16 because the footer counted raw contact-noise pairs. Attempt 06 classifies and filters those pairs against the shared mechanism cap.",
+ "The supported-drive evidence is limited to middle/index/thumb MCP joints. PIP/DIP joints remain visual kinematic joints until real transmission/support paths are modeled.",
+ "Contact reachability against the declared cylinder is still not proven by a fast gate."
+ ]
+ },
+ {
+ "id": "06",
+ "title": "Interference footer classification for supported MCP hand",
+ "source": "tests/fixtures/robot-hand/rejected-five-finger-kinematic-hand.kcad.ts",
+ "status": "partial-pass-actionable-interference-count-clean",
+ "evidence": {
+ "studioScreenshot": "/tmp/five-finger-supported-mcp-hand-interference-filter.png",
+ "studioUrl": "http://127.0.0.1:5176/studio?script=tests/fixtures/robot-hand/rejected-five-finger-kinematic-hand.kcad.ts"
+ },
+ "visualFindings": [
+ "The rendered hand remains visually the same supported-MCP slice: palm, five fingers, seated middle/index/thumb MCP servo blocks, and blue grasp cylinder.",
+ "Studio footer now reports interferences: 0 because the footer counts only pairs above the shared 20 mm3 actionable interference cap.",
+ "The raw default-pose detector still sees 16 tiny overlap pairs, all below the cap. They are documented as joint/contact-noise inventory rather than hidden."
+ ],
+ "deterministicChecks": [
+ "npx vitest run src/studio/__tests__/StudioShell.status.test.tsx --reporter=dot",
+ "npx vitest run tests/integration/examples/fiveFingerKinematicHand.test.ts --reporter=dot",
+ "npm run typecheck"
+ ],
+ "remainingCaveats": [
+ "This slice fixes false-positive actionable interference reporting; it does not prove the grasp captures the cylinder.",
+ "The raw 16 below-cap pairs should shrink in future geometry cleanup, but they are no longer blocking under the existing mechanism-truth threshold.",
+ "PIP/DIP transmission and fast contact reachability remain unresolved."
+ ]
+ },
+ {
+ "id": "07",
+ "title": "Tooling gate: targeted grasp reachability",
+ "source": "tests/fixtures/robot-hand/rejected-five-finger-kinematic-hand.kcad.ts",
+ "status": "rejected-by-physical-use-case-contact-reachability",
+ "deterministicChecks": [
+ "npx vitest run tests/integration/examples/fiveFingerKinematicHand.test.ts -t \"physical use case reachability\" --reporter=dot"
+ ],
+ "remainingCaveats": [
+ "The model remains visually inspectable but is rejected because declared contacts do not reach the cylinder within maxSlipMm.",
+ "Next geometry slice must redesign thumb/index/middle placement or actuator travel using this diagnostic, not visual guesswork."
+ ]
+ }
+ ]
+}
diff --git a/artifacts/robot-hand-workflow-comparison/index.html b/artifacts/robot-hand-workflow-comparison/index.html
new file mode 100644
index 000000000..a2057cad8
--- /dev/null
+++ b/artifacts/robot-hand-workflow-comparison/index.html
@@ -0,0 +1,186 @@
+
Robot Hand Workflow Benchmark
+ Thin prototypes scored against the same target: a reference-matched, physically valid parametric robot hand.
+
+
Recommended path: reference-conditioned + master-skeleton + validation-loop
+
Reference-conditioned CAD preserves visible fit, master skeletons provide stable parametrics, and the validation loop supplies physical acceptance.
+
+
+
+
+
+
#1 evidence-to-generator
+
Reference-Conditioned CAD
+
+
81
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Inputs: reference image or mesh landmarks plus mechanism family
+ Builds: landmark-driven visible proportions with mechanical completion
+ Failure caught: visual drift, wrong thumb angle, lost palm/wrist language
+ Caveat: Landmarks are manual until mesh or image extraction is added.
+
+
Physics
+
Reference fit
+
Stability
+
Automation
+
Validation
+
+
+
+
+
+
+
#2 validator
+
Validation Loop
+
+
78
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Inputs: candidate assembly, reference evidence, physical requirements
+ Builds: acceptance gates, scoring, repair hints, reject/pass decision
+ Failure caught: floating parts, invalid mates, collisions, weak loads, visual drift
+ Caveat: This is not a generator; it decides whether a generated model is acceptable.
+
+
Physics
+
Reference fit
+
Stability
+
Automation
+
Validation
+
+
+
+
+
+
+
#3 skeleton
+
Master Skeleton
+
+
77
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Inputs: datums, joint centers, axes, envelopes, motion arcs
+ Builds: stable parametric skeleton that downstream solids follow
+ Failure caught: sideways hands, broken axes, unstable edits, impossible motion
+ Caveat: Best as a control layer; still needs either templates or reference evidence for solids.
+
+
Physics
+
Reference fit
+
Stability
+
Automation
+
Validation
+
+
+
+
+
+
+
#4 generator
+
Mechanism Templates
+
+
76
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Inputs: mechanism family, target DOF, rough envelope
+ Builds: known-good palm, clevis, pin, tendon, and finger modules
+ Failure caught: missing joints, unsupported pins, floating visual parts
+ Caveat: Reliable mechanically, but can drift visually when the reference has strong style cues.
+
+
Physics
+
Reference fit
+
Stability
+
Automation
+
Validation
+
+
+
+
+
+
+
#5 evidence-to-generator
+
Mesh Feature Fitting
+
+
66
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Inputs: segmented mesh regions, fitted planes, cylinders, boxes, axes
+ Builds: CAD primitives fitted to visible mesh features
+ Failure caught: bad primitive fit, missing shaft axes, repeated-module mismatch
+ Caveat: Promising for automation, but bad segmentation can create false confidence.
+
+
Physics
+
Reference fit
+
Stability
+
Automation
+
Validation
+
+
+
diff --git a/docs/superpowers/plans/2026-07-08-mesh-conditioned-robot-hand.md b/docs/superpowers/plans/2026-07-08-mesh-conditioned-robot-hand.md
new file mode 100644
index 000000000..6a5904080
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-08-mesh-conditioned-robot-hand.md
@@ -0,0 +1,78 @@
+# Mesh-Conditioned Robot Hand Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Convert the robot hand example from manually scattered dimensions into a reference-landmark-driven parametric assembly with mechanical completion.
+
+**Architecture:** Keep the prototype local to the hand example. Add a `referenceLandmarks` evidence object, generate the visible palm/finger/thumb/wrist details from it, and keep the existing clevis/mate/load validation path as the mechanical completion layer.
+
+**Tech Stack:** KernelCAD `.kcad.ts` example script, Vitest integration tests, `evaluateAndBuildScript`.
+
+---
+
+### Task 1: Add Reference-Landmark Contract Test
+
+**Files:**
+- Modify: `tests/integration/examples/fiveFingerKinematicHand.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Add assertions to the existing front-facing silhouette test:
+
+```ts
+expect(source).toContain('const referenceLandmarks =');
+expect(source).toContain('referenceLandmarks.fingers.forEach(addFinger)');
+expect(source).toContain('referenceLandmarks.actuatorWindows');
+expect(source).toContain('referenceLandmarks.tendons');
+expect(source).toContain('referenceLandmarks.screws');
+expect(source).toContain('angleDeg: 38');
+expect(source).not.toMatch(/\[\s*\{ name: 'little'/);
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npm test -- tests/integration/examples/fiveFingerKinematicHand.test.ts --reporter=dot`
+
+Expected: FAIL because `referenceLandmarks` does not exist yet.
+
+### Task 2: Move Visible Evidence Into `referenceLandmarks`
+
+**Files:**
+- Modify: `examples/robot-hand/five-finger-kinematic-hand.kcad.ts`
+
+- [ ] **Step 1: Add the landmark object**
+
+Create a top-level `referenceLandmarks` object holding palm dimensions, actuator windows, screws, tendons, and finger specs.
+
+- [ ] **Step 2: Generate palm details from landmarks**
+
+Replace hard-coded actuator-window, screw, and tendon loops with loops over
+`referenceLandmarks.actuatorWindows`, `referenceLandmarks.screws`, and
+`referenceLandmarks.tendons`.
+
+- [ ] **Step 3: Generate fingers from landmarks**
+
+Replace the inline finger spec array with `referenceLandmarks.fingers.forEach(addFinger)`.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npm test -- tests/integration/examples/fiveFingerKinematicHand.test.ts --reporter=dot`
+
+Expected: PASS, or a real KernelCAD evaluation failure to fix before continuing.
+
+### Task 3: Verify Open/Closed Evaluation Directly
+
+**Files:**
+- No file changes.
+
+- [ ] **Step 1: Run the integration test**
+
+Run: `npm test -- tests/integration/examples/fiveFingerKinematicHand.test.ts --reporter=dot`
+
+Expected: both open and closed pose evaluation assertions pass with zero error diagnostics.
+
+- [ ] **Step 2: Inspect worktree diff**
+
+Run: `git status --short` and `git diff -- examples/robot-hand/five-finger-kinematic-hand.kcad.ts tests/integration/examples/fiveFingerKinematicHand.test.ts docs/superpowers/specs/2026-07-08-mesh-conditioned-robot-hand-design.md docs/superpowers/plans/2026-07-08-mesh-conditioned-robot-hand.md`
+
+Expected: only the prototype files changed.
diff --git a/docs/superpowers/plans/2026-07-09-physical-plausibility-tooling.md b/docs/superpowers/plans/2026-07-09-physical-plausibility-tooling.md
new file mode 100644
index 000000000..8700b3c29
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-09-physical-plausibility-tooling.md
@@ -0,0 +1,1092 @@
+# Physical Plausibility Tooling Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build tooling that rejects or explains physically implausible CAD assemblies before agents continue visual/model iterations.
+
+**Architecture:** Add small deterministic review units that sit under `src/modeling/` and are surfaced through `review_cad`, Studio, and `design_loop`. The hand model becomes a consumer of these gates, not the place where we encode one-off discipline. Each task is test-first and independently shippable.
+
+**Tech Stack:** TypeScript, Vitest, KernelCAD assembly/mate APIs, existing `review_cad`, `design_loop`, Studio React UI, Playwright only for final browser evidence.
+
+---
+
+## File Structure
+
+- Create `src/modeling/runtime/interferenceClassification.ts`
+ - Pure helper that classifies raw `InterferencePair[]` into `contact-noise` and `actionable` using `jointContactCapMm3()`.
+- Create `tests/unit/runtime/interferenceClassification.test.ts`
+ - Unit tests for classification, summary counts, and boundary behavior at the cap.
+- Modify `src/agent/mcp/tools/reviewCad.ts`
+ - Add `interferenceSummary` to `review_cad` output while keeping `rawInterferencePairs` for low-level consumers.
+- Modify `src/agent/mcp/toolOutputSchemas.ts`
+ - Expose `interferenceSummary` in MCP output schemas.
+- Modify `src/studio/StudioShell.tsx`
+ - Read `interferenceSummary.actionableCount` when available, falling back to classified raw pairs.
+- Modify `src/studio/components/Layout/StatusBar.tsx`
+ - Show actionable count and concise tooltip text that mentions raw/contact-noise/actionable counts.
+- Modify `src/studio/types.ts` and `src/studio/hooks/useRecomputeResult.ts`
+ - Add `interferenceSummary` to the Studio recompute contract.
+- Modify `src/studio/__tests__/StudioShell.status.test.tsx`
+ - Cover actionable footer count behavior.
+- Modify `src/studio/components/Layout/StatusBar.test.tsx`
+ - Cover footer tooltip/status rendering.
+- Create `src/modeling/mates/physicalUseCaseReachability.ts`
+ - Targeted use-case contact reachability sampler that samples only actuator mates named in `physicalUseCase.actuatorLimits`.
+- Modify `src/modeling/mates/physicalUseCase.ts`
+ - Call the targeted reachability review when enabled and emit concrete `contact-unreachable` diagnostics with closest distance.
+- Modify `tests/integration/mcp/physicalUseCaseGate.test.ts`
+ - Add fast fixtures for reachable and unreachable declared contacts.
+- Modify `src/agent/mcp/tools/reviewCad.ts`
+ - Add input option `includePhysicalUseCaseReachability?: boolean`, defaulting to true when `requirePhysicalUseCase` is true and `includePoseEnvelope` is false.
+- Modify `src/agent/mcp/toolRegistry.ts`
+ - Document the new reachability option and output behavior.
+- Create `src/modeling/joints/supportedServoRevolute.ts`
+ - Helper builder for a supported servo revolute drive intent.
+- Modify `src/modeling/joints/index.ts` and `src/modeling/api.ts`
+ - Expose the helper under `joint.supportedServoRevolute(...)`.
+- Create `src/modeling/joints/supportedServoRevolute.test.ts`
+ - Unit tests for generated geometry/connectors/intents.
+- Modify `tests/integration/mcp/designLoop.test.ts`
+ - Add a tooling acceptance report test: an accepted attempt must have no actionable interference, supported actuators, reachable declared contacts, and screenshot review.
+
+---
+
+### Task 1: Shared Interference Classification
+
+**Files:**
+- Create: `src/modeling/runtime/interferenceClassification.ts`
+- Create: `tests/unit/runtime/interferenceClassification.test.ts`
+
+- [x] **Step 1: Write failing tests**
+
+Create `tests/unit/runtime/interferenceClassification.test.ts`:
+
+```ts
+import { describe, expect, it } from 'vitest';
+import { classifyInterferencePairs, summarizeInterferencePairs } from '../../../src/modeling/runtime/interferenceClassification';
+import { jointContactCapMm3 } from '../../../src/modeling/runtime/jointContactCap';
+
+describe('interference classification', () => {
+ it('classifies raw pairs below or equal to the cap as contact noise', () => {
+ const cap = jointContactCapMm3();
+
+ const result = classifyInterferencePairs([
+ { a: 'palm-root', b: 'index-proximal', volumeMm3: 0.5 },
+ { a: 'index-proximal', b: 'index-middle', volumeMm3: cap },
+ ]);
+
+ expect(result.map((pair) => pair.classification)).toEqual(['contact-noise', 'contact-noise']);
+ expect(result.map((pair) => pair.actionable)).toEqual([false, false]);
+ });
+
+ it('classifies raw pairs above the cap as actionable', () => {
+ const cap = jointContactCapMm3();
+
+ const result = classifyInterferencePairs([
+ { a: 'servo', b: 'palm-root', volumeMm3: cap + 0.01 },
+ ]);
+
+ expect(result).toEqual([
+ {
+ a: 'servo',
+ b: 'palm-root',
+ volumeMm3: cap + 0.01,
+ capMm3: cap,
+ classification: 'actionable',
+ actionable: true,
+ },
+ ]);
+ });
+
+ it('summarizes raw, contact-noise, and actionable counts', () => {
+ const cap = jointContactCapMm3();
+
+ expect(summarizeInterferencePairs([
+ { a: 'a', b: 'b', volumeMm3: 1 },
+ { a: 'c', b: 'd', volumeMm3: cap + 1 },
+ ])).toMatchObject({
+ rawCount: 2,
+ contactNoiseCount: 1,
+ actionableCount: 1,
+ capMm3: cap,
+ });
+ });
+});
+```
+
+- [x] **Step 2: Run test and verify red**
+
+Run:
+
+```bash
+npx vitest run tests/unit/runtime/interferenceClassification.test.ts --reporter=dot
+```
+
+Expected: FAIL because `src/modeling/runtime/interferenceClassification.ts` does not exist.
+
+- [x] **Step 3: Implement minimal classifier**
+
+Create `src/modeling/runtime/interferenceClassification.ts`:
+
+```ts
+import type { InterferencePair } from './detectInterferences';
+import { jointContactCapMm3 } from './jointContactCap';
+
+export type InterferenceClassification = 'contact-noise' | 'actionable';
+
+export interface ClassifiedInterferencePair extends InterferencePair {
+ readonly capMm3: number;
+ readonly classification: InterferenceClassification;
+ readonly actionable: boolean;
+}
+
+export interface InterferenceSummary {
+ readonly rawCount: number;
+ readonly contactNoiseCount: number;
+ readonly actionableCount: number;
+ readonly capMm3: number;
+ readonly pairs: readonly ClassifiedInterferencePair[];
+}
+
+export function classifyInterferencePairs(
+ pairs: readonly InterferencePair[],
+ capMm3 = jointContactCapMm3(),
+): ClassifiedInterferencePair[] {
+ return pairs.map((pair) => {
+ const actionable = pair.volumeMm3 > capMm3;
+ return {
+ ...pair,
+ capMm3,
+ classification: actionable ? 'actionable' : 'contact-noise',
+ actionable,
+ };
+ });
+}
+
+export function summarizeInterferencePairs(
+ pairs: readonly InterferencePair[],
+ capMm3 = jointContactCapMm3(),
+): InterferenceSummary {
+ const classified = classifyInterferencePairs(pairs, capMm3);
+ const actionableCount = classified.filter((pair) => pair.actionable).length;
+ return {
+ rawCount: classified.length,
+ contactNoiseCount: classified.length - actionableCount,
+ actionableCount,
+ capMm3,
+ pairs: classified,
+ };
+}
+```
+
+- [x] **Step 4: Run test and verify green**
+
+Run:
+
+```bash
+npx vitest run tests/unit/runtime/interferenceClassification.test.ts --reporter=dot
+```
+
+Expected: PASS.
+
+- [x] **Step 5: Commit**
+
+```bash
+git add src/modeling/runtime/interferenceClassification.ts tests/unit/runtime/interferenceClassification.test.ts
+git commit -m "feat: classify interference actionability"
+```
+
+---
+
+### Task 2: Wire Interference Summary Through `review_cad` And Studio
+
+**Files:**
+- Modify: `src/agent/mcp/tools/reviewCad.ts`
+- Modify: `src/agent/mcp/toolOutputSchemas.ts`
+- Modify: `src/studio/types.ts`
+- Modify: `src/studio/hooks/useRecomputeResult.ts`
+- Modify: `src/studio/StudioShell.tsx`
+- Modify: `src/studio/components/Layout/StatusBar.tsx`
+- Modify: `src/studio/__tests__/StudioShell.status.test.tsx`
+- Modify: `src/studio/components/Layout/StatusBar.test.tsx`
+
+- [x] **Step 1: Write failing Studio/status tests**
+
+In `src/studio/__tests__/StudioShell.status.test.tsx`, keep raw pairs in the mock and add `interferenceSummary`:
+
+```ts
+let recomputeInterferenceSummary: {
+ rawCount: number;
+ contactNoiseCount: number;
+ actionableCount: number;
+ capMm3: number;
+} | null = null;
+```
+
+Extend the mocked `useRecomputeResult` return:
+
+```ts
+interferenceSummary: recomputeInterferenceSummary,
+```
+
+Add test:
+
+```ts
+it('uses actionable interference summary for footer count', () => {
+ recomputeRawPairs = [
+ { a: 'raw-a', b: 'raw-b', volumeMm3: 1 },
+ { a: 'real-a', b: 'real-b', volumeMm3: 30 },
+ ];
+ recomputeInterferenceSummary = {
+ rawCount: 2,
+ contactNoiseCount: 1,
+ actionableCount: 1,
+ capMm3: 20,
+ };
+
+ render( );
+
+ expect(screen.getByTestId('status-interferences').textContent).toBe('1');
+});
+```
+
+In `src/studio/components/Layout/StatusBar.test.tsx`, add:
+
+```ts
+it('renders actionable interference count with summary title', () => {
+ render(
+ ,
+ );
+
+ const text = screen.getByText(/interferences: 1/);
+ expect(text).toHaveAttribute('title', expect.stringContaining('raw: 16'));
+ expect(text).toHaveAttribute('title', expect.stringContaining('contact-noise: 15'));
+});
+```
+
+- [x] **Step 2: Run tests and verify red**
+
+Run:
+
+```bash
+npx vitest run src/studio/__tests__/StudioShell.status.test.tsx src/studio/components/Layout/StatusBar.test.tsx --reporter=dot
+```
+
+Expected: FAIL because `interferenceSummary` is not yet part of the Studio contract and StatusBar props.
+
+- [x] **Step 3: Add `interferenceSummary` to `review_cad` output**
+
+In `src/agent/mcp/tools/reviewCad.ts`, import:
+
+```ts
+import { summarizeInterferencePairs, type InterferenceSummary } from '../../../modeling/runtime/interferenceClassification';
+```
+
+Add `interferenceSummary: InterferenceSummary;` beside `rawInterferencePairs` in both output variants.
+
+After `rawInterferencePairs` is computed:
+
+```ts
+const interferenceSummary = summarizeInterferencePairs(rawInterferencePairs);
+```
+
+Return `interferenceSummary` in both `ok: true` and `ok: false` branches that already return `rawInterferencePairs`.
+
+- [x] **Step 4: Update output schema**
+
+In `src/agent/mcp/toolOutputSchemas.ts`, add `interferenceSummary` next to `rawInterferencePairs`:
+
+```ts
+interferenceSummary: {
+ type: 'object',
+ additionalProperties: true,
+ description: 'Classified interference counts and pairs: raw, contact-noise, actionable, and capMm3.',
+},
+```
+
+- [x] **Step 5: Update Studio types and hook**
+
+In `src/studio/types.ts`, add:
+
+```ts
+readonly interferenceSummary: {
+ readonly rawCount: number;
+ readonly contactNoiseCount: number;
+ readonly actionableCount: number;
+ readonly capMm3: number;
+} | null;
+```
+
+In `src/studio/hooks/useRecomputeResult.ts`, add:
+
+```ts
+const interferenceSummary = useMemo(
+ () => workbench.scriptReview?.interferenceSummary ?? null,
+ [workbench.scriptReview],
+);
+```
+
+Return it and add it to the dependency list.
+
+- [x] **Step 6: Update StudioShell and StatusBar**
+
+In `src/studio/StudioShell.tsx`, replace direct raw-pair filtering with:
+
+```ts
+const interferenceCount = recompute.interferenceSummary?.actionableCount
+ ?? (recompute.rawInterferencePairs ?? []).filter((pair) => pair.volumeMm3 > jointContactCapMm3()).length;
+```
+
+Pass summary:
+
+```tsx
+
+```
+
+In `src/studio/components/Layout/StatusBar.tsx`, extend props:
+
+```ts
+interferenceSummary?: {
+ rawCount: number;
+ contactNoiseCount: number;
+ actionableCount: number;
+ capMm3: number;
+} | null;
+```
+
+Render title:
+
+```tsx
+const interferenceTitle = interferenceSummary
+ ? `actionable: ${interferenceSummary.actionableCount}, contact-noise: ${interferenceSummary.contactNoiseCount}, raw: ${interferenceSummary.rawCount}, cap: ${interferenceSummary.capMm3} mm3`
+ : undefined;
+```
+
+Attach `title={interferenceTitle}` to the `interferences: N` element.
+
+- [x] **Step 7: Run tests and verify green**
+
+Run:
+
+```bash
+npx vitest run src/studio/__tests__/StudioShell.status.test.tsx src/studio/components/Layout/StatusBar.test.tsx --reporter=dot
+```
+
+Expected: PASS.
+
+- [x] **Step 8: Run review_cad interference tests**
+
+Run:
+
+```bash
+npx vitest run tests/integration/mcp/reviewCad-interference.test.ts --reporter=dot
+```
+
+Expected: PASS and no output schema failures.
+
+- [x] **Step 9: Commit**
+
+```bash
+git add src/agent/mcp/tools/reviewCad.ts src/agent/mcp/toolOutputSchemas.ts src/studio/types.ts src/studio/hooks/useRecomputeResult.ts src/studio/StudioShell.tsx src/studio/components/Layout/StatusBar.tsx src/studio/__tests__/StudioShell.status.test.tsx src/studio/components/Layout/StatusBar.test.tsx
+git commit -m "feat: surface actionable interference summary"
+```
+
+---
+
+### Task 3: Targeted Physical-Use-Case Reachability Gate
+
+**Files:**
+- Create: `src/modeling/mates/physicalUseCaseReachability.ts`
+- Modify: `src/modeling/mates/physicalUseCase.ts`
+- Modify: `src/agent/mcp/tools/reviewCad.ts`
+- Modify: `src/agent/mcp/toolRegistry.ts`
+- Modify: `tests/integration/mcp/physicalUseCaseGate.test.ts`
+
+- [x] **Step 1: Write failing unreachable-contact test**
+
+In `tests/integration/mcp/physicalUseCaseGate.test.ts`, add:
+
+```ts
+it('blocks declared physical-use-case contacts that targeted actuator sampling cannot reach', async () => {
+ const result = await reviewCadTool({
+ includePoseEnvelope: false,
+ includeInterference: false,
+ includePhysicalUseCaseReachability: true,
+ requirePhysicalUseCase: true,
+ code: `
+ const arm = assembly('unreachable use case contact');
+ arm.part('base', box(30, 30, 8, true))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 4] }, axis: [0, 0, 1] })
+ .connector('target', { type: 'frame', origin: { kind: 'vec3', value: [120, 0, 4] } })
+ .connector('support', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 4] } });
+ arm.part('finger', box(40, 6, 6, true).translate(20, 0, 0))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [40, 0, 0] } });
+ arm.part('servo', box(8, 8, 8, true))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } });
+ arm.mate('servo-fix', 'base.support', 'servo.mount', 'fastened');
+ arm.mate('curl', 'base.axis', 'finger.axis', 'revolute', { limitsDeg: [0, 30] });
+ arm.mechanicalJoint('curl-drive', {
+ mate: 'curl',
+ actuator: 'servo',
+ shaft: 'base',
+ supports: ['base'],
+ output: 'finger',
+ });
+ arm.physicalUseCase('touch-target', {
+ stableParts: ['base'],
+ loads: [{ part: 'finger', force: [0, 0, -1] }],
+ contacts: [{ a: 'finger.tip', b: 'base.target', normal: [1, 0, 0], friction: 0.5, normalForceN: 2 }],
+ actuatorLimits: [{ mate: 'curl', maxTorqueNmm: 500 }],
+ criteria: { maxSlipMm: 2 },
+ });
+ return arm.model();
+ `,
+ });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ const unreachable = result.diagnostics.find((diagnostic) =>
+ diagnostic.code === 'assembly.physical-use-case.contact-unreachable'
+ );
+ expect(unreachable).toMatchObject({
+ contactA: 'finger.tip',
+ contactB: 'base.target',
+ toleranceMm: 2,
+ });
+ }
+});
+```
+
+- [x] **Step 2: Run test and verify red**
+
+Run:
+
+```bash
+npx vitest run tests/integration/mcp/physicalUseCaseGate.test.ts -t "targeted actuator sampling cannot reach" --reporter=dot
+```
+
+Expected: FAIL because `includePhysicalUseCaseReachability` is not wired and no targeted reachability diagnostic is emitted.
+
+- [x] **Step 3: Implement targeted sampler**
+
+Create `src/modeling/mates/physicalUseCaseReachability.ts`:
+
+```ts
+import type { Assembly } from '../capture/assembly';
+import type { PhysicalUseCaseContact, PhysicalUseCaseRecord } from './physicalUseCase';
+import { parseConnectorRef } from './mate';
+
+export interface PhysicalUseCaseReachabilityIssue {
+ readonly useCaseName: string;
+ readonly contact: PhysicalUseCaseContact;
+ readonly minDistanceMm?: number;
+ readonly toleranceMm: number;
+}
+
+export async function reviewPhysicalUseCaseReachability(
+ arm: Assembly,
+ useCase: PhysicalUseCaseRecord,
+ opts: { samplesPerMate?: number } = {},
+): Promise {
+ const samplesPerMate = opts.samplesPerMate ?? 3;
+ const samples = buildTargetedPoseSamples(arm, useCase, samplesPerMate);
+ const best = new Map();
+
+ for (const poses of samples) {
+ const solved = await arm.solve(poses);
+ for (const contact of useCase.contacts) {
+ const a = connectorWorldPoint(arm, solved.transforms, contact.a);
+ const b = connectorWorldPoint(arm, solved.transforms, contact.b);
+ if (a === undefined || b === undefined) continue;
+ const distance = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
+ const key = `${contact.a}\t${contact.b}`;
+ best.set(key, Math.min(best.get(key) ?? Infinity, distance));
+ }
+ }
+
+ return useCase.contacts.flatMap((contact) => {
+ const toleranceMm = useCase.criteria?.maxSlipMm ?? 0;
+ const key = `${contact.a}\t${contact.b}`;
+ const minDistanceMm = best.get(key);
+ if (minDistanceMm !== undefined && minDistanceMm <= toleranceMm) return [];
+ return [{ useCaseName: useCase.name, contact, minDistanceMm, toleranceMm }];
+ });
+}
+
+function buildTargetedPoseSamples(
+ arm: Assembly,
+ useCase: PhysicalUseCaseRecord,
+ samplesPerMate: number,
+): Array> {
+ const matesByName = new Map(arm.__mates().map((mate) => [mate.name, mate]));
+ const actuatorMates = useCase.actuatorLimits
+ .map((limit) => matesByName.get(limit.mate))
+ .filter((mate): mate is NonNullable => mate !== undefined && mate.type === 'revolute');
+
+ if (actuatorMates.length === 0) return [{}];
+ const samples: Array> = [{}];
+ for (const mate of actuatorMates) {
+ const [min, max] = mate.limitsDeg ?? [0, 0];
+ const values = samplesPerMate <= 1
+ ? [min]
+ : Array.from({ length: samplesPerMate }, (_, i) => min + ((max - min) * i) / (samplesPerMate - 1));
+ const next: Array> = [];
+ for (const base of samples) {
+ for (const value of values) next.push({ ...base, [mate.name]: value });
+ }
+ samples.splice(0, samples.length, ...next.slice(0, 64));
+ }
+ return samples;
+}
+
+function connectorWorldPoint(
+ arm: Assembly,
+ transforms: ReadonlyMap,
+ ref: string,
+): [number, number, number] | undefined {
+ const parsed = parseConnectorRef(ref);
+ const part = arm.__parts().find((candidate) => candidate.name === parsed.partName);
+ const connector = part?.mateConnectors.find((candidate) => candidate.name === parsed.connectorName);
+ if (connector === undefined || connector.origin.kind !== 'vec3') return undefined;
+ return transforms.get(parsed.partName)?.point(connector.origin.value);
+}
+```
+
+If `arm.solve(...)` returns a different property name than `transforms`, inspect `src/modeling/capture/assembly.ts` and adjust the one accessor only. Do not change the sampler API.
+
+- [x] **Step 4: Wire diagnostics into physical use-case review**
+
+Change `reviewPhysicalUseCases(...)` to async only if current callers can handle it. If not, add a new async wrapper:
+
+```ts
+export async function reviewPhysicalUseCasesWithReachability(
+ arm: Assembly,
+ opts: { requirePhysicalUseCase?: boolean; includeReachability?: boolean; samplesPerMate?: number } = {},
+): Promise {
+ const base = reviewPhysicalUseCases(arm, opts);
+ if (opts.includeReachability !== true) return base;
+ const diagnostics = [...base.diagnostics];
+ for (const useCase of arm.__physicalUseCases()) {
+ const issues = await reviewPhysicalUseCaseReachability(arm, useCase, { samplesPerMate: opts.samplesPerMate });
+ for (const issue of issues) {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.contact-unreachable',
+ severity: 'error',
+ useCaseName: issue.useCaseName,
+ contactA: issue.contact.a,
+ contactB: issue.contact.b,
+ ...(issue.minDistanceMm === undefined ? {} : { minDistanceMm: issue.minDistanceMm }),
+ toleranceMm: issue.toleranceMm,
+ message: issue.minDistanceMm === undefined
+ ? `Physical use case '${issue.useCaseName}' contact '${issue.contact.a}' to '${issue.contact.b}' could not be checked in targeted actuator samples.`
+ : `Physical use case '${issue.useCaseName}' contact '${issue.contact.a}' to '${issue.contact.b}' never gets within ${issue.toleranceMm.toFixed(2)} mm; closest targeted sample is ${issue.minDistanceMm.toFixed(2)} mm.`,
+ hint: `physical-use-case.contact-unreachable — revise the target, connector placement, or actuator mate limits so '${issue.contact.a}' reaches '${issue.contact.b}' within maxSlipMm ${issue.toleranceMm.toFixed(2)}.`,
+ });
+ }
+ }
+ return { ...base, diagnostics };
+}
+```
+
+- [x] **Step 5: Wire review_cad option**
+
+In `ReviewCadInput`, add:
+
+```ts
+includePhysicalUseCaseReachability?: boolean;
+physicalUseCaseReachabilitySamplesPerMate?: number;
+```
+
+In `reviewCadTool`, replace the physical use-case review call with:
+
+```ts
+const physicalUseCases = await reviewPhysicalUseCasesWithReachability(arm, {
+ requirePhysicalUseCase: input.requirePhysicalUseCase,
+ poseEnvelope,
+ includeReachability: input.includePhysicalUseCaseReachability ?? input.requirePhysicalUseCase === true,
+ samplesPerMate: input.physicalUseCaseReachabilitySamplesPerMate,
+});
+```
+
+- [x] **Step 6: Update registry docs**
+
+In `src/agent/mcp/toolRegistry.ts`, add schema entries:
+
+```ts
+includePhysicalUseCaseReachability: {
+ type: 'boolean',
+ description: 'When true, declared physicalUseCase contacts are checked by sampling only actuatorLimits mates, faster than full pose envelope.',
+},
+physicalUseCaseReachabilitySamplesPerMate: {
+ type: 'number',
+ description: 'Samples per actuator mate for targeted physicalUseCase contact reachability. Default 3.',
+},
+```
+
+- [x] **Step 7: Run focused tests**
+
+Run:
+
+```bash
+npx vitest run tests/integration/mcp/physicalUseCaseGate.test.ts -t "targeted actuator sampling cannot reach" --reporter=dot
+```
+
+Expected: PASS.
+
+- [x] **Step 8: Run physical use-case suite**
+
+Run:
+
+```bash
+npx vitest run tests/integration/mcp/physicalUseCaseGate.test.ts --reporter=dot
+```
+
+Expected: PASS.
+
+- [x] **Step 9: Commit**
+
+```bash
+git add src/modeling/mates/physicalUseCaseReachability.ts src/modeling/mates/physicalUseCase.ts src/agent/mcp/tools/reviewCad.ts src/agent/mcp/toolRegistry.ts tests/integration/mcp/physicalUseCaseGate.test.ts
+git commit -m "feat: check physical use case contact reachability"
+```
+
+---
+
+### Task 4: Supported Servo Revolute Helper
+
+**Files:**
+- Create: `src/modeling/joints/supportedServoRevolute.ts`
+- Create: `src/modeling/joints/supportedServoRevolute.test.ts`
+- Modify: `src/modeling/joints/index.ts`
+- Modify: `src/modeling/api.ts`
+- Modify: `src/agent/mcp/tools/listApi.ts`
+
+- [x] **Step 1: Write failing API test**
+
+Create `src/modeling/joints/supportedServoRevolute.test.ts`:
+
+```ts
+import { describe, expect, it } from 'vitest';
+import { CaptureSession } from '../capture/captureSession';
+import { createApi } from '../api';
+
+describe('joint.supportedServoRevolute', () => {
+ it('creates seated actuator geometry and mechanicalJoint intent for a revolute mate', () => {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ const arm = kcad.assembly('supported drive');
+
+ arm.part('base', kcad.box(30, 30, 8, true))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 4] }, axis: [0, 0, 1] })
+ .connector('servo-mount', { type: 'frame', origin: { kind: 'vec3', value: [0, -15, 4] } });
+ arm.part('link', kcad.box(40, 6, 6, true).translate(20, 0, 0))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.mate('curl', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [0, 45] });
+
+ const drive = kcad.joint.supportedServoRevolute(arm, {
+ name: 'curl-drive',
+ mate: 'curl',
+ support: 'base',
+ supportMount: 'base.servo-mount',
+ output: 'link',
+ axis: 'base.axis',
+ });
+
+ expect(drive.actuatorPartName).toBe('curl-drive-servo');
+ expect(arm.__mechanicalJointIntents()).toEqual([
+ expect.objectContaining({
+ name: 'curl-drive',
+ mate: 'curl',
+ actuator: 'curl-drive-servo',
+ shaft: 'base',
+ supports: ['base'],
+ output: 'link',
+ }),
+ ]);
+ expect(arm.__mates().some((mate) => mate.name === 'curl-drive-servo-fix' && mate.type === 'fastened')).toBe(true);
+ });
+});
+```
+
+- [x] **Step 2: Run test and verify red**
+
+Run:
+
+```bash
+npx vitest run src/modeling/joints/supportedServoRevolute.test.ts --reporter=dot
+```
+
+Expected: FAIL because `joint.supportedServoRevolute` does not exist.
+
+- [x] **Step 3: Implement helper**
+
+Create `src/modeling/joints/supportedServoRevolute.ts`:
+
+```ts
+import type { Assembly } from '../capture/assembly';
+import type { Shape } from '../capture/proxy';
+import type { KernelCadApi } from '../api';
+
+export interface SupportedServoRevoluteOptions {
+ readonly name: string;
+ readonly mate: string;
+ readonly support: string;
+ readonly supportMount: string;
+ readonly output: string;
+ readonly axis: string;
+ readonly servoBody?: Shape;
+ readonly minBearingLengthMm?: number;
+}
+
+export interface SupportedServoRevoluteResult {
+ readonly actuatorPartName: string;
+}
+
+export function supportedServoRevolute(
+ kc: KernelCadApi,
+ arm: Assembly,
+ opts: SupportedServoRevoluteOptions,
+): SupportedServoRevoluteResult {
+ const actuatorPartName = `${opts.name}-servo`;
+ const servoBody = opts.servoBody
+ ?? kc.box(20, 13, 16, true)
+ .union(kc.cylinder(5, 4.5, 24).alongAxis([1, 0, 0]).translate(0, -7, 0));
+
+ arm.part(actuatorPartName, servoBody)
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } });
+ arm.mate(`${opts.name}-servo-fix`, opts.supportMount, `${actuatorPartName}.mount`, 'fastened');
+ arm.mechanicalJoint(opts.name, {
+ mate: opts.mate,
+ actuator: actuatorPartName,
+ shaft: opts.support,
+ supports: [opts.support],
+ output: opts.output,
+ requiredSupport: {
+ kind: 'hinge-bracket',
+ around: opts.axis,
+ supports: [opts.support],
+ minBearingLengthMm: opts.minBearingLengthMm ?? 8,
+ },
+ });
+
+ return { actuatorPartName };
+}
+```
+
+- [x] **Step 4: Expose helper**
+
+In `src/modeling/joints/index.ts`, export the helper type/function.
+
+In `src/modeling/api.ts`, add method under `joint` namespace:
+
+```ts
+supportedServoRevolute: (arm, opts) => supportedServoRevolute(api, arm, opts),
+```
+
+Adjust exact names to match the existing `makeJointNamespace` pattern.
+
+- [x] **Step 5: Update API listing**
+
+In `src/agent/mcp/tools/listApi.ts`, extend the `joint` API description with:
+
+```ts
+`joint.supportedServoRevolute(assembly, { name, mate, support, supportMount, output, axis, servoBody?, minBearingLengthMm? })` creates a seated servo part, fastened mount, and mechanicalJoint support contract for a driven revolute mate.
+```
+
+- [x] **Step 6: Run helper test**
+
+Run:
+
+```bash
+npx vitest run src/modeling/joints/supportedServoRevolute.test.ts --reporter=dot
+```
+
+Expected: PASS.
+
+- [x] **Step 7: Commit**
+
+```bash
+git add src/modeling/joints/supportedServoRevolute.ts src/modeling/joints/supportedServoRevolute.test.ts src/modeling/joints/index.ts src/modeling/api.ts src/agent/mcp/tools/listApi.ts
+git commit -m "feat: add supported servo revolute helper"
+```
+
+---
+
+### Task 5: Design-Loop Physical Acceptance Report
+
+**Files:**
+- Modify: `src/agent/mcp/tools/designLoop.ts`
+- Modify: `tests/integration/mcp/designLoop.test.ts`
+- Modify: `tests/unit/mcp/designLoopNextActionPrompt.test.ts`
+
+- [x] **Step 1: Write failing design-loop test**
+
+In `tests/integration/mcp/designLoop.test.ts`, add:
+
+```ts
+it('rejects visual acceptance when physical use case contacts are unreachable', async () => {
+ const result = await designLoopTool({
+ goal: 'Reject a visually reviewed hand if the declared grasp contact cannot be reached.',
+ requireVisualReview: true,
+ includePoseEnvelope: false,
+ includeInterference: false,
+ attempts: [{
+ id: 'bad-grasp',
+ title: 'Visually accepted but physically unreachable',
+ code: `
+ const arm = assembly('bad grasp');
+ arm.part('base', box(30, 30, 8, true))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 4] }, axis: [0, 0, 1] })
+ .connector('target', { type: 'frame', origin: { kind: 'vec3', value: [120, 0, 4] } })
+ .connector('support', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 4] } });
+ arm.part('finger', box(40, 6, 6, true).translate(20, 0, 0))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [40, 0, 0] } });
+ arm.part('servo', box(8, 8, 8, true)).connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } });
+ arm.mate('servo-fix', 'base.support', 'servo.mount', 'fastened');
+ arm.mate('curl', 'base.axis', 'finger.axis', 'revolute', { limitsDeg: [0, 30] });
+ arm.mechanicalJoint('curl-drive', { mate: 'curl', actuator: 'servo', shaft: 'base', supports: ['base'], output: 'finger' });
+ arm.physicalUseCase('touch-target', {
+ stableParts: ['base'],
+ loads: [{ part: 'finger', force: [0, 0, -1] }],
+ contacts: [{ a: 'finger.tip', b: 'base.target', normal: [1, 0, 0], friction: 0.5, normalForceN: 2 }],
+ actuatorLimits: [{ mate: 'curl', maxTorqueNmm: 500 }],
+ criteria: { maxSlipMm: 2 },
+ });
+ return arm.model();
+ `,
+ visualReview: {
+ accepted: true,
+ screenshotPath: '/tmp/bad-grasp.png',
+ findings: ['The fixture is visible in the screenshot.'],
+ checks: requiredPassingVisualChecks('Visible fixture with connector labels and seated servo mount.'),
+ },
+ }],
+ });
+
+ expect(result.attempts[0].accepted).toBe(false);
+ expect(result.attempts[0].reviewFacts.some((fact) =>
+ fact.code === 'assembly.physical-use-case.contact-unreachable'
+ )).toBe(true);
+});
+```
+
+Use the existing helper pattern for `requiredPassingVisualChecks(...)` in that test file. If the helper is named differently, reuse the local helper that creates complete visual-review checks.
+
+- [x] **Step 2: Run test and verify red**
+
+Run:
+
+```bash
+npx vitest run tests/integration/mcp/designLoop.test.ts -t "physically unreachable" --reporter=dot
+```
+
+Expected: FAIL because design_loop is not forwarding targeted physical-use-case reachability.
+
+- [x] **Step 3: Forward reachability through design_loop**
+
+In `src/agent/mcp/tools/designLoop.ts`, where `reviewCadTool` is called, add:
+
+```ts
+includePhysicalUseCaseReachability: true,
+requirePhysicalUseCase: true,
+```
+
+only when the attempt contains `physicalUseCase(` or when the tool input explicitly asks for physical acceptance. If adding an input flag is cleaner, add:
+
+```ts
+requirePhysicalAcceptance?: boolean;
+```
+
+Default it to `true` for mechanism/robot-hand goals only if current design_loop conventions already infer goal-dependent gates. Otherwise default false and set true in this test.
+
+- [x] **Step 4: Make next-action prompt explicit**
+
+In `src/agent/mcp/tools/designLoop.ts`, ensure contact-unreachable facts are included in `nextActionPrompt` by preserving them in `reviewFacts`.
+
+In `tests/unit/mcp/designLoopNextActionPrompt.test.ts`, add:
+
+```ts
+expect(prompt).toContain('assembly.physical-use-case.contact-unreachable');
+expect(prompt).toContain('closest');
+expect(prompt).toContain('maxSlipMm');
+```
+
+- [x] **Step 5: Run design-loop tests**
+
+Run:
+
+```bash
+npx vitest run tests/integration/mcp/designLoop.test.ts tests/unit/mcp/designLoopNextActionPrompt.test.ts --reporter=dot
+```
+
+Expected: PASS.
+
+- [x] **Step 6: Commit**
+
+```bash
+git add src/agent/mcp/tools/designLoop.ts tests/integration/mcp/designLoop.test.ts tests/unit/mcp/designLoopNextActionPrompt.test.ts
+git commit -m "feat: reject unreachable physical use cases in design loop"
+```
+
+---
+
+### Task 6: Apply Tooling To Robot Hand Without Geometry Changes
+
+**Files:**
+- Modify: `tests/integration/examples/fiveFingerKinematicHand.test.ts`
+- Modify: `artifacts/robot-hand-design-loop/2026-07-09-five-finger-hand-loop.json`
+
+- [x] **Step 1: Add hand reachability assertion**
+
+In `tests/integration/examples/fiveFingerKinematicHand.test.ts`, add a test that runs `reviewCadTool` with:
+
+```ts
+const result = await reviewCadTool({
+ file: EXAMPLE_PATH,
+ includePoseEnvelope: false,
+ includeInterference: false,
+ requirePhysicalUseCase: true,
+ includePhysicalUseCaseReachability: true,
+ physicalUseCaseReachabilitySamplesPerMate: 3,
+});
+```
+
+Expected for current hand:
+
+```ts
+expect(result.ok).toBe(false);
+expect(result.diagnostics.some((diagnostic) =>
+ diagnostic.code === 'assembly.physical-use-case.contact-unreachable'
+)).toBe(true);
+```
+
+This locks the current hand as rejected for a concrete, tool-generated reason.
+
+Implementation note: the final test evaluates the same fixture and calls `reviewPhysicalUseCasesWithReachability(...)` directly because the full `reviewCadTool` path was too slow/hung for this fixture. The spec and quality review accepted this as satisfying the task intent.
+
+- [x] **Step 2: Run test and verify expected failure/pass**
+
+Run:
+
+```bash
+npx vitest run tests/integration/examples/fiveFingerKinematicHand.test.ts -t "physical use case reachability" --reporter=dot
+```
+
+Expected: PASS because the hand is intentionally rejected by the new gate.
+
+- [x] **Step 3: Update design-loop artifact**
+
+Append an attempt entry:
+
+```json
+{
+ "id": "07",
+ "title": "Tooling gate: targeted grasp reachability",
+ "status": "rejected-by-physical-use-case-contact-reachability",
+ "deterministicChecks": [
+ "npx vitest run tests/integration/examples/fiveFingerKinematicHand.test.ts -t \"physical use case reachability\" --reporter=dot"
+ ],
+ "remainingCaveats": [
+ "The model remains visually inspectable but is rejected because declared contacts do not reach the cylinder within maxSlipMm.",
+ "Next geometry slice must redesign thumb/index/middle placement or actuator travel using this diagnostic, not visual guesswork."
+ ]
+}
+```
+
+Use valid JSON and include the actual screenshot path from the latest browser run if one was captured during execution.
+
+- [x] **Step 4: Validate JSON**
+
+Run:
+
+```bash
+node -e "JSON.parse(require('fs').readFileSync('artifacts/robot-hand-design-loop/2026-07-09-five-finger-hand-loop.json','utf8')); console.log('json ok')"
+```
+
+Expected: `json ok`.
+
+- [x] **Step 5: Commit**
+
+```bash
+git add tests/integration/examples/fiveFingerKinematicHand.test.ts artifacts/robot-hand-design-loop/2026-07-09-five-finger-hand-loop.json
+git commit -m "test: reject hand by targeted grasp reachability"
+```
+
+---
+
+## Final Verification
+
+- [x] Run fast unit coverage:
+
+```bash
+npx vitest run tests/unit/runtime/interferenceClassification.test.ts src/modeling/joints/supportedServoRevolute.test.ts src/studio/__tests__/StudioShell.status.test.tsx src/studio/components/Layout/StatusBar.test.tsx --reporter=dot
+```
+
+Expected: all pass.
+
+- [x] Run physical-use-case and hand integration coverage:
+
+```bash
+npx vitest run tests/integration/mcp/physicalUseCaseGate.test.ts tests/integration/examples/fiveFingerKinematicHand.test.ts --reporter=dot
+```
+
+Expected: all pass; the hand-specific reachability test passes by asserting rejection.
+
+- [x] Run design-loop coverage:
+
+```bash
+npx vitest run tests/integration/mcp/designLoop.test.ts tests/unit/mcp/designLoopNextActionPrompt.test.ts --reporter=dot
+```
+
+Expected: all pass.
+
+- [x] Run typecheck:
+
+```bash
+npm run typecheck
+```
+
+Expected: `tsc -b --noEmit` exits 0. Existing TanStack route warnings may appear and are not part of this plan.
+
+- [x] Browser evidence:
+
+```bash
+npm run dev -- --host 127.0.0.1 --port 5173
+```
+
+Open:
+
+```text
+http://127.0.0.1:/studio?script=examples/robot-hand/five-finger-kinematic-hand.kcad.ts
+```
+
+Expected:
+- Status footer shows actionable interference count, not raw contact-noise count.
+- Validity/design-loop output reports `assembly.physical-use-case.contact-unreachable` for the current hand until geometry is redesigned.
+
+---
+
+## Self-Review
+
+- Spec coverage: interference classification, visible reporting, targeted reachability, mechanism-drive helper, and design-loop acceptance are all mapped to tasks.
+- Placeholder scan: no placeholder markers, no open-ended “add tests” steps, and every behavior change has an explicit test command.
+- Type consistency: `InterferenceSummary`, `ClassifiedInterferencePair`, `includePhysicalUseCaseReachability`, and `supportedServoRevolute` are introduced once and reused with the same names.
+- Scope discipline: this plan explicitly avoids hand geometry changes until tooling can produce a concrete reachability rejection.
diff --git a/docs/superpowers/plans/2026-07-10-five-finger-hand-topology-repair.md b/docs/superpowers/plans/2026-07-10-five-finger-hand-topology-repair.md
new file mode 100644
index 000000000..e6c6e510a
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-10-five-finger-hand-topology-repair.md
@@ -0,0 +1,473 @@
+# Five-Finger Hand Topology Repair Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make the current five-finger hand pass `reviewJointTopology(...)` by adding real passive joint-support evidence and treating the grasp cylinder as a contact target rather than hand structure.
+
+**Architecture:** Add an assembly-level passive `jointSupport(...)` intent that reuses the support-side fastened-path rule already used by driven `mechanicalJoint(...)`. Add a narrow part role for contact targets so topology ignores load-path checks on objects that are explicitly not part of the hand structure. Apply both to the existing hand without visual redesign.
+
+**Tech Stack:** TypeScript, Vitest, KernelCAD Assembly capture API, existing mate topology reviewer and five-finger hand example.
+
+---
+
+## File Structure
+
+- Modify `src/modeling/capture/assembly.ts`
+ - Add `JointSupportIntentOpts`, `JointSupportIntentRecord`, `AssemblyPartRole`, `AssemblyPartOpts.role`, stored part role, `jointSupport(...)`, and `__jointSupportIntents()`.
+- Modify `tests/unit/assemblies/assemblyCapture.test.ts`
+ - Add capture/validation tests for passive joint support and contact-target role storage.
+- Modify `src/modeling/mates/jointTopology.ts`
+ - Accept either driven `mechanicalJoint(...)` or passive `jointSupport(...)` as revolute support evidence.
+ - Skip `assembly.connectivity.no-load-path` for physical-use-case load parts whose stored role is `contact-target`.
+- Modify `src/modeling/mates/jointTopology.test.ts`
+ - Add passive-supported hinge pass/fail tests.
+ - Add contact-target load-path skip test.
+- Modify `examples/robot-hand/five-finger-kinematic-hand.kcad.ts`
+ - Add passive support declarations for every unsupported revolute.
+ - Mark `grasp-cylinder` as `role: 'contact-target'`.
+- Modify `tests/integration/examples/fiveFingerKinematicHand.test.ts`
+ - Change the topology regression from expected blockers to expected empty diagnostics.
+
+---
+
+### Task 1: Capture Passive Joint Support And Contact Target Role
+
+**Files:**
+- Modify: `src/modeling/capture/assembly.ts`
+- Modify: `tests/unit/assemblies/assemblyCapture.test.ts`
+
+- [x] **Step 1: Write failing capture tests**
+
+Add tests near the existing `mechanicalJoint(...)` capture tests:
+
+```ts
+it('captures passive joint support intent records', () => {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ const arm = kcad.assembly('passive support');
+
+ const returned = arm.jointSupport('pip-bearing', {
+ mate: 'index-pip',
+ shaft: 'index-proximal',
+ supports: ['index-proximal'],
+ output: 'index-middle',
+ requiredSupport: {
+ kind: 'hinge-bracket',
+ around: 'index-proximal.pip',
+ supports: ['index-proximal'],
+ minBearingLengthMm: 6,
+ },
+ });
+
+ expect(returned).toBe(arm);
+ expect(arm.__jointSupportIntents()).toEqual([
+ {
+ name: 'pip-bearing',
+ mate: 'index-pip',
+ shaft: 'index-proximal',
+ supports: ['index-proximal'],
+ output: 'index-middle',
+ requiredSupport: {
+ kind: 'hinge-bracket',
+ around: 'index-proximal.pip',
+ supports: ['index-proximal'],
+ minBearingLengthMm: 6,
+ },
+ },
+ ]);
+});
+
+it('stores contact target part roles', () => {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ const arm = kcad.assembly('contact target');
+
+ arm.part('grasp-cylinder', kcad.cylinder(20, 10), { role: 'contact-target' });
+
+ expect(arm.__parts().find((part) => part.name === 'grasp-cylinder')?.role).toBe('contact-target');
+});
+```
+
+- [x] **Step 2: Verify red**
+
+Run:
+
+```bash
+npx vitest run tests/unit/assemblies/assemblyCapture.test.ts -t "passive joint support|contact target" --reporter=dot
+```
+
+Expected: FAIL because `jointSupport`, `__jointSupportIntents`, and `role` do not exist.
+
+- [x] **Step 3: Implement capture API**
+
+In `src/modeling/capture/assembly.ts`:
+
+```ts
+export type AssemblyPartRole = 'structure' | 'contact-target';
+
+export interface JointSupportIntentOpts {
+ readonly mate: string;
+ readonly shaft: string;
+ readonly supports: readonly string[];
+ readonly output: string;
+ readonly requiredSupport?: MechanicalJointSupportRequirement;
+}
+
+export interface JointSupportIntentRecord extends JointSupportIntentOpts {
+ readonly name: string;
+}
+```
+
+Add `role?: AssemblyPartRole` to `AssemblyPartOpts` and `AssemblyPartStored`. Store it in `part(...)` only when defined.
+
+Add a private array beside `mechanicalJointIntents`:
+
+```ts
+private readonly jointSupportIntents: JointSupportIntentRecord[] = [];
+```
+
+Add `jointSupport(name, opts)` mirroring `mechanicalJoint(...)` validation, without `actuator`:
+
+```ts
+jointSupport(name: string, opts: JointSupportIntentOpts): this {
+ validateMechanicalIntentName('name', name);
+ if (this.jointSupportIntents.some((intent) => intent.name === name)) {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `assembly.jointSupport.duplicate-name: joint support intent '${name}' is already declared.`,
+ undefined,
+ `invalid-args.assembly.joint-support-duplicate-name — use a unique jointSupport name.`,
+ );
+ }
+ validateMechanicalIntentName('mate', opts.mate);
+ validateMechanicalIntentName('shaft', opts.shaft);
+ validateMechanicalIntentName('output', opts.output);
+ if (!Array.isArray(opts.supports) || opts.supports.length === 0) {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `assembly.jointSupport.invalid-ref: joint support intent '${name}' requires at least one support part.`,
+ undefined,
+ `invalid-args.assembly.joint-support-invalid-ref — pass supports: ['support-part-name', ...].`,
+ );
+ }
+ for (const support of opts.supports) {
+ validateMechanicalIntentName('supports[]', support);
+ }
+ if (opts.requiredSupport !== undefined) {
+ validateMechanicalIntentName('requiredSupport.kind', opts.requiredSupport.kind);
+ validateMechanicalIntentName('requiredSupport.around', opts.requiredSupport.around);
+ for (const support of opts.requiredSupport.supports ?? []) {
+ validateMechanicalIntentName('requiredSupport.supports[]', support);
+ }
+ if (
+ opts.requiredSupport.minBearingLengthMm !== undefined &&
+ (!Number.isFinite(opts.requiredSupport.minBearingLengthMm) || opts.requiredSupport.minBearingLengthMm <= 0)
+ ) {
+ throw new KernelError('feature.invalid-args', `assembly.jointSupport.invalid-required-support: minBearingLengthMm must be a positive finite number.`);
+ }
+ if (
+ opts.requiredSupport.clearanceMm !== undefined &&
+ (!Number.isFinite(opts.requiredSupport.clearanceMm) || opts.requiredSupport.clearanceMm < 0)
+ ) {
+ throw new KernelError('feature.invalid-args', `assembly.jointSupport.invalid-required-support: clearanceMm must be a non-negative finite number.`);
+ }
+ }
+
+ this.jointSupportIntents.push({
+ name,
+ mate: opts.mate,
+ shaft: opts.shaft,
+ supports: [...opts.supports],
+ output: opts.output,
+ ...(opts.requiredSupport !== undefined ? {
+ requiredSupport: {
+ ...opts.requiredSupport,
+ ...(opts.requiredSupport.supports !== undefined ? { supports: [...opts.requiredSupport.supports] } : {}),
+ },
+ } : {}),
+ });
+ return this;
+}
+```
+
+Add:
+
+```ts
+__jointSupportIntents(): readonly JointSupportIntentRecord[] {
+ return this.jointSupportIntents;
+}
+```
+
+- [x] **Step 4: Verify green**
+
+Run:
+
+```bash
+npx vitest run tests/unit/assemblies/assemblyCapture.test.ts -t "passive joint support|contact target" --reporter=dot
+```
+
+Expected: PASS.
+
+- [x] **Step 5: Commit**
+
+```bash
+git add src/modeling/capture/assembly.ts tests/unit/assemblies/assemblyCapture.test.ts
+git commit -m "feat: capture passive joint support"
+```
+
+---
+
+### Task 2: Teach Topology Gate Passive Support And Contact Targets
+
+**Files:**
+- Modify: `src/modeling/mates/jointTopology.ts`
+- Modify: `src/modeling/mates/jointTopology.test.ts`
+
+- [x] **Step 1: Write failing topology tests**
+
+Add tests to `src/modeling/mates/jointTopology.test.ts`:
+
+```ts
+it('accepts passive support intents for supported revolute hinges', () => {
+ const arm = armLike({
+ parts: [
+ { name: 'proximal', role: 'structure', mateConnectors: [{ name: 'pip', type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [1, 0, 0] }] },
+ { name: 'middle', role: 'structure', mateConnectors: [{ name: 'pip', type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [1, 0, 0] }] },
+ ],
+ mates: [{ name: 'pip', a: 'proximal.pip', b: 'middle.pip', type: 'revolute', limitsDeg: [0, 40] }],
+ jointSupportIntents: [{ mate: 'pip', shaft: 'proximal', supports: ['proximal'], output: 'middle' }],
+ });
+
+ expect(codesOf(arm)).not.toContain('assembly.joint-topology.unsupported-axis');
+});
+
+it('rejects passive support intents disconnected from the hinge support side', () => {
+ const arm = armLike({
+ parts: [
+ { name: 'proximal', role: 'structure', mateConnectors: [{ name: 'pip', type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [1, 0, 0] }] },
+ { name: 'middle', role: 'structure', mateConnectors: [{ name: 'pip', type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [1, 0, 0] }] },
+ { name: 'fake-shaft', role: 'structure', mateConnectors: [] },
+ { name: 'fake-support', role: 'structure', mateConnectors: [] },
+ ],
+ mates: [{ name: 'pip', a: 'proximal.pip', b: 'middle.pip', type: 'revolute', limitsDeg: [0, 40] }],
+ jointSupportIntents: [{ mate: 'pip', shaft: 'fake-shaft', supports: ['fake-support'], output: 'middle' }],
+ });
+
+ expect(codesOf(arm)).toContain('assembly.joint-topology.unsupported-axis');
+});
+
+it('does not require load paths for contact target load parts', () => {
+ const arm = armLike({
+ parts: [
+ { name: 'palm-root', role: 'structure', mateConnectors: [] },
+ { name: 'grasp-cylinder', role: 'contact-target', mateConnectors: [] },
+ ],
+ physicalUseCases: [{
+ name: 'grasp',
+ stableParts: ['palm-root'],
+ loads: [{ part: 'grasp-cylinder', force: [0, 0, -3] }],
+ }],
+ });
+
+ expect(codesOf(arm)).not.toContain('assembly.connectivity.no-load-path');
+});
+```
+
+Update `armLike(...)` to include:
+
+```ts
+jointSupportIntents?: unknown[];
+__jointSupportIntents: () => overrides.jointSupportIntents ?? [],
+```
+
+- [x] **Step 2: Verify red**
+
+Run:
+
+```bash
+npx vitest run src/modeling/mates/jointTopology.test.ts --reporter=dot
+```
+
+Expected: FAIL because passive support intents are ignored and contact-target role is not skipped.
+
+- [x] **Step 3: Implement topology support**
+
+In `jointTopology.ts`, import `JointSupportIntentRecord`.
+
+Generalize `isCompleteMechanicalIntent(...)` into a shared support check for records shaped like:
+
+```ts
+interface JointSupportLikeIntent {
+ readonly mate: string;
+ readonly shaft: string;
+ readonly supports: readonly string[];
+ readonly output: string;
+}
+```
+
+Then `collectSupportedRevoluteMates(...)` must iterate both:
+
+```ts
+for (const intent of arm.__mechanicalJointIntents()) {
+ if (!isCompleteDrivenMechanicalIntent(intent, matesByName, partsByName, fastenedGraph)) continue;
+ supported.add(intent.mate);
+}
+
+for (const intent of arm.__jointSupportIntents()) {
+ if (!isCompleteJointSupportIntent(intent, matesByName, partsByName, fastenedGraph)) continue;
+ supported.add(intent.mate);
+}
+```
+
+Use the same support-side fastened reachability for both; only driven `mechanicalJoint(...)` also checks `actuator`.
+
+In the physical-use-case load-path loop, skip contact targets:
+
+```ts
+const loadPart = partsByName.get(load.part);
+if (loadPart === undefined) continue;
+if (loadPart.role === 'contact-target') continue;
+```
+
+- [x] **Step 4: Verify green**
+
+Run:
+
+```bash
+npx vitest run src/modeling/mates/jointTopology.test.ts --reporter=dot
+```
+
+Expected: PASS.
+
+- [x] **Step 5: Commit**
+
+```bash
+git add src/modeling/mates/jointTopology.ts src/modeling/mates/jointTopology.test.ts
+git commit -m "feat: review passive joint support"
+```
+
+---
+
+### Task 3: Repair Five-Finger Hand Topology
+
+**Files:**
+- Modify: `examples/robot-hand/five-finger-kinematic-hand.kcad.ts`
+- Modify: `tests/integration/examples/fiveFingerKinematicHand.test.ts`
+
+- [x] **Step 1: Write failing example expectation**
+
+Change the topology regression to require no topology diagnostics:
+
+```ts
+const topologyReview = reviewJointTopology(assembly);
+
+expect(topologyReview.diagnostics).toEqual([]);
+```
+
+- [x] **Step 2: Verify red**
+
+Run:
+
+```bash
+npx vitest run tests/integration/examples/fiveFingerKinematicHand.test.ts -t "topology" --reporter=dot
+```
+
+Expected: FAIL with current unsupported-axis and grasp-cylinder no-load-path diagnostics.
+
+- [x] **Step 3: Add passive supports and contact target role**
+
+In `examples/robot-hand/five-finger-kinematic-hand.kcad.ts`, add a helper inside `assemblyTasks.push(...)` after mates are declared:
+
+```ts
+function supportRevolute(mate, shaft, output, around) {
+ hand.jointSupport(`${mate}-support`, {
+ mate,
+ shaft,
+ supports: [shaft],
+ output,
+ requiredSupport: {
+ kind: 'hinge-bracket',
+ around,
+ supports: [shaft],
+ minBearingLengthMm: 6,
+ },
+ });
+}
+```
+
+For every finger, call:
+
+```ts
+if (!supportedGraspMcpNames.includes(spec.name)) {
+ supportRevolute(`${spec.name}-mcp`, 'palm-root', `${spec.name}-proximal`, `palm-root.${spec.name}Mcp`);
+}
+supportRevolute(`${spec.name}-pip`, `${spec.name}-proximal`, `${spec.name}-middle`, `${spec.name}-proximal.pip`);
+supportRevolute(`${spec.name}-dip`, `${spec.name}-middle`, `${spec.name}-distal`, `${spec.name}-middle.dip`);
+```
+
+Mark the object:
+
+```ts
+const graspCylinder = hand.part('grasp-cylinder', ..., { role: 'contact-target' });
+```
+
+- [x] **Step 4: Verify green**
+
+Run:
+
+```bash
+npx vitest run tests/integration/examples/fiveFingerKinematicHand.test.ts -t "topology" --reporter=dot
+```
+
+Expected: PASS.
+
+- [x] **Step 5: Run full hand integration**
+
+Run:
+
+```bash
+npx vitest run tests/integration/examples/fiveFingerKinematicHand.test.ts --reporter=dot
+```
+
+Expected: PASS.
+
+- [x] **Step 6: Commit**
+
+```bash
+git add examples/robot-hand/five-finger-kinematic-hand.kcad.ts tests/integration/examples/fiveFingerKinematicHand.test.ts
+git commit -m "fix: satisfy hand topology gate"
+```
+
+---
+
+## Final Verification
+
+- [x] Capture tests:
+
+```bash
+npx vitest run tests/unit/assemblies/assemblyCapture.test.ts -t "passive joint support|contact target" --reporter=dot
+```
+
+- [x] Topology unit tests:
+
+```bash
+npx vitest run src/modeling/mates/jointTopology.test.ts --reporter=dot
+```
+
+- [x] Review/design-loop integration:
+
+```bash
+npx vitest run tests/integration/mcp/physicalUseCaseGate.test.ts tests/integration/mcp/designLoop.test.ts tests/unit/mcp/designLoopNextActionPrompt.test.ts --reporter=dot
+```
+
+- [x] Hand integration:
+
+```bash
+npx vitest run tests/integration/examples/fiveFingerKinematicHand.test.ts --reporter=dot
+```
+
+- [x] Typecheck:
+
+```bash
+npm run typecheck
+```
diff --git a/docs/superpowers/plans/2026-07-10-five-finger-topology-gate.md b/docs/superpowers/plans/2026-07-10-five-finger-topology-gate.md
new file mode 100644
index 000000000..0411523e7
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-10-five-finger-topology-gate.md
@@ -0,0 +1,203 @@
+# Five-Finger Topology Gate Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a deterministic topology/connectivity gate that rejects robot hands with disconnected moving links, unsupported revolute axes, missing limits, or invalid connector contracts before any visual iteration is accepted.
+
+**Architecture:** Add a pure `reviewJointTopology(assembly)` reviewer under `src/modeling/mates/`, wire its diagnostics into `review_cad`, then preserve them through `design_loop`. The gate uses existing assembly surfaces: `__parts()`, `__mates()`, `__mechanicalJointIntents()`, and `__physicalUseCases()`.
+
+**Tech Stack:** TypeScript, Vitest, KernelCAD Assembly/Mate APIs, existing `review_cad` and `design_loop`.
+
+---
+
+## File Structure
+
+- Create `src/modeling/mates/jointTopology.ts`
+ - Pure deterministic reviewer for part graph connectivity and non-fastened mate contracts.
+- Create `src/modeling/mates/jointTopology.test.ts`
+ - Fast unit tests using small in-memory assemblies.
+- Modify `src/agent/mcp/tools/reviewCad.ts`
+ - Include topology diagnostics in review diagnostics and fitness summary inputs.
+- Modify `src/modeling/mates/mechanismFitness.ts`
+ - Accept topology diagnostics as blocking reasons.
+- Modify `tests/integration/mcp/physicalUseCaseGate.test.ts`
+ - Add `review_cad` integration for topology failures and passing clean fixture.
+- Modify `tests/integration/mcp/designLoop.test.ts`
+ - Add design-loop prompt preservation for topology diagnostics.
+- Modify `tests/integration/examples/fiveFingerKinematicHand.test.ts`
+ - Add regression proving current five-finger hand fails topology/connectivity before geometry redesign is accepted.
+
+---
+
+### Task 1: Pure Topology Reviewer
+
+**Files:**
+- Create: `src/modeling/mates/jointTopology.ts`
+- Create: `src/modeling/mates/jointTopology.test.ts`
+
+- [x] **Step 1: Write failing tests**
+
+Create tests for:
+
+- `assembly.connectivity.floating-moving-part` when a moving link is isolated from physical-use-case stable parts.
+- `assembly.joint-topology.missing-limit` when a revolute mate has no `limitsDeg`.
+- `assembly.joint-topology.unsupported-axis` when a revolute mate has no mechanical support intent.
+- clean supported hinge passes with stable root, finite limits, and `mechanicalJoint(...)`.
+
+- [x] **Step 2: Verify red**
+
+Run:
+
+```bash
+npx vitest run src/modeling/mates/jointTopology.test.ts --reporter=dot
+```
+
+Expected: FAIL because `jointTopology.ts` does not exist.
+
+- [x] **Step 3: Implement reviewer**
+
+Create `reviewJointTopology(arm)` with:
+
+- graph nodes from `arm.__parts()`;
+- graph edges from every mate whose refs parse and whose parts exist;
+- stable roots from `arm.__physicalUseCases().flatMap(useCase => useCase.stableParts)`, plus explicit `palm-root`, `palm`, `base`, `root` fallback when present;
+- moving parts from all non-fastened mate endpoints;
+- non-fastened mate contract checks:
+ - connector exists;
+ - connector origin must be numeric `vec3`;
+ - revolute/cylindrical/pin_slot connector axes must be finite non-zero vectors;
+ - revolute/cylindrical/pin_slot require finite `limitsDeg`;
+ - prismatic requires finite `limitsMm`;
+ - revolute requires a `mechanicalJoint` intent whose `mate` equals the mate name.
+
+- [x] **Step 4: Verify green**
+
+Run:
+
+```bash
+npx vitest run src/modeling/mates/jointTopology.test.ts --reporter=dot
+```
+
+Expected: PASS.
+
+- [x] **Step 5: Commit**
+
+```bash
+git add src/modeling/mates/jointTopology.ts src/modeling/mates/jointTopology.test.ts
+git commit -m "feat: review joint topology"
+```
+
+---
+
+### Task 2: Wire Topology Into Review And Design Loop
+
+**Files:**
+- Modify: `src/agent/mcp/tools/reviewCad.ts`
+- Modify: `src/modeling/mates/mechanismFitness.ts`
+- Modify: `tests/integration/mcp/physicalUseCaseGate.test.ts`
+- Modify: `tests/integration/mcp/designLoop.test.ts`
+
+- [x] **Step 1: Write integration tests**
+
+Add tests proving:
+
+- `review_cad` returns `assembly.joint-topology.unsupported-axis` for a revolute mate with finite limits but no support intent.
+- `review_cad` returns `assembly.connectivity.floating-moving-part` for an articulated load part with no stable-root path.
+- `design_loop` includes topology diagnostics in `reviewFacts` and `nextActionPrompt`.
+
+- [x] **Step 2: Verify red**
+
+Run:
+
+```bash
+npx vitest run tests/integration/mcp/physicalUseCaseGate.test.ts tests/integration/mcp/designLoop.test.ts --reporter=dot
+```
+
+Expected: FAIL on missing topology diagnostics in review output.
+
+- [x] **Step 3: Wire reviewer**
+
+- Import `reviewJointTopology` and its diagnostic type in `reviewCad.ts`.
+- Add topology diagnostics to the review diagnostic list before physical-use-case reachability diagnostics.
+- Include topology diagnostics in `summarizeMechanismFitness(...)`.
+- Extend `MechanismFitnessResult` inputs to treat topology diagnostics as blocking reasons.
+- Ensure repair prompt generation includes topology diagnostics through the existing diagnostic list path.
+
+- [x] **Step 4: Verify green**
+
+Run:
+
+```bash
+npx vitest run tests/integration/mcp/physicalUseCaseGate.test.ts tests/integration/mcp/designLoop.test.ts tests/unit/mcp/designLoopNextActionPrompt.test.ts --reporter=dot
+```
+
+Expected: PASS.
+
+- [x] **Step 5: Commit**
+
+```bash
+git add src/agent/mcp/tools/reviewCad.ts src/modeling/mates/mechanismFitness.ts tests/integration/mcp/physicalUseCaseGate.test.ts tests/integration/mcp/designLoop.test.ts
+git commit -m "feat: gate reviews on joint topology"
+```
+
+---
+
+### Task 3: Five-Finger Hand Regression
+
+**Files:**
+- Modify: `tests/integration/examples/fiveFingerKinematicHand.test.ts`
+
+- [x] **Step 1: Write current-hand regression**
+
+Add a test that evaluates `examples/robot-hand/five-finger-kinematic-hand.kcad.ts`, extracts `front-facing-five-finger-robot-hand`, runs `reviewJointTopology(assembly)`, and asserts at least one blocking topology diagnostic is present.
+
+- [x] **Step 2: Verify red or diagnostic behavior**
+
+Run:
+
+```bash
+npx vitest run tests/integration/examples/fiveFingerKinematicHand.test.ts -t "topology" --reporter=dot
+```
+
+Expected: PASS if current hand has topology failures; FAIL if the current hand is topologically clean, in which case add a `review_cad` assertion that the hand still fails reachability and record that topology is not the current first blocker.
+
+- [x] **Step 3: Tighten only the gate, not geometry**
+
+Do not edit `examples/robot-hand/five-finger-kinematic-hand.kcad.ts` in this task. If the current hand passes topology, leave it passing topology and preserve reachability as the current blocker.
+
+- [x] **Step 4: Commit**
+
+```bash
+git add tests/integration/examples/fiveFingerKinematicHand.test.ts
+git commit -m "test: check hand topology gate"
+```
+
+---
+
+## Final Verification
+
+- [ ] Run topology unit tests:
+
+```bash
+npx vitest run src/modeling/mates/jointTopology.test.ts --reporter=dot
+```
+
+- [ ] Run review/design-loop integration:
+
+```bash
+npx vitest run tests/integration/mcp/physicalUseCaseGate.test.ts tests/integration/mcp/designLoop.test.ts tests/unit/mcp/designLoopNextActionPrompt.test.ts --reporter=dot
+```
+
+- [ ] Run hand integration:
+
+```bash
+npx vitest run tests/integration/examples/fiveFingerKinematicHand.test.ts --reporter=dot
+```
+
+- [ ] Run typecheck:
+
+```bash
+npm run typecheck
+```
+
+- [ ] Run final review.
diff --git a/docs/superpowers/plans/2026-07-11-joint-reaction-and-clevis-structure.md b/docs/superpowers/plans/2026-07-11-joint-reaction-and-clevis-structure.md
new file mode 100644
index 000000000..86bdd15d3
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-11-joint-reaction-and-clevis-structure.md
@@ -0,0 +1,607 @@
+# Joint Reaction and Clevis Structure Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
+
+**Goal:** Add pose-bound joint reaction, declared envelope, and geometry/material-derived clevis strength certificates to the physical-use-case gate.
+
+**Architecture:** Enrich the existing static contact certificate, derive reaction wrenches through uniquely rooted articulated trees, then evaluate those reactions in two separate layers: declared resultant envelopes and a narrow `joint.clevis` closed-form structural model. `review_cad` and `design_loop` orchestrate the layers and keep ambiguous or unsupported physics blocking.
+
+**Tech Stack:** TypeScript, Vitest, existing Assembly/mate solver, kernelCAD MCP tool schemas.
+
+---
+
+## File Map
+
+- `src/modeling/mates/physicalUseCaseStatics.ts`: make contact force point, sign, and mechanism ownership explicit.
+- `src/modeling/mates/physicalUseCaseJointReactions.ts`: exact-pose topology validation and subtree wrench propagation.
+- `src/modeling/mates/physicalUseCaseJointReactions.test.ts`: hand-calculated reaction solver tests.
+- `src/modeling/mates/mate.ts`: public capacity types and legacy adapter type.
+- `src/modeling/capture/assembly.ts`: capture-time capacity validation and preservation.
+- `src/modeling/mates/physicalUseCaseJointCapacity.ts`: declared envelope comparison.
+- `src/modeling/mates/physicalUseCaseJointCapacity.test.ts`: capacity capture, conversion, and threshold tests.
+- `src/modeling/joints/types.ts`: structural material/model types and clevis engineering options.
+- `src/modeling/joints/clevis.ts`: emit structural dimensions from resolved build geometry.
+- `src/modeling/joints/index.ts`: export new public structural types.
+- `src/modeling/mates/clevisJointStructure.ts`: pure closed-form clevis checks.
+- `src/modeling/mates/clevisJointStructure.test.ts`: equation, safety-factor, and unsupported-load tests.
+- `src/modeling/mates/physicalUseCase.ts`: orchestrate reaction/capacity/structure reviews and map diagnostics.
+- `src/agent/mcp/tools/reviewCad.ts`: request and return new certificates.
+- `src/agent/mcp/tools/designLoop.ts`: enable both checks for physical-use-case attempts.
+- `src/agent/mcp/toolRegistry.ts`: publish new input fields.
+- `src/agent/mcp/toolOutputSchemas.ts`: publish new output evidence.
+- Existing physical-use-case, MCP, bar-grasp, and five-finger tests: integration/regression coverage.
+
+### Task 1: Exact-Pose Joint Reaction Certificate
+
+**Files:**
+- Modify: `src/modeling/mates/physicalUseCaseStatics.ts`
+- Create: `src/modeling/mates/physicalUseCaseJointReactions.ts`
+- Create: `src/modeling/mates/physicalUseCaseJointReactions.test.ts`
+- Modify: `src/modeling/mates/physicalUseCaseStatics.test.ts`
+
+- [x] **Step 1: Write failing tests for explicit static contact evidence**
+
+Update the passing static-certificate assertion to require the actual world
+contact point, mechanism part, and unambiguous held-object force:
+
+```ts
+expect(certificate.contactForces[0]).toMatchObject({
+ pointWorldMm: [50, 0, 0],
+ mechanismPart: 'finger',
+ forceOnHeldWorldN: expect.any(Array),
+});
+```
+
+Replace every existing test read of `contact.force` with
+`contact.forceOnHeldWorldN` so the ambiguous field can be removed.
+
+- [x] **Step 2: Run the static test and verify RED**
+
+Run:
+
+```bash
+npx vitest run src/modeling/mates/physicalUseCaseStatics.test.ts
+```
+
+Expected: FAIL because `pointWorldMm`, `mechanismPart`, and
+`forceOnHeldWorldN` do not exist.
+
+- [x] **Step 3: Add explicit contact fields to static evidence**
+
+Change the public evidence type and `evaluateCandidate()` construction:
+
+```ts
+export interface PhysicalUseCaseStaticContactForce {
+ readonly contactA: string;
+ readonly contactB: string;
+ readonly pointWorldMm: Vec3;
+ readonly mechanismPart: string;
+ readonly forceOnHeldWorldN: Vec3;
+ readonly normalForceN: number;
+ readonly tangentialForceN: number;
+ readonly normalCapacityN: number;
+ readonly friction: number;
+}
+```
+
+Use `safePartName(contact.mechanismRef)` and copy `contact.point`/the solved
+held force. Update internal consumers to read `forceOnHeldWorldN`; do not keep
+an ambiguous public `force` alias after test migration.
+
+- [x] **Step 4: Run the static test and verify GREEN**
+
+Run the command from Step 2. Expected: all existing statics tests pass with the
+new explicit evidence.
+
+- [x] **Step 5: Write failing reaction tests**
+
+Create fixtures around the public reaction review entry point:
+
+```ts
+const result = await reviewPhysicalUseCaseJointReactions(
+ arm,
+ useCase,
+ staticCertificate,
+);
+expect(result.issues).toEqual([]);
+expect(result.certificates[0].reactions).toEqual(expect.arrayContaining([
+ expect.objectContaining({ mateName: 'distal', resultantMomentNmm: 500 }),
+ expect.objectContaining({ mateName: 'proximal', resultantMomentNmm: 1500 }),
+]));
+```
+
+Add separate tests proving exact-pose moment-arm changes, branch-force vector
+cancellation, force negation exactly once, fastened-group collapse, rejection
+of an articulated loop, and rejection of two stable roots.
+
+- [x] **Step 6: Run the reaction test and verify RED**
+
+Run:
+
+```bash
+npx vitest run src/modeling/mates/physicalUseCaseJointReactions.test.ts
+```
+
+Expected: FAIL because the module and review function do not exist.
+
+- [x] **Step 7: Implement the minimal reaction solver**
+
+Export these contracts:
+
+```ts
+export interface PhysicalUseCaseJointReactionEvidence {
+ readonly mateName: string;
+ readonly parentPart: string;
+ readonly childPart: string;
+ readonly pointWorldMm: Vec3;
+ readonly axisWorld: Vec3;
+ readonly forceWorldN: Vec3;
+ readonly momentWorldNmm: Vec3;
+ readonly resultantForceN: number;
+ readonly resultantMomentNmm: number;
+ readonly axialForceN: number;
+ readonly radialForceN: number;
+ readonly axisMomentNmm: number;
+ readonly bendingMomentNmm: number;
+}
+
+export type PhysicalUseCaseJointReactionIssue =
+ | { readonly kind: 'joint-reaction-input-incomplete'; readonly useCaseName: string; readonly message: string }
+ | { readonly kind: 'joint-reaction-indeterminate'; readonly useCaseName: string; readonly message: string };
+
+export interface PhysicalUseCaseJointReactionCertificate {
+ readonly useCaseName: string;
+ readonly poses: NumericPoses;
+ readonly reactions: readonly PhysicalUseCaseJointReactionEvidence[];
+}
+```
+
+Use union-find to collapse fastened mates, reject any loaded articulated
+component that is not a single-root tree, orient it from the stable group, and
+accumulate subtree wrenches bottom-up. Resolve joint origins and axes from the
+exact solved transforms. Apply `forceOnMechanism = -forceOnHeldWorldN` once.
+
+- [x] **Step 8: Run reaction and statics tests and verify GREEN**
+
+Run:
+
+```bash
+npx vitest run src/modeling/mates/physicalUseCaseJointReactions.test.ts src/modeling/mates/physicalUseCaseStatics.test.ts
+```
+
+Expected: both files pass.
+
+### Task 2: Unit-Bearing Mate Envelope
+
+**Files:**
+- Modify: `src/modeling/mates/mate.ts`
+- Modify: `src/modeling/capture/assembly.ts`
+- Create: `src/modeling/mates/physicalUseCaseJointCapacity.ts`
+- Create: `src/modeling/mates/physicalUseCaseJointCapacity.test.ts`
+- Modify: `src/modeling/mates/jointLoadCapacity.ts`
+
+- [x] **Step 1: Write failing public capture tests**
+
+Add tests that author and inspect:
+
+```ts
+arm.mate('hinge', 'base.axis', 'link.axis', 'revolute', {
+ capacity: {
+ envelope: {
+ maxResultantForceN: 120,
+ maxResultantMomentNmm: 800,
+ },
+ },
+});
+expect(arm.__mates()[0].capacity?.envelope).toEqual({
+ maxResultantForceN: 120,
+ maxResultantMomentNmm: 800,
+});
+```
+
+Also assert that zero, negative, NaN, or infinite limits throw; supplying both
+`capacity` and `maxLoad` throws; and legacy `{ force: 120, torque: 0.8 }`
+normalizes to 120 N and 800 Nmm for the new review.
+
+- [x] **Step 2: Run the capacity test and verify RED**
+
+Run:
+
+```bash
+npx vitest run src/modeling/mates/physicalUseCaseJointCapacity.test.ts
+```
+
+Expected: FAIL because `capacity` is not accepted or preserved.
+
+- [x] **Step 3: Add capture types and validation**
+
+Add:
+
+```ts
+export interface MateCapacityEnvelope {
+ readonly maxResultantForceN: number;
+ readonly maxResultantMomentNmm: number;
+}
+
+export interface MateCapacity {
+ readonly envelope?: MateCapacityEnvelope;
+}
+```
+
+Extend `MateRecord` and `Assembly.mate()` options with `capacity` and the legacy
+`maxLoad`. Validate positive finite envelope fields, reject `capacity` plus
+`maxLoad`, and copy nested objects so later caller mutation cannot alter
+captured evidence. Keep the old `maxLoad` record only for compatibility with
+the old external-load adapter. Task 3 extends `MateCapacity` with structural
+evidence after the clevis type exists.
+
+- [x] **Step 4: Write failing envelope comparison tests**
+
+Use hand-built `PhysicalUseCaseJointReactionEvidence` and assert:
+
+```ts
+expect(reviewJointReactionCapacity(mate, reaction)).toMatchObject({ status: 'pass' });
+expect(reviewJointReactionCapacity(overloadedMate, reaction)).toMatchObject({
+ status: 'exceeded',
+ forceExceeded: true,
+});
+expect(reviewJointReactionCapacity(mateWithoutCapacity, reaction)).toMatchObject({
+ status: 'undeclared',
+});
+```
+
+Cover exact threshold equality and one-time legacy Nm-to-Nmm conversion.
+
+- [x] **Step 5: Run the comparison test and verify RED**
+
+Run the command from Step 2. Expected: capture tests pass after Step 3 but the
+comparison tests fail because the function is missing.
+
+- [x] **Step 6: Implement the pure envelope comparison**
+
+Return unit-bearing evidence:
+
+```ts
+export interface JointReactionCapacityEvidence {
+ readonly mateName: string;
+ readonly status: 'pass' | 'exceeded' | 'undeclared';
+ readonly resultantForceN: number;
+ readonly resultantMomentNmm: number;
+ readonly maxResultantForceN?: number;
+ readonly maxResultantMomentNmm?: number;
+ readonly forceExceeded: boolean;
+ readonly momentExceeded: boolean;
+}
+```
+
+Normalize the legacy adapter in one helper. A partial legacy declaration is
+`undeclared`; it must never synthesize an infinite counterpart. Mark the old
+manual external-load checker as deprecated in its JSDoc and remove any message
+that says a load was exceeded when no comparison ran.
+
+- [x] **Step 7: Run capacity tests and verify GREEN**
+
+Run:
+
+```bash
+npx vitest run src/modeling/mates/physicalUseCaseJointCapacity.test.ts src/modeling/mates/jointLoadCapacity.test.ts
+```
+
+Expected: both files pass.
+
+### Task 3: Geometry-Derived Clevis Strength
+
+**Files:**
+- Modify: `src/modeling/joints/types.ts`
+- Modify: `src/modeling/joints/clevis.ts`
+- Modify: `src/modeling/joints/index.ts`
+- Modify: `src/modeling/joints/clevis.test.ts`
+- Modify: `src/modeling/mates/mate.ts`
+- Modify: `src/modeling/capture/assembly.ts`
+- Create: `src/modeling/mates/clevisJointStructure.ts`
+- Create: `src/modeling/mates/clevisJointStructure.test.ts`
+
+- [x] **Step 1: Write failing clevis descriptor tests**
+
+Build a clevis with explicit style and engineering material:
+
+```ts
+const steel = {
+ name: 'test steel',
+ model: 'isotropic-ductile' as const,
+ yieldStrengthMPa: 250,
+ bearingStrengthMPa: 400,
+};
+const result = joint.clevis({
+ ...bodies,
+ axis: 'Y',
+ pivotParent: [0, 0, 0],
+ style: { pinR: 3, holeClearance: 0.2, plateT: 4, tongueY: 5, forkGapY: 6, knuckleR: 10 },
+ engineering: { pin: steel, fork: steel, tongue: steel },
+});
+expect(result.structural).toMatchObject({
+ kind: 'clevis-double-shear-v1',
+ pinDiameterMm: 6,
+ boreDiameterMm: 6.4,
+ forkPlateThicknessMm: 4,
+ tongueThicknessMm: 5,
+ forkGapMm: 6,
+ supportSpanMm: 10,
+ edgeDistanceMm: 10,
+});
+```
+
+Assert that invalid material strengths throw at construction and that omitting
+`engineering` still emits geometry with no materials.
+
+- [x] **Step 2: Run clevis tests and verify RED**
+
+Run:
+
+```bash
+npx vitest run src/modeling/joints/clevis.test.ts
+```
+
+Expected: FAIL because engineering options and `structural` output do not
+exist.
+
+- [x] **Step 3: Add structural public types and clevis emission**
+
+Add the exact `StructuralMaterial` and `ClevisStructuralModel` contracts from
+the design spec, plus `engineering?: { pin; fork; tongue }` on
+`ClevisJointOptions` and `structural` on `ClevisJoint`. Validate every declared
+strength as positive finite. Build all structural dimensions only from the
+resolved `style` used by geometry.
+
+Extend `MateCapacity` with
+`readonly structure?: ClevisStructuralModel`, preserve a defensive copy in
+`Assembly.mate()`, and reject structural evidence on non-revolute mates.
+
+- [x] **Step 4: Run clevis tests and verify GREEN**
+
+Run the command from Step 2. Expected: all clevis tests pass.
+
+- [x] **Step 5: Write failing pure equation tests**
+
+Create a radial-load fixture and independently calculate expected values:
+
+```ts
+const result = reviewClevisJointStructure({
+ reaction,
+ model,
+ minSafetyFactor: 2,
+});
+expect(result.status).toBe('pass');
+expect(result.checks.pinDoubleShear.stressMPa).toBeCloseTo(
+ radialForceN / (2 * Math.PI * pinDiameterMm ** 2 / 4),
+ 10,
+);
+expect(result.checks.pinBending.stressMPa).toBeCloseTo(
+ 32 * (radialForceN * supportSpanMm / 4) / (Math.PI * pinDiameterMm ** 3),
+ 10,
+);
+```
+
+Add tests where only pin diameter flips pass to failed, only material strength
+flips pass to failed, invalid ligament is input-incomplete, missing materials
+is input-incomplete, axial force is unsupported, and perpendicular moment is
+unsupported. Verify the implicit shear allowable records the
+`yield/sqrt(3)` assumption.
+
+- [x] **Step 6: Run equation tests and verify RED**
+
+Run:
+
+```bash
+npx vitest run src/modeling/mates/clevisJointStructure.test.ts
+```
+
+Expected: FAIL because the structural review module does not exist.
+
+- [x] **Step 7: Implement the pure structural review**
+
+Implement the equations and statuses exactly as specified:
+
+```ts
+export type ClevisJointStructureStatus =
+ | 'pass'
+ | 'failed'
+ | 'input-incomplete'
+ | 'unsupported-load-case';
+
+export interface ClevisJointStructureReview {
+ readonly mateName: string;
+ readonly status: ClevisJointStructureStatus;
+ readonly minSafetyFactor: number;
+ readonly checks: Readonly>;
+ readonly assumptions: readonly string[];
+ readonly message?: string;
+}
+```
+
+Use N/mm2 = MPa directly. Reject `minSafetyFactor < 2`, non-positive geometry,
+axial force above 0.01 N, and perpendicular bending moment above 0.1 Nmm. Do
+not consume PBR material, density, or generic BREP measurements.
+
+- [x] **Step 8: Run clevis and equation tests and verify GREEN**
+
+Run:
+
+```bash
+npx vitest run src/modeling/joints/clevis.test.ts src/modeling/mates/clevisJointStructure.test.ts
+```
+
+Expected: both files pass.
+
+### Task 4: Physical Gate and MCP Integration
+
+**Files:**
+- Modify: `src/modeling/mates/physicalUseCase.ts`
+- Modify: `src/modeling/mates/physicalUseCase.test.ts`
+- Modify: `src/agent/mcp/tools/reviewCad.ts`
+- Modify: `src/agent/mcp/tools/designLoop.ts`
+- Modify: `src/agent/mcp/toolRegistry.ts`
+- Modify: `src/agent/mcp/toolOutputSchemas.ts`
+- Modify: `tests/integration/mcp/physicalUseCaseGate.test.ts`
+- Modify: `tests/integration/mcp/designLoop.test.ts`
+- Modify: `tests/unit/mcp/reviewCadOutputSchema.test.ts`
+- Modify: `tests/integration/examples/functionFirstBarGraspSkeleton.test.ts`
+- Modify: `tests/integration/examples/fiveFingerKinematicHand.test.ts`
+
+- [x] **Step 1: Write failing orchestration tests**
+
+Add an in-memory complete clevis mechanism and request structure review:
+
+```ts
+const result = await reviewPhysicalUseCasesWithReachability(arm, {
+ includeReachability: true,
+ includeStatics: true,
+ includeJointReactions: true,
+ includeJointStructure: true,
+});
+expect(result.jointReactionCertificates).toHaveLength(1);
+expect(result.jointStructuralCertificates[0].joints[0]).toMatchObject({
+ envelope: { status: 'pass' },
+ structure: { status: 'pass' },
+});
+```
+
+Add diagnostic assertions for indeterminate topology, undeclared envelope,
+envelope exceeded, missing structural model/materials, unsupported axial or
+perpendicular moment, and insufficient safety factor. Verify
+`minJointSafetyFactor` defaults to 2 and values below 2 are rejected.
+
+- [x] **Step 2: Run physical-use-case tests and verify RED**
+
+Run:
+
+```bash
+npx vitest run src/modeling/mates/physicalUseCase.test.ts
+```
+
+Expected: FAIL because options, outputs, and diagnostics do not exist.
+
+- [x] **Step 3: Implement physical-use-case orchestration**
+
+Extend `PhysicalUseCaseCriteria` with `minJointSafetyFactor`, validating it as
+finite and at least 2. Extend options/results with:
+
+```ts
+readonly includeJointReactions?: boolean;
+readonly includeJointStructure?: boolean;
+readonly jointReactionCertificates: readonly PhysicalUseCaseJointReactionCertificate[];
+readonly jointStructuralCertificates: readonly PhysicalUseCaseJointStructuralCertificate[];
+```
+
+Define the aggregate certificate explicitly:
+
+```ts
+export interface PhysicalUseCaseJointStructuralCertificate {
+ readonly useCaseName: string;
+ readonly poses: NumericPoses;
+ readonly joints: readonly {
+ readonly mateName: string;
+ readonly envelope: JointReactionCapacityEvidence;
+ readonly structure?: ClevisJointStructureReview;
+ }[];
+}
+```
+
+`includeJointStructure` implies reactions; reactions imply statics. For every
+passing static certificate, run reactions, then envelope and structure reviews.
+Map every issue/status to the exact diagnostic codes in the design spec. Only
+emit a structural certificate when the review actually ran; preserve failed
+evidence alongside its blocking diagnostic.
+
+- [x] **Step 4: Run physical-use-case tests and verify GREEN**
+
+Run the command from Step 2. Expected: all tests pass.
+
+- [x] **Step 5: Write failing MCP/tool integration tests**
+
+Assert `review_cad` accepts:
+
+```ts
+{
+ includePhysicalUseCaseJointReactions: true,
+ includePhysicalUseCaseJointStructure: true,
+}
+```
+
+and returns `physicalUseCaseJointReactionCertificates` and
+`physicalUseCaseJointStructuralCertificates`. Assert that `design_loop`
+forwards both flags automatically for source containing `physicalUseCase(`.
+Update schema tests to require arrays with unit-bearing reaction and stress
+properties.
+
+- [x] **Step 6: Run MCP tests and verify RED**
+
+Run:
+
+```bash
+npx vitest run tests/integration/mcp/physicalUseCaseGate.test.ts tests/integration/mcp/designLoop.test.ts tests/unit/mcp/reviewCadOutputSchema.test.ts
+```
+
+Expected: FAIL because the registry, tools, and output schemas lack the fields.
+
+- [x] **Step 7: Wire review_cad, design_loop, registry, and schemas**
+
+Add the two booleans to the review input and registry. Forward them to the
+physical-use-case review, include both certificate arrays in success and
+failure output, and describe that structure implies reaction/statics. In
+`designLoop`, set both true beside existing reachability/statics flags whenever
+the attempt declares a physical use case. Extend JSON output schemas without
+loosening existing required fields.
+
+- [x] **Step 8: Make example expectations honest**
+
+Keep the direct bar test green only when structure review is not requested.
+Add a structure-enabled assertion requiring one of:
+
+```ts
+expect(codes).toContain(
+ 'assembly.physical-use-case.joint-structure-input-incomplete',
+);
+expect(codes).toContain(
+ 'assembly.physical-use-case.joint-structure-unsupported-load-case',
+);
+```
+
+Keep the five-finger test's reachability rejection unchanged; do not add a
+structural pass assertion for it.
+
+- [x] **Step 9: Run all focused tests**
+
+Run:
+
+```bash
+npx vitest run src/modeling/mates/physicalUseCaseStatics.test.ts src/modeling/mates/physicalUseCaseJointReactions.test.ts src/modeling/mates/physicalUseCaseJointCapacity.test.ts src/modeling/joints/clevis.test.ts src/modeling/mates/clevisJointStructure.test.ts src/modeling/mates/physicalUseCase.test.ts tests/integration/mcp/physicalUseCaseGate.test.ts tests/integration/mcp/designLoop.test.ts tests/unit/mcp/reviewCadOutputSchema.test.ts tests/integration/examples/functionFirstBarGraspSkeleton.test.ts tests/integration/examples/fiveFingerKinematicHand.test.ts
+```
+
+Expected: all focused tests pass; the bar and five-finger rejection assertions
+remain explicit.
+
+- [x] **Step 10: Run static verification**
+
+Run:
+
+```bash
+npm run typecheck
+npm run lint
+```
+
+Expected: both commands exit zero. Existing generated TanStack route warnings
+may be reported by typecheck but are not failures.
+
+- [x] **Step 11: Inspect the final diff**
+
+Run:
+
+```bash
+git diff --check
+git status --short
+```
+
+Expected: no whitespace errors and only the intended current-session files
+plus the pre-existing dirty hand/tooling files. Do not revert or commit
+unrelated existing changes.
diff --git a/docs/superpowers/plans/2026-07-11-pose-bound-static-equilibrium.md b/docs/superpowers/plans/2026-07-11-pose-bound-static-equilibrium.md
new file mode 100644
index 000000000..48256bf6a
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-11-pose-bound-static-equilibrium.md
@@ -0,0 +1,280 @@
+# Pose-Bound Static Equilibrium Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Certify that one sampled common-contact pose can balance the declared held-object wrench inside contact friction/capacity and actuator torque limits.
+
+**Architecture:** Refactor targeted reachability to retain reusable solved pose witnesses. A new statics module converts each witness into a conservative eight-edge friction-pyramid feasibility problem, searches contact forces with projected gradient, independently verifies candidate certificates, and computes actuator torque through finite-difference relative-contact Jacobians with coupling expansion.
+
+**Tech Stack:** TypeScript, Vitest, KernelCAD mate solver and SE(3) transforms, pure-TypeScript numerical helpers.
+
+---
+
+### Task 1: Add explicit statics evidence fields
+
+**Files:**
+- Modify: `src/modeling/mates/physicalUseCase.ts`
+- Modify: `src/modeling/mates/physicalUseCase.test.ts`
+
+- [ ] **Step 1: Write failing deep-copy and validation tests**
+
+Extend the record test with:
+
+```ts
+loads: [{ part: 'object', at: 'object.com', force: [0, 0, -4] }],
+contacts: [{
+ a: 'finger.tip',
+ b: 'object.contact',
+ normal: [0, -1, 0],
+ normalFrame: 'b',
+ friction: 0.6,
+ normalForceN: 8,
+}],
+criteria: {
+ maxSlipMm: 1,
+ maxForceResidualN: 0.01,
+ maxTorqueResidualNmm: 0.1,
+},
+```
+
+Add invalid-value cases for unsupported normal frames, non-positive residual tolerances, and attempts to loosen the numerical defaults.
+
+- [ ] **Step 2: Run RED**
+
+```bash
+npx vitest run src/modeling/mates/physicalUseCase.test.ts --reporter=dot
+```
+
+Expected: FAIL because the fields are not represented or validated.
+
+- [ ] **Step 3: Implement schema and copy behavior**
+
+Add optional `load.at`, `contact.normalFrame: 'world' | 'a' | 'b'`, and the two positive residual criteria. Omitted normal frames remain semantically world-space without materializing a new property. Residual criteria may tighten but never exceed the built-in defaults.
+
+- [ ] **Step 4: Run GREEN**
+
+Run the Step 2 command. Expected: PASS.
+
+---
+
+### Task 2: Retain shared solved pose witnesses
+
+**Files:**
+- Modify: `src/modeling/mates/physicalUseCaseReachability.ts`
+- Modify: `src/modeling/mates/physicalUseCaseReachability.test.ts`
+
+- [ ] **Step 1: Write a failing witness test**
+
+For the existing split-pose fixture, call a wished-for `assessPhysicalUseCaseReachability(...)`. Assert two solved witnesses with expanded poses and world endpoint distances while findings still contain the simultaneous-contact failure. For the valid common-pose fixture, assert `commonPoseSamples` contains the sample satisfying every contact.
+
+- [ ] **Step 2: Run RED**
+
+```bash
+npx vitest run src/modeling/mates/physicalUseCaseReachability.test.ts --reporter=dot
+```
+
+Expected: FAIL because the assessment API does not exist.
+
+- [ ] **Step 3: Implement collection and compatibility wrapper**
+
+Add:
+
+```ts
+export interface PhysicalUseCaseSolvedContact {
+ readonly contactA: string;
+ readonly contactB: string;
+ readonly pointA: Vec3;
+ readonly pointB: Vec3;
+ readonly distanceMm: number;
+}
+
+export interface PhysicalUseCasePoseWitness {
+ readonly poses: NumericPoses;
+ readonly transforms: ReadonlyMap;
+ readonly contacts: readonly PhysicalUseCaseSolvedContact[];
+ readonly complete: boolean;
+ readonly maxDistanceMm?: number;
+}
+```
+
+`assessPhysicalUseCaseReachability(...)` builds samples once, solves each once, computes findings, and returns complete in-tolerance samples as `commonPoseSamples`. Keep `reviewPhysicalUseCaseReachability(...)` as a wrapper returning only findings.
+
+- [ ] **Step 4: Run GREEN**
+
+Run the Step 2 command. Expected: PASS with existing reachability behavior unchanged.
+
+---
+
+### Task 3: Add held-object wrench feasibility
+
+**Files:**
+- Create: `src/modeling/mates/physicalUseCaseStatics.ts`
+- Create: `src/modeling/mates/physicalUseCaseStatics.test.ts`
+
+- [ ] **Step 1: Write failing statics tests**
+
+Use real assemblies and pose witnesses for:
+
+```text
+1. common pose + missing load.at -> static-input-incomplete
+2. force balance possible but contact offset leaves an unbalanced moment -> static-equilibrium-unmet
+3. two symmetric contacts balance a vertical held-object load -> certificate
+4. normalFrame on a rotated endpoint resolves to the expected world normal -> certificate
+```
+
+The symmetric fixture has one held part, contacts at x=-10/+10 mm, normal caps 8 N, friction 0.5, and a 6 N downward load at its center.
+
+- [ ] **Step 2: Run RED**
+
+```bash
+npx vitest run src/modeling/mates/physicalUseCaseStatics.test.ts --reporter=dot
+```
+
+Expected: FAIL because the module does not exist.
+
+- [ ] **Step 3: Implement input resolution**
+
+Require exactly one held load part, load application connectors on that part, one held endpoint per contact, numeric connector origins, finite transforms, `normalForceN`, supported normal frames, and finite positive tolerances. Unsupported evidence returns input-incomplete.
+
+- [ ] **Step 4: Implement friction search**
+
+Construct eight conservative generators per contact. Assemble six held-part wrench rows about the first load point. Minimize normalized residual while projecting each contact group onto:
+
+```text
+lambda[k] >= 0
+sum(lambda[k]) <= normalForceN
+```
+
+Use deterministic ordering, a bounded iteration count, and a step bounded by the matrix Frobenius norm.
+
+- [ ] **Step 5: Independently verify candidates**
+
+Reconstruct forces and verify the true circular friction inequality, normal cap, force residual, and moment residual. Only verified data becomes a `PhysicalUseCaseStaticCertificate`; otherwise emit static-equilibrium-unmet with best residual evidence.
+
+- [ ] **Step 6: Run GREEN**
+
+Run the Step 2 command. Expected: PASS.
+
+---
+
+### Task 4: Add finite-difference actuator torque constraints
+
+**Files:**
+- Modify: `src/modeling/mates/physicalUseCaseStatics.ts`
+- Modify: `src/modeling/mates/physicalUseCaseStatics.test.ts`
+
+- [ ] **Step 1: Write failing low/high torque tests**
+
+Create a one-axis finger contacting a held target at a 10 mm lever arm. The held-object equilibrium requires 1 N. Assert a 5 Nmm actuator limit fails with `static-actuator-torque-insufficient`, while 20 Nmm produces a certificate.
+
+- [ ] **Step 2: Run RED**
+
+Run the Task 3 test command. Expected: low-torque fixture incorrectly passes or lacks the actuator diagnostic.
+
+- [ ] **Step 3: Implement relative-contact Jacobians**
+
+For each revolute actuator:
+
+1. Perturb the source pose by `1e-4 rad` in degrees.
+2. Stay inside limits using central or inward one-sided differences.
+3. Re-expand couplings and solve.
+4. Differentiate mechanism-endpoint minus held-endpoint world displacement.
+5. Compute ideal generalized torque with `J^T f`.
+
+Missing limits, unsupported mates, failed perturbations, independent contact-path mates without actuator limits, coupled mates without transmission intent, or contradictory coupling/transmission ratios return input-incomplete.
+
+- [ ] **Step 4: Add actuator constraints and post-check**
+
+Run a contact-only search first. If it certifies equilibrium, rerun with squared actuator-limit violation penalties. Independently verify every absolute torque limit before issuing a certificate.
+
+- [ ] **Step 5: Run GREEN**
+
+Run the Task 3 test command. Expected: both low/high torque fixtures pass their assertions.
+
+---
+
+### Task 5: Wire diagnostics, review tooling, and design loop
+
+**Files:**
+- Modify: `src/modeling/mates/physicalUseCase.ts`
+- Modify: `src/agent/mcp/tools/reviewCad.ts`
+- Modify: `src/agent/mcp/toolRegistry.ts`
+- Modify: `src/agent/mcp/tools/designLoop.ts`
+- Modify: `tests/integration/mcp/physicalUseCaseGate.test.ts`
+- Modify: `tests/integration/mcp/designLoop.test.ts`
+
+- [ ] **Step 1: Write failing integration tests**
+
+Add input-incomplete, unbalanced-wrench, low-torque, and high-torque cases using `includePhysicalUseCaseStatics: true`. Assert diagnostics, fitness blockers, repair prompts, and certificate evidence.
+
+- [ ] **Step 2: Run RED**
+
+```bash
+npx vitest run tests/integration/mcp/physicalUseCaseGate.test.ts -t "static equilibrium|static actuator" --reporter=dot
+```
+
+Expected: FAIL because the option and diagnostics are not wired.
+
+- [ ] **Step 3: Extend physical-use-case review**
+
+Add typed diagnostics:
+
+```text
+assembly.physical-use-case.static-input-incomplete
+assembly.physical-use-case.static-equilibrium-unmet
+assembly.physical-use-case.static-actuator-torque-insufficient
+```
+
+When reachability has no findings, pass its exact common-pose samples to statics and return successful certificates.
+
+- [ ] **Step 4: Wire review_cad and registry**
+
+Add `includePhysicalUseCaseStatics?: boolean` and expose certificates on output. Document sampled, linearized, opt-in static certification.
+
+- [ ] **Step 5: Preserve failures in design_loop**
+
+Physical-acceptance attempts enable statics. Preserve all statics codes in review facts and repair prompts. Add a focused regression.
+
+- [ ] **Step 6: Run GREEN**
+
+Run Task 5 focused coverage and the full `designLoop.test.ts`. Expected: PASS.
+
+---
+
+### Task 6: Exercise the function-first hand and verify
+
+**Files:**
+- Modify: `examples/robot-hand/function-first-bar-grasp-skeleton.kcad.ts`
+- Modify: `tests/integration/examples/functionFirstBarGraspSkeleton.test.ts`
+- Test: `tests/integration/examples/fiveFingerKinematicHand.test.ts`
+
+- [ ] **Step 1: Add explicit bar load evidence**
+
+Add `target-bar.load-point` at the bar center, set the load's `at`, request statics in the regression, and assert a certificate for `bar-grasp`.
+
+- [ ] **Step 2: Run the bar regression**
+
+```bash
+npx vitest run tests/integration/examples/functionFirstBarGraspSkeleton.test.ts --reporter=dot
+```
+
+If it fails equilibrium, retain the failure and inspect the contact layout/forces. Do not change tolerances or capacities solely to obtain green.
+
+- [ ] **Step 3: Run hand regressions**
+
+```bash
+npx vitest run tests/integration/examples/functionFirstBarGraspSkeleton.test.ts tests/integration/examples/fiveFingerKinematicHand.test.ts --reporter=dot
+```
+
+Expected: the bar skeleton provides a certificate or exposes a concrete blocker; the old five-finger hand remains rejected.
+
+- [ ] **Step 4: Run focused and static verification**
+
+```bash
+npx vitest run src/modeling/mates/physicalUseCaseReachability.test.ts src/modeling/mates/physicalUseCaseStatics.test.ts src/modeling/mates/physicalUseCase.test.ts tests/integration/mcp/designLoop.test.ts --reporter=dot
+npm run typecheck
+npx eslint src/modeling/mates/physicalUseCaseReachability.ts src/modeling/mates/physicalUseCaseStatics.ts src/modeling/mates/physicalUseCaseStatics.test.ts src/modeling/mates/physicalUseCase.ts src/agent/mcp/tools/reviewCad.ts src/agent/mcp/tools/designLoop.ts src/agent/mcp/toolRegistry.ts
+git diff --check
+```
+
+Expected: all commands exit 0. Existing route-generator warnings may remain during typecheck, but no TypeScript errors are allowed.
diff --git a/docs/superpowers/plans/2026-07-11-simultaneous-grasp-reachability.md b/docs/superpowers/plans/2026-07-11-simultaneous-grasp-reachability.md
new file mode 100644
index 000000000..e94d9e975
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-11-simultaneous-grasp-reachability.md
@@ -0,0 +1,132 @@
+# Simultaneous Grasp Reachability Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Reject a multi-contact physical use case unless one targeted actuator sample satisfies every declared contact at once.
+
+**Architecture:** Extend the existing reachability issue type with a use-case-level simultaneous-contact variant. Evaluate a complete contact-distance vector for each solved pose, preserve existing per-contact minima, and emit the simultaneous variant only when all contacts are individually reachable but no common pose passes.
+
+**Tech Stack:** TypeScript, Vitest, KernelCAD assembly mate solver.
+
+---
+
+### Task 1: Add the common-pose reachability gate
+
+**Files:**
+- Modify: `src/modeling/mates/physicalUseCaseReachability.test.ts`
+- Modify: `src/modeling/mates/physicalUseCaseReachability.ts`
+- Modify: `src/modeling/mates/physicalUseCase.ts`
+- Modify: `src/agent/mcp/tools/designLoop.ts`
+- Modify: `src/agent/mcp/toolRegistry.ts`
+- Test: `tests/integration/mcp/designLoop.test.ts`
+- Test: `tests/integration/examples/functionFirstBarGraspSkeleton.test.ts`
+- Test: `tests/integration/examples/fiveFingerKinematicHand.test.ts`
+
+- [x] **Step 1: Write the failing counterexample test**
+
+Add a base with a revolute `yaw` mate, two colocated moving contact connectors at `[10, 0, 0]`, and fixed targets at `[10, 0, 0]` and `[0, 10, 0]`. Sample `[0, 90]` degrees. Assert that `reviewPhysicalUseCaseReachability(...)` returns one issue with:
+
+```ts
+{
+ kind: 'simultaneous-contacts-unreachable',
+ useCaseName: 'split-pose-grasp',
+ toleranceMm: 0.1,
+ bestMaxDistanceMm: expect.any(Number),
+ contactDistances: expect.arrayContaining([
+ expect.objectContaining({ contactA: 'finger.a', contactB: 'base.target-a' }),
+ expect.objectContaining({ contactA: 'finger.b', contactB: 'base.target-b' }),
+ ]),
+}
+```
+
+- [x] **Step 2: Run the new test and verify RED**
+
+Run:
+
+```bash
+npx vitest run src/modeling/mates/physicalUseCaseReachability.test.ts -t "rejects contacts that are reachable only at different actuator poses" --reporter=dot
+```
+
+Expected: FAIL because the current implementation returns `[]` after independently minimizing both contacts.
+
+- [x] **Step 3: Add a discriminated simultaneous-contact issue**
+
+In `physicalUseCaseReachability.ts`, preserve the current contact issue fields and add:
+
+```ts
+export interface PhysicalUseCaseSimultaneousContactsReachabilityIssue {
+ readonly kind: 'simultaneous-contacts-unreachable';
+ readonly useCaseName: string;
+ readonly toleranceMm: number;
+ readonly bestMaxDistanceMm?: number;
+ readonly contactDistances: readonly {
+ readonly contactA: string;
+ readonly contactB: string;
+ readonly distanceMm?: number;
+ }[];
+}
+```
+
+Preserve the existing exported `PhysicalUseCaseReachabilityIssue` interface and introduce `PhysicalUseCaseReachabilityFinding` as the union of that interface and the new shape.
+
+- [x] **Step 4: Implement common-pose evaluation**
+
+For each solved sample, calculate every contact distance in one array. Update independent minima exactly as today. Only complete arrays are common-pose candidates. Track the candidate with the smallest maximum contact distance and whether any complete candidate has every distance `<= toleranceMm`.
+
+After sampling:
+
+```ts
+const contactIssues = /* existing unreachable/uncheckable contacts */;
+if (contactIssues.length > 0 || useCase.contacts.length < 2 || hasPassingCommonPose) {
+ return contactIssues;
+}
+return [{
+ kind: 'simultaneous-contacts-unreachable',
+ useCaseName: useCase.name,
+ toleranceMm,
+ ...(bestCommonPose === undefined ? {} : { bestMaxDistanceMm: bestCommonPose.maxDistanceMm }),
+ contactDistances: useCase.contacts.map(/* distances from best common pose when available */),
+}];
+```
+
+- [x] **Step 5: Verify the focused test is GREEN**
+
+Run the Step 2 command. Expected: PASS.
+
+- [x] **Step 6: Wire the blocking diagnostic**
+
+Add `PhysicalUseCaseSimultaneousContactsUnreachableDiagnostic` to the `PhysicalUseCaseDiagnostic` union in `physicalUseCase.ts` with code `assembly.physical-use-case.simultaneous-contacts-unreachable`. In `reviewPhysicalUseCasesWithReachability(...)`, discriminate the new issue and emit a use-case-level error. Keep the existing per-contact mapping unchanged.
+
+- [x] **Step 7: Add diagnostic and design-loop integration coverage**
+
+Extend an adjacent `physicalUseCase` test to call `reviewPhysicalUseCasesWithReachability(...)` and assert the new code, use-case name, tolerance, and contact-distance evidence are surfaced as a blocking error. Add a `designLoopTool(...)` regression that requires the code and repair hint in `reviewFacts` and `nextActionPrompt`. Update the `review_cad` tool description to state that all contacts must pass in the same sampled actuator pose.
+
+- [x] **Step 8: Run focused reachability coverage**
+
+Run:
+
+```bash
+npx vitest run src/modeling/mates/physicalUseCaseReachability.test.ts src/modeling/mates/physicalUseCase.test.ts --reporter=dot
+```
+
+Expected: PASS.
+
+- [x] **Step 9: Run hand regressions**
+
+Run:
+
+```bash
+npx vitest run tests/integration/examples/functionFirstBarGraspSkeleton.test.ts tests/integration/examples/fiveFingerKinematicHand.test.ts --reporter=dot
+```
+
+Expected: the bar-grasp skeleton passes and the five-finger regression remains intentionally rejected by its asserted diagnostics.
+
+- [x] **Step 10: Check types and whitespace**
+
+Run the repository TypeScript check if defined in `package.json`, then run:
+
+```bash
+git diff --check
+```
+
+Expected: exit 0.
diff --git a/docs/superpowers/specs/2026-07-08-function-first-robot-hand-design.md b/docs/superpowers/specs/2026-07-08-function-first-robot-hand-design.md
new file mode 100644
index 000000000..1e70c53d7
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-08-function-first-robot-hand-design.md
@@ -0,0 +1,134 @@
+# Function-First Robot Hand Design
+
+## Purpose
+
+Build robot hands from grasp requirements instead of visual appearance. A model
+is accepted only when it can satisfy explicit object-holding tasks with a
+physically connected mechanism. Visual references and mesh fitting are secondary
+inputs used after functional gates pass.
+
+## Problem
+
+The previous hand attempts started from a front-facing hand image or from visual
+workflow variants. That produced repeated failures:
+
+- parts looked disconnected or unsupported;
+- fingers were placed to satisfy a silhouette instead of contact geometry;
+- visual rods and panels did not prove load paths;
+- mesh/reference fitting could not recover hidden joints, bearings, pins, or
+ actuation anchors.
+
+The correct starting point is: what must the hand hold, where must it contact,
+and what loads must travel through the mechanism?
+
+## Core Rule
+
+Function before form.
+
+For a robot hand, tests are the design brief. The CAD model is generated from
+grasp tasks, contact targets, joint limits, and load paths. A hand that looks
+good but cannot satisfy the grasp tests is rejected.
+
+## Required Grasp Tasks
+
+The initial task set lives in `scripts/robotHandFunctionalRequirements.ts`.
+
+1. **Pinch thin plate**
+ Hold a 2-5 mm plate or card edge between thumb and fingertip.
+
+2. **Power grasp cylinder**
+ Wrap around a 30-55 mm cylinder such as a bottle neck, tool handle, or pipe.
+
+3. **Spherical grasp**
+ Enclose a 35-65 mm sphere with at least three useful contact regions.
+
+4. **Box grasp**
+ Hold a rectangular block using opposed faces, not fingertip-only cheating.
+
+5. **Hook or handle pull**
+ Curl around a handle or ring section and carry pull load through the palm.
+
+6. **Wide object aperture**
+ Open far enough to accept an object wider than the relaxed palm contact span.
+
+## Acceptance Gates
+
+Each candidate hand must prove:
+
+- target contact points are reachable;
+- contact normals oppose object escape;
+- joint limits are respected;
+- pose envelope has no breaking self-collisions;
+- object clearance exists in open and closed poses;
+- loaded parts are in the mate graph;
+- joint axes pass through supported material;
+- actuation/transmission has anchored load paths;
+- visual reference styling is applied only after functional gates pass.
+
+## Build Sequence
+
+```text
+grasp tasks
+ -> object envelopes and contact targets
+ -> joint skeleton and thumb placement
+ -> finger count and phalanx lengths
+ -> palm and bearing support geometry
+ -> actuator/transmission anchors
+ -> pose/load/interference validation
+ -> visual styling and reference fit
+```
+
+## First Artifact
+
+The first real artifact should be a three-finger functional hand, not a
+five-finger visual hand.
+
+Minimum structure:
+
+- one opposed thumb;
+- two fingers that can cooperate or oppose the thumb;
+- palm with physically supported hinge blocks;
+- named parts and mates for every loaded body;
+- fingertip/contact connectors;
+- declared grasp targets for plate, cylinder, sphere, box, handle, and wide
+ object aperture.
+
+Why three fingers first:
+
+- covers the grasp test set with fewer joints;
+- exposes disconnected parts faster;
+- makes reachability and load-path failures easier to debug;
+- avoids wasting effort on decorative non-contact fingers.
+
+## Relation To Meshes And References
+
+Meshes and images remain useful, but only after function is defined:
+
+- reference images provide styling and rough proportions;
+- meshes can suggest object envelopes or visible surfaces;
+- neither source is allowed to satisfy a mechanical gate by itself.
+
+If mesh fitting cannot produce joint centers, supported axes, contact targets,
+and load paths, it is evidence only, not a design.
+
+## Tooling Implications
+
+KernelCAD should support a function-first hand workflow:
+
+- declare grasp tasks and target objects;
+- generate contact targets and workspace requirements;
+- synthesize a skeleton from those targets;
+- generate physical joints and transmission anchors;
+- run pose-envelope, aperture, collision, and load-path reviews;
+- only then apply visual/reference fitting.
+
+The current two-finger aperture review and workspace tools are the nearest
+existing primitives. The next implementation should extend them from gripper
+aperture into multi-contact grasp tests.
+
+## Success Criteria
+
+- `scripts/robotHandFunctionalRequirements.ts` defines the required grasp tasks.
+- tests enforce that hand requirements start from tasks, contacts, and gates.
+- future hand examples can be rejected before visual review if they do not pass
+ the functional grasp brief.
diff --git a/docs/superpowers/specs/2026-07-08-generic-physical-plausibility-gate-design.md b/docs/superpowers/specs/2026-07-08-generic-physical-plausibility-gate-design.md
new file mode 100644
index 000000000..a1104569e
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-08-generic-physical-plausibility-gate-design.md
@@ -0,0 +1,59 @@
+# Generic Physical Plausibility Gate — High-Level Design
+
+## Problem
+
+KernelCAD can currently reject many bad mechanisms: floating parts, disconnected bodies, unsupported joints, pose-envelope collisions, and some MuJoCo gravity/drop failures. That still allows designs that are visually connected and kinematically moving but have no declared task physics: no load, no contact contract, no actuator limit, and no stability criterion.
+
+The goal is a generic gate that asks: "what physical situation is this assembly supposed to survive or perform?" The gate must not be hand-specific. A gripper, hinge, latch, arm, bracket, watch lug, or lamp should all use the same evidence pattern.
+
+## Approach
+
+Add an assembly-level declaration:
+
+```ts
+arm.physicalUseCase('hold-cylinder', {
+ stableParts: ['target-cylinder'],
+ loads: [{ part: 'target-cylinder', force: [0, 0, -8] }],
+ contacts: [
+ { a: 'thumb-finger.tip', b: 'target-cylinder.mount', normal: [-1, 0, 0], friction: 0.6 },
+ ],
+ actuatorLimits: [{ mate: 'grip', maxTorqueNmm: 180 }],
+ criteria: { maxSlipMm: 2, settleTimeMs: 500 },
+});
+```
+
+First slice is structural, not full force simulation. It checks that the assembly declares enough physics evidence to be reviewable:
+
+- loads reference real parts and have non-zero force or torque
+- contacts reference real connectors, have normals, and positive friction
+- actuator limits reference real driven mates and positive torque
+- stable parts reference real parts
+- mechanisms can be reviewed with `requirePhysicalUseCase: true`
+
+Later slices can consume the same record in deterministic statics and MuJoCo dynamic tasks.
+
+## Alternatives
+
+| Option | Description | Tradeoff |
+|---|---|---|
+| A recommended | Add generic `physicalUseCase(...)` records and opt-in review gate first. | Small, testable, does not break the existing corpus immediately. |
+| B | Immediately make every mechanism fail without a physical use case. | Stronger product stance, but will break many current examples before the contract is mature. |
+| C | Add hand/grasp-specific simulation first. | Faster for the robot hand, but repeats the mistake of building special fixtures instead of reusable tooling. |
+
+## First Slice Acceptance
+
+1. `review_cad({ requirePhysicalUseCase: true })` fails a mechanism with no physical use case.
+2. A malformed use case fails with actionable diagnostics.
+3. A minimally declared generic use case passes the use-case gate.
+4. Existing reviews are unchanged unless `requirePhysicalUseCase` is set.
+
+## Later Slices
+
+1. Quasi-static load path and torque margin checks.
+2. Contact friction/slip margin checks.
+3. MuJoCo task runner: settle, gravity, impulse, disturbance, held-object pose drift.
+4. Material/stiffness budget checks for bending and bearing pressure.
+
+## Concern
+
+This first slice proves declaration quality, not real physics. That is intentional: a reusable record must exist before statics or dynamics can consume it.
diff --git a/docs/superpowers/specs/2026-07-08-mesh-conditioned-robot-hand-design.md b/docs/superpowers/specs/2026-07-08-mesh-conditioned-robot-hand-design.md
new file mode 100644
index 000000000..3a7180cdf
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-08-mesh-conditioned-robot-hand-design.md
@@ -0,0 +1,103 @@
+# Mesh-Conditioned Robot Hand Prototype Design
+
+## Purpose
+
+Build a narrow prototype that keeps the original robot hand reference in the
+modeling loop without treating it as a complete physical design. The reference
+drives visible fit. A hand-specific mechanical completion template adds the
+hidden structure needed for joints, load paths, clearances, and validation.
+
+## Problem
+
+The current robot hand workflow fails in two ways:
+
+- Manual primitive edits drift away from the original reference render.
+- Reference-derived shape alone is physically incomplete and cannot prove a
+ working mechanism.
+
+The prototype must avoid both failures. It should not become a generic mesh
+importer yet.
+
+## Scope
+
+In scope:
+
+- Add a structured `referenceLandmarks` block to the five-finger robot hand
+ example.
+- Generate visible hand proportions from those landmarks: palm, wrist, finger
+ roots, finger lengths, thumb angle, actuator windows, tendon rods, and screws.
+- Generate missing physical structure from a hand mechanism template: clevises,
+ pins, connector axes, revolute mates, load limits, clearances, and external
+ load checks.
+- Add integration tests that make the reference-conditioned workflow explicit
+ and reject render-budget loose-body patterns.
+
+Out of scope:
+
+- Real GLB/STL mesh import.
+- Automatic mesh segmentation.
+- Generic `fit.box()` / `fit.hinge()` APIs.
+- Full visual similarity scoring.
+
+## Architecture
+
+The example script owns two layers:
+
+1. `referenceLandmarks`: an evidence layer with visible dimensions and feature
+ positions from the original robot hand render.
+2. Mechanical completion functions: deterministic generators that convert
+ landmarks into an articulated, validated KernelCAD assembly.
+
+The assembly remains the source of truth. The reference layer is kept beside
+the parameters so future agents cannot silently replace it with guessed
+coordinates.
+
+## Data Model
+
+`referenceLandmarks` contains:
+
+- `palm`: width, depth, height, center, and shoulder geometry.
+- `wrist`: block dimensions, position, and tendon anchor row.
+- `actuatorWindows`: visible black inserts on the palm face.
+- `screws`: visible screw-head positions.
+- `tendons`: visible rod paths.
+- `fingers`: one entry per finger with root x/z, phalanx lengths, width, visual
+ angle, curl limits, and load limits.
+
+This is intentionally plain JavaScript data inside the `.kcad.ts` example so it
+is easy to inspect and mutate without adding public API surface.
+
+## Mechanical Completion
+
+The generator must add what the reference cannot prove:
+
+- clevis fork/tongue geometry at MCP, PIP, and DIP joints;
+- pin caps and hole clearances;
+- connector frames on both sides of each joint;
+- revolute mates with limits;
+- load limits and external loads;
+- unioned non-articulated visible details on the palm root so they are not
+ separate floating bodies.
+
+## Acceptance Criteria
+
+- The example evaluates at open pose and closed pose with no error diagnostics.
+- The script has a top-level `referenceLandmarks` object.
+- All five fingers are generated from `referenceLandmarks.fingers`.
+- The thumb angle and root position come from the reference landmark data.
+- No render-budget loose-body patterns remain: no `parts.push`, no `return parts`,
+ no `addPart(`.
+- The assembly exposes exactly one palm root plus three parts per finger.
+- The assembly exposes fifteen revolute mates.
+- Visible palm details are unioned into the palm root or articulated parts, not
+ left as separate loose solids.
+
+## Non-Goals And Risks
+
+This prototype does not prove that arbitrary meshes can become parametric CAD.
+It proves a smaller workflow: reference evidence can stay in the source while a
+mechanical template completes missing physical structure.
+
+The main risk is pretending the landmark object is automatic mesh fitting. It is
+not. It is a hand-authored evidence layer that should later be produced by
+mesh/image landmark extraction.
diff --git a/docs/superpowers/specs/2026-07-10-five-finger-hand-topology-gate-design.md b/docs/superpowers/specs/2026-07-10-five-finger-hand-topology-gate-design.md
new file mode 100644
index 000000000..303e9d757
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-10-five-finger-hand-topology-gate-design.md
@@ -0,0 +1,93 @@
+# Five-Finger Hand Topology Gate Design
+
+## Problem
+
+The current robot hand can look like a hand while still being mechanically invalid. The next slice must stop that failure mode before geometry iteration continues.
+
+The target remains a five-finger anthropomorphic hand, but the immediate deliverable is not a prettier hand. The deliverable is a deterministic gate that rejects a hand whose fingers, joints, or moving links are not structurally connected and mechanically meaningful.
+
+## First-Principles Check
+
+A robot hand is a load-bearing articulated mechanism, not a visual arrangement of finger-like solids.
+
+The minimum physical truths for this slice are:
+
+- Every moving finger link must transfer load back to the palm/root through mates or declared structural links.
+- Every non-fastened joint must connect two real parts through real connector references.
+- A revolute finger joint without a supported axis or finite travel limit is not a joint.
+- A moving segment that is disconnected, only decorative, or connected through impossible geometry is not part of a working hand.
+
+This gate belongs before reachability, collision sweep, and load simulation. Kinematic and dynamic checks are only meaningful after the mechanism graph is structurally coherent.
+
+## Architecture
+
+Add a deterministic topology review layer under the existing CAD review path:
+
+1. Build a part graph from assembly parts, mates, and declared structural relationships.
+2. Identify stable/root parts from physical-use-case declarations, falling back to explicit palm/root naming only in tests or fixtures that intentionally model a hand.
+3. Classify moving parts as any part participating in a non-fastened mate.
+4. For every moving part, verify a load path to a stable/root part.
+5. For every non-fastened mate, verify a joint contract:
+ - both parts exist,
+ - both connector refs resolve,
+ - connector origins and axes are finite,
+ - revolute-like joints have finite travel limits,
+ - revolute-like joints have support evidence through an existing supported-joint intent or equivalent declared support metadata.
+6. Return structured diagnostics through `review_cad` and `design_loop`.
+
+The review should be conservative. If a mechanism omits support metadata, the gate should fail with a repair hint instead of guessing that visual contact is sufficient.
+
+## Diagnostics
+
+Initial diagnostic codes:
+
+| Code | Meaning |
+| --- | --- |
+| `assembly.connectivity.floating-moving-part` | A moving part has no load path back to a stable/root part. |
+| `assembly.connectivity.no-load-path` | A declared load-bearing part cannot transfer force to any stable/root part. |
+| `assembly.joint-topology.connector-missing` | A mate references a missing part connector. |
+| `assembly.joint-topology.axis-invalid` | A joint connector axis is missing, zero-length, non-finite, or inconsistent. |
+| `assembly.joint-topology.missing-limit` | A moving joint lacks finite travel limits. |
+| `assembly.joint-topology.unsupported-axis` | A revolute-like joint has no support/bearing intent. |
+
+Diagnostics must include the part or mate name, a plain-language message, and a repair hint that tells the agent what structural evidence to add.
+
+## Integration
+
+- `review_cad` should include topology diagnostics in the same blocking diagnostic stream as physical-use-case reachability.
+- `design_loop` should preserve these diagnostics in `reviewFacts` and `nextActionPrompt`.
+- The current five-finger hand fixture should fail this gate if it has disconnected links, fake joints, missing limits, or unsupported axes.
+- A small clean fixture should pass: palm, two or three articulated links, supported revolute joints, finite limits, and a declared stable root/load path.
+
+## Test Strategy
+
+Write tests before implementation:
+
+1. Unit tests for graph reachability over small synthetic assemblies.
+2. Unit tests for joint-contract diagnostics on missing connectors, missing limits, invalid axes, and unsupported revolute joints.
+3. Integration test proving `review_cad` emits topology diagnostics.
+4. Design-loop test proving topology failures are carried into the next action prompt.
+5. Current five-finger hand regression proving the hand is rejected by topology/connectivity before any geometry redesign is accepted.
+
+## Accepted Limitations
+
+- This gate does not prove the hand can grasp an object.
+- This gate does not prove collision-free motion.
+- This gate does not compute torque or structural stress.
+- This gate may reject a visually plausible joint until the model declares support metadata. That is intentional; hidden assumptions are not physical evidence.
+
+## Alternatives Considered
+
+| Option | Description | Decision |
+| --- | --- | --- |
+| Graph + joint-contract gate | Check load paths, connector validity, limits, and support metadata before kinematics. | Recommended because it tests the minimum physical structure. |
+| Geometry contact-only gate | Check touching/floating solids using mesh contact. | Rejected as insufficient; visual contact can still hide fake hinges and unsupported axes. |
+| Dynamics simulation first | Use physics simulation before topology checks. | Rejected for this slice; simulation on an incoherent mechanism produces misleading failures. |
+
+## Decisions
+
+| # | Decision | Rationale |
+| --- | --- | --- |
+| 1 | Build topology/connectivity before more hand geometry work. | The current failure is architectural: the model can be visually hand-like while mechanically disconnected. |
+| 2 | Treat missing joint support metadata as blocking. | A revolute axis without support evidence is a fantasy joint. |
+| 3 | Preserve first-principles checks in the spec and future plan. | This keeps the agent from optimizing for appearance before physical coherence. |
diff --git a/docs/superpowers/specs/2026-07-10-five-finger-hand-topology-repair-design.md b/docs/superpowers/specs/2026-07-10-five-finger-hand-topology-repair-design.md
new file mode 100644
index 000000000..655052ce5
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-10-five-finger-hand-topology-repair-design.md
@@ -0,0 +1,63 @@
+# Five-Finger Hand Topology Repair Design
+
+## Problem
+
+The five-finger hand now fails the topology gate for the right reason: it is not mechanically complete. The current blockers are unsupported revolute axes on finger joints and a grasp-cylinder load with no structural path to the palm/root.
+
+The next pass must make the current hand satisfy the topology gate without hiding the failure behind visual edits or fake drive declarations.
+
+## First-Principles Check
+
+A finger joint can be driven or passive, but it cannot be unsupported. For a revolute joint to be physically meaningful, the support side needs bearing or bracket evidence tied into the mate endpoint that carries the shaft. A passive PIP/DIP hinge should not have to pretend it has its own actuator; it should declare support as support.
+
+A grasp object is different from a structural hand part. It is useful as a contact/load target, but the topology graph should not require the object itself to be mated into the hand assembly unless the scenario is explicitly modeling a grasped object as a held payload with structural contact closure.
+
+## Architecture
+
+Add a small passive joint-support intent alongside the existing driven `mechanicalJoint(...)` intent.
+
+1. Capture `assembly.jointSupport(name, opts)` records with:
+ - `mate`: revolute mate being supported,
+ - `shaft`: support-side part or shaft part,
+ - `supports`: one or more support-side parts,
+ - `output`: moving side of the mate,
+ - optional `requiredSupport` metadata matching the existing support contract shape.
+2. Extend `reviewJointTopology(...)` so a revolute mate is supported by either:
+ - a complete driven `mechanicalJoint(...)`, or
+ - a complete passive `jointSupport(...)`.
+3. Apply the new support intent to the five-finger hand:
+ - all PIP/DIP hinges,
+ - little/ring MCP hinges,
+ - any remaining revolute not already backed by existing MCP drive support.
+4. Treat the grasp cylinder as a non-structural contact target for topology, not a moving hand link with a required mate path. It should still remain available to physical-use-case and reachability checks.
+
+## Diagnostics And Behavior
+
+- The current hand should produce no `reviewJointTopology(...)` diagnostics.
+- `review_cad` should stop reporting `assembly.joint-topology.unsupported-axis` for the hand.
+- The topology repair must not silence physical-use-case reachability, load, collision, or visual gates.
+- Existing fake-support tests must still fail when support parts are unrelated to the support-side endpoint.
+
+## Test Strategy
+
+1. Unit test the passive support intent capture and stored records.
+2. Unit test `reviewJointTopology(...)` accepts a passive supported revolute hinge.
+3. Unit test unrelated passive supports do not satisfy the topology gate.
+4. Update the five-finger hand regression so topology diagnostics are empty.
+5. Keep existing review/design-loop integration tests green.
+
+## Accepted Limitations
+
+- This pass does not prove the hand can grasp the cylinder.
+- This pass does not solve dynamic simulation.
+- This pass does not redesign geometry or add transmissions for every passive finger joint.
+- A contact target marked non-structural is still only a modeling target; grasp plausibility remains the job of the physical-use-case/reachability gates.
+
+## Decisions
+
+| # | Decision | Rationale |
+| --- | --- | --- |
+| 1 | Add passive joint-support intent instead of abusing `mechanicalJoint(...)`. | Passive hinges need support evidence but not fake actuators. |
+| 2 | Reuse the existing support-side fastened-path rule. | Support evidence must be grounded in the mate endpoint, not a matching string. |
+| 3 | Keep grasp-cylinder out of topology moving-part/load-path requirements. | It is a contacted object, not part of the hand structure. |
+| 4 | Stop at topology clean, not full grasp success. | The next gate should fail honestly if reachability/load/dynamics are still invalid. |
diff --git a/docs/superpowers/specs/2026-07-11-joint-reaction-and-clevis-structure-design.md b/docs/superpowers/specs/2026-07-11-joint-reaction-and-clevis-structure-design.md
new file mode 100644
index 000000000..477a8778d
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-11-joint-reaction-and-clevis-structure-design.md
@@ -0,0 +1,314 @@
+# Joint Reaction and Clevis Structure Design
+
+## Status
+
+Approved for implementation on 2026-07-11.
+
+## Problem
+
+The physical-use-case gate can currently prove that a held object is in sampled
+quasi-static equilibrium and that declared actuator torque limits are not
+exceeded. It cannot prove how the resulting contact wrench travels through the
+mechanism or whether modeled joint hardware can carry it.
+
+The older `jointLoadCapacity` path is not a suitable base for this work. It
+uses manually supplied per-part loads, does not propagate loads across joints,
+uses inconsistent torque units, and cannot consume the exact pose/contact
+evidence in a static certificate. `MateRecord.maxLoad` is also not captured by
+the public `Assembly.mate()` API, so source declarations are currently dropped.
+
+## Goals
+
+1. Derive a deterministic reaction wrench at every articulated mate on a
+ supported load path from an exact passing static certificate.
+2. Compare each reaction with an explicit, unit-bearing declared envelope
+ without presenting that declaration as structural proof.
+3. Derive a narrow clevis pin/bearing strength certificate from the same
+ dimensions used to construct `joint.clevis()` geometry and from explicit
+ engineering material properties.
+4. Block on ambiguous topology, missing evidence, unsupported load cases, and
+ insufficient safety factor.
+5. Integrate the new evidence into `review_cad` and `design_loop` so physical
+ acceptance cannot bypass it when the new checks are requested.
+
+## Non-Goals
+
+- Finite-element analysis, contact-pressure simulation, fatigue, creep,
+ printed-material anisotropy, cap pullout, or fork-root bending.
+- Stiffness-based reaction sharing across closed loops or multiple supports.
+- Structural certification of custom hand-built hinge geometry from BREP/AABB
+ heuristics.
+- Repairing or accepting the existing five-finger hand.
+- Making the current bar-grasp model pass the structural gate.
+
+## Evidence Pipeline
+
+The pipeline has three independent certificates. A later certificate may only
+run from a passing earlier certificate.
+
+1. `PhysicalUseCaseStaticCertificate`: exact common-contact pose, held-object
+ force/moment residuals, contact forces, and actuator torque evidence.
+2. `PhysicalUseCaseJointReactionCertificate`: reaction wrench at each
+ articulated mate on each loaded tree.
+3. `PhysicalUseCaseJointStructuralCertificate`: declared-envelope result and,
+ when present and applicable, geometry/material-derived clevis checks.
+
+An envelope pass is not a structural pass. The final result reports these
+statuses separately.
+
+## Static Contact Evidence
+
+Each `PhysicalUseCaseStaticContactForce` gains explicit semantics:
+
+```ts
+interface PhysicalUseCaseStaticContactForce {
+ contactA: string;
+ contactB: string;
+ pointWorldMm: Vec3;
+ mechanismPart: string;
+ forceOnHeldWorldN: Vec3;
+ normalForceN: number;
+ tangentialForceN: number;
+ normalCapacityN: number;
+ friction: number;
+}
+```
+
+The old ambiguous `force` field is removed from new evidence. The force applied
+to the mechanism is exactly `-forceOnHeldWorldN`; it must be negated once and
+must not be combined with the held-object load a second time.
+
+## Reaction Solver
+
+### Supported topology
+
+For the exact certificate pose:
+
+1. Solve mates using the certificate's expanded coupled poses.
+2. Collapse parts joined by `fastened` mates into rigid groups.
+3. Build a graph whose remaining edges are articulated mates.
+4. For every group receiving a certified contact force, find its connected
+ component.
+5. Require exactly one stable rigid group in that loaded component.
+6. Require the component to be a tree (`edgeCount === vertexCount - 1`).
+
+Closed loops, parallel articulated paths, zero stable roots, and multiple
+stable roots produce `joint-reaction-indeterminate`. The solver must never pick
+an arbitrary spanning tree or invent load sharing.
+
+### Wrench propagation
+
+Orient each supported tree away from its stable root. Attach each mechanism
+contact force at `pointWorldMm` to the rigid group containing
+`mechanismPart`. Traverse child subtrees from leaves to root.
+
+For a subtree wrench expressed about point `q1`, shift it to joint point `q2`
+with:
+
+```text
+M(q2) = M(q1) + (q1 - q2) x F
+```
+
+The reaction reported at an edge is the wrench exerted by the parent side on
+the child-side free body, equal to the negative of the child's accumulated
+external subtree wrench about the mate origin.
+
+The mate origin is the solved world position of its connector pair. The axis
+is the solved, normalized world direction of the parent-side axis connector.
+Connector origins must coincide within the existing solver tolerance.
+
+```ts
+interface PhysicalUseCaseJointReactionEvidence {
+ mateName: string;
+ parentPart: string;
+ childPart: string;
+ pointWorldMm: Vec3;
+ axisWorld: Vec3;
+ forceWorldN: Vec3;
+ momentWorldNmm: Vec3;
+ resultantForceN: number;
+ resultantMomentNmm: number;
+ axialForceN: number;
+ radialForceN: number;
+ axisMomentNmm: number;
+ bendingMomentNmm: number;
+}
+```
+
+All internal mechanics use N, mm, Nmm, and MPa.
+
+## Declared Envelope
+
+The public mate API gains a unit-explicit capacity namespace:
+
+```ts
+interface MateCapacityEnvelope {
+ maxResultantForceN: number;
+ maxResultantMomentNmm: number;
+}
+
+interface MateCapacity {
+ envelope?: MateCapacityEnvelope;
+ structure?: ClevisStructuralModel;
+}
+
+arm.mate(name, a, b, type, {
+ capacity: {
+ envelope: {
+ maxResultantForceN: 120,
+ maxResultantMomentNmm: 800,
+ },
+ structure: clevis.structural,
+ },
+});
+```
+
+Envelope values must be positive and finite. A supplied structural model is
+only valid on a revolute mate. Missing either envelope limit is `undeclared`,
+not pass.
+
+Legacy `maxLoad: { force, torque }` is accepted only as a deprecated adapter.
+`force` maps to N and `torque` maps once from Nm to Nmm. Supplying both
+`capacity` and `maxLoad` is invalid. A partial legacy declaration remains
+undeclared for the new resultant-envelope gate.
+
+Envelope status is `pass | exceeded | undeclared`. Exceeded and undeclared are
+blocking when reaction-capacity review is requested.
+
+## Clevis Structural Descriptor
+
+`joint.clevis()` emits a descriptor from its resolved build dimensions. It is
+not reconstructed later from rendered material or BREP heuristics.
+
+```ts
+interface StructuralMaterial {
+ name: string;
+ model: 'isotropic-ductile';
+ yieldStrengthMPa: number;
+ bearingStrengthMPa: number;
+ shearStrengthMPa?: number;
+}
+
+interface ClevisStructuralModel {
+ kind: 'clevis-double-shear-v1';
+ source: 'joint.clevis';
+ pinDiameterMm: number;
+ boreDiameterMm: number;
+ forkPlateThicknessMm: number;
+ forkPlateCount: 2;
+ tongueThicknessMm: number;
+ forkGapMm: number;
+ supportSpanMm: number;
+ edgeDistanceMm: number;
+ materials?: {
+ pin: StructuralMaterial;
+ fork: StructuralMaterial;
+ tongue: StructuralMaterial;
+ };
+}
+```
+
+For resolved clevis style:
+
+- `pinDiameterMm = 2 * pinR`
+- `boreDiameterMm = 2 * (pinR + holeClearance)`
+- `forkPlateThicknessMm = plateT`
+- `tongueThicknessMm = tongueY`
+- `forkGapMm = forkGapY`
+- `supportSpanMm = forkGapY + plateT`
+- `edgeDistanceMm = knuckleR`
+
+PBR material and part density are never treated as strength evidence.
+
+## Clevis Strength Model
+
+The first model applies only when axial force is at most 0.01 N and
+perpendicular reaction moment is at most 0.1 Nmm, matching the non-weakenable
+default statics residual tolerances. A revolute-axis moment is delegated to
+the already-required actuator/transmission evidence and is not attributed to
+pin friction.
+
+Given radial reaction `V`, pin diameter `d`, bore diameter `db`, fork plate
+thickness `tf`, tongue thickness `tt`, support span `L`, and edge distance `e`:
+
+```text
+pin area A = pi * d^2 / 4
+double-shear pin stress = V / (2 * A)
+center-load pin moment = V * L / 4
+pin bending stress = 32 * M / (pi * d^3)
+pin von Mises stress = sqrt(bending^2 + 3 * shear^2)
+tongue bearing stress = V / (d * tt)
+fork bearing stress = V / (2 * d * tf)
+ligament = e - db / 2
+tongue tear-out stress = V / (2 * ligament * tt)
+fork tear-out stress = V / (4 * ligament * tf)
+tongue net-section stress = V / ((2 * e - db) * tt)
+fork net-section stress = V / (2 * (2 * e - db) * tf)
+```
+
+Geometry with non-positive area, ligament, or net section is invalid.
+`shearStrengthMPa` is used when explicitly declared; otherwise the solver may
+derive `yieldStrengthMPa / sqrt(3)` only for the declared
+`isotropic-ductile` model and records that assumption. Bearing strength is
+always explicit.
+
+Every check reports stress, allowable, and factor of safety (`null` only for
+zero stress, meaning unbounded rather than unknown). The use-case
+criterion `minJointSafetyFactor` defaults to 2.0 and may only be increased.
+The structural status is `pass | failed | input-incomplete |
+unsupported-load-case`.
+
+Axial force above 0.01 N, perpendicular reaction moment above 0.1 Nmm, missing materials, and
+custom geometry without a `joint.clevis` descriptor are blockers. They are not
+silently omitted.
+
+## Diagnostics
+
+New error diagnostics:
+
+- `assembly.physical-use-case.joint-reaction-input-incomplete`
+- `assembly.physical-use-case.joint-reaction-indeterminate`
+- `assembly.physical-use-case.joint-capacity-undeclared`
+- `assembly.physical-use-case.joint-capacity-exceeded`
+- `assembly.physical-use-case.joint-structure-input-incomplete`
+- `assembly.physical-use-case.joint-structure-unsupported-load-case`
+- `assembly.physical-use-case.joint-structure-insufficient`
+
+Diagnostic messages name the use case and mate and include the measured value,
+limit, or missing/unsupported evidence. No diagnostic may claim a comparison
+was performed when it was not.
+
+## Tool Integration
+
+`review_cad` adds:
+
+- `includePhysicalUseCaseJointReactions`
+- `includePhysicalUseCaseJointStructure`
+- `physicalUseCaseJointReactionCertificates`
+- `physicalUseCaseJointStructuralCertificates`
+
+Structure review implies statics and reactions. Reaction review implies
+statics. `design_loop` enables both checks whenever an attempt declares a
+physical use case, alongside the existing reachability and statics checks.
+
+## Acceptance Tests
+
+1. A 10 N serial-chain load with 50 mm and 150 mm arms produces 500 Nmm and
+ 1500 Nmm joint moments.
+2. Changing the certified articulated pose changes the derived moment arm.
+3. Branch forces combine vectorially and can cancel at an upstream joint.
+4. The contact force is negated exactly once and applied at its certified
+ midpoint.
+5. A fastened group is collapsed; a closed articulated loop and a two-root
+ tree are rejected as indeterminate.
+6. Public `mate({ capacity })` capture validates and preserves unit-bearing
+ values; the legacy Nm adapter converts once.
+7. Hand-calculated clevis equation tests cover pass/fail boundaries and invalid
+ geometry.
+8. `joint.clevis()` emits dimensions identical to its resolved style.
+9. A complete clevis fixture passes, then fails when only pin diameter or
+ material strength is reduced.
+10. Missing descriptor, axial load, and perpendicular moment are blocking.
+11. The current bar-grasp example is structurally incomplete/unsupported, not
+ green.
+12. The current five-finger example retains its reachability rejection.
diff --git a/docs/superpowers/specs/2026-07-11-pose-bound-static-equilibrium-design.md b/docs/superpowers/specs/2026-07-11-pose-bound-static-equilibrium-design.md
new file mode 100644
index 000000000..526bf8c99
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-11-pose-bound-static-equilibrium-design.md
@@ -0,0 +1,141 @@
+# Pose-Bound Static Equilibrium Design
+
+## Problem
+
+KernelCAD now rejects a multi-contact grasp unless all declared contacts are reachable in one sampled actuator pose. The remaining physical-use-case force and torque checks are not tied to that pose: they use declared vectors and part-local connector coordinates independently, sum directional capacities, and estimate actuator torque without solving contact-force balance. Such checks can accept a grasp whose forces balance translation but not moment, or whose contact forces require more actuator torque than declared.
+
+## First-Principles Requirement
+
+A sampled grasp state is statically feasible only when one contact-force allocation simultaneously satisfies all of the following:
+
+1. Every declared contact is within `criteria.maxSlipMm` at the same successfully solved pose.
+2. Contact forces are compressive and stay inside a conservative Coulomb friction pyramid.
+3. The net world-space force and moment on every loaded part are within explicit residual tolerances.
+4. Generalized holding torque at every declared actuator stays within `maxTorqueNmm`, including declared coupled/transmitted joints.
+
+This is a quasi-static load-case check. It does not prove arbitrary force closure, dynamic stability, impact survival, structural stiffness, or feasibility between sampled poses.
+
+## Explicit Evidence Contract
+
+Extend physical-use-case declarations without changing existing vector defaults:
+
+```ts
+arm.physicalUseCase('hold-object', {
+ loads: [{
+ part: 'object',
+ at: 'object.center-of-mass',
+ force: [0, 0, -8],
+ }],
+ contacts: [{
+ a: 'finger.tip',
+ b: 'object.contact',
+ normal: [0, -1, 0],
+ normalFrame: 'world',
+ friction: 0.6,
+ normalForceN: 12,
+ }],
+ actuatorLimits: [{ mate: 'grip', maxTorqueNmm: 180 }],
+ criteria: {
+ maxSlipMm: 1,
+ maxForceResidualN: 0.01,
+ maxTorqueResidualNmm: 0.1,
+ },
+});
+```
+
+- `load.at` is a connector on `load.part` and names the force application point. It is required by the statics gate when a force is declared, and at least one load must provide it as the wrench reference even for a pure-torque case. Load force and free torque vectors remain world-space; torque units are Nmm.
+- `contact.normalFrame` is `'world' | 'a' | 'b'` and defaults to `'world'` for compatibility. Local normals are rotated to world space with the selected part transform.
+- A contact normal points from side `b` toward side `a`. A compressive contact force acts along `+normal` on `a` and `-normal` on `b`.
+- `normalForceN` is the maximum compressive normal force available at that contact and is required by the statics gate.
+- A connector pair may appear only once per use case, regardless of endpoint order. Distinct physical contact patches require distinct connector evidence; duplicate declarations cannot multiply capacity.
+- `maxForceResidualN` and `maxTorqueResidualNmm` default to conservative numerical tolerances. Callers may tighten them, but cannot increase them above the defaults because residual tolerance is solver hygiene, not a physical requirement that an agent may weaken.
+
+Missing or unusable required evidence does not silently skip physics. Once a use case has a common contact pose and statics is enabled, it emits an input-incomplete blocker.
+
+V1 deliberately supports one rigid held part per use case. Every declared load acts on that part, every contact has exactly one endpoint on it, and the held part is not a stable part or a structurally mated member of the mechanism. Multi-body held systems and contacts between two loaded bodies are uncheckable in this slice.
+
+## Shared Pose Sampling
+
+Refactor targeted reachability into an internal assessment that returns both findings and successfully solved contact samples. Each sample contains requested mate poses, part transforms, world-space contact points, and maximum contact distance. The existing reachability API remains as a compatibility wrapper returning only findings.
+
+The statics evaluator consumes only complete samples whose every contact distance is within `maxSlipMm`. It never resamples or resolves the assembly independently, so reachability and statics cannot accidentally validate different states.
+
+## Contact Model
+
+For each contact, construct a deterministic eight-edge friction pyramid. If `n` is the unit normal and `t1`, `t2` are an orthonormal tangent basis, its generators are evenly spaced around:
+
+```text
+n + friction * (cos(theta) * t1 + sin(theta) * t2)
+theta = 0, 45, 90, ... 315 degrees
+```
+
+Non-negative generator weights are constrained so their sum is at most `normalForceN`. This pyramid is inscribed in the circular Coulomb cone and is therefore conservative.
+
+For each loaded part, assemble a six-dimensional wrench equation about an explicit reference point. External forces contribute at their `load.at` points; free torques contribute directly. Contact forces contribute at the midpoint of the two sampled connector points so the allowed slip gap does not create a fictitious couple.
+
+## Feasibility Solver
+
+Use a small deterministic projected-gradient feasibility search over the contact generator weights:
+
+- Objective: normalized squared residual of all loaded-part force and moment equations plus squared actuator-limit violations.
+- Constraint projection: independently project each contact's eight weights onto the non-negative capped simplex whose sum is at most `normalForceN`.
+- Run a contact-only solve first. If no sampled pose balances the declared wrenches, report static equilibrium failure.
+- Run a second solve including actuator-limit penalties. If equilibrium is possible but no sampled solution also satisfies torque limits, report actuator torque failure.
+- A sample passes only after independently reconstructing its forces and verifying force balance, moment balance, the true circular Coulomb inequality, normal-force caps, and actuator limits against explicit tolerances. Solver convergence by itself is never a pass, and iteration exhaustion never counts as a pass.
+
+The optimization is convex under the linearized friction pyramid. A passing post-check is a concrete force-allocation certificate. Failure wording says that no certificate was found in the sampled linearized model; it does not claim analytical impossibility.
+
+## Actuator Torque
+
+For each scalar revolute actuator limit, numerically differentiate every mechanism-to-held contact point displacement with respect to that actuator's source coordinate. Perturbations are expressed in radians, stay inside the mate limits, re-expand declared couplings, and re-solve the assembly. Central differences are used away from a limit and an inward one-sided difference at a limit.
+
+Apply virtual work directly to the relative contact Jacobian:
+
+```text
+actuator generalized torque = sum(relative contact Jacobian dot mechanism-side contact force)
+```
+
+Because every perturbed sample expands the existing coupling records, source-to-driven ratios are included kinematically. Coupled motion must also have the already validated transmission intent; missing limits, failed perturbation solves, unsupported mate types, or missing transmission evidence makes statics input incomplete rather than assuming a zero torque path. V1 treats the declared transmission as ideal and lossless.
+
+Every independent articulated mate on a mate-graph path from a mechanism-side contact to a declared stable part must resolve to an `actuatorLimits` source. Driven coupled mates resolve transitively to their independent source. A declared transmission ratio, when present, must match the corresponding kinematic coupling ratio. This prevents omitted hinges or contradictory transmission evidence from silently contributing unbounded holding torque.
+
+## Diagnostics
+
+Add blocking physical-use-case diagnostics:
+
+- `assembly.physical-use-case.static-input-incomplete`
+- `assembly.physical-use-case.static-equilibrium-unmet`
+- `assembly.physical-use-case.static-actuator-torque-insufficient`
+
+Diagnostics include the use-case name, best sampled poses when available, residual force/moment, actuator torque evidence, and an actionable hint. `review_cad`, mechanism fitness, and `design_loop` surface and preserve these errors like the existing contact-reachability failures.
+
+## Integration Defaults
+
+Add `includePhysicalUseCaseStatics` to `review_cad`. It is opt-in during this compatibility slice; the design loop enables it for physical-acceptance attempts, and the function-first bar-grasp regression requests it explicitly. Cheap reviews and existing direct `requirePhysicalUseCase` callers remain unchanged until the contract has broader corpus coverage.
+
+Successful reviews expose a compact static-equilibrium certificate with the sampled actuator poses, residual wrench, contact forces and utilization, and required/allowed actuator torque. Agents should not have to infer success only from diagnostic silence.
+
+## Alternatives
+
+### Directional Capacity Sums
+
+Rejected. Summing force magnitudes ignores moment balance and can combine mutually incompatible contact directions.
+
+### Full MuJoCo Contact Simulation
+
+Deferred. The existing MuJoCo probe checks articulated mechanism gravity/drop behavior, but abstract physical-use-case contacts are not collision constraints or actuator models. Building those correctly is a later dynamic-validation slice.
+
+### External Linear-Programming Dependency
+
+Deferred. The problem sizes are small and the repository already contains pure-TypeScript numerical helpers. A focused projected convex search plus independent certificate verification avoids adding a runtime dependency. A bounded Phase-I simplex remains a future replacement if the projected search produces unacceptable false negatives; it must preserve the same evidence and post-check contract.
+
+## Test Strategy
+
+1. RED: contacts are geometrically reachable and have enough summed force, but their wrench cannot balance the load moment.
+2. RED: object equilibrium is feasible, but every feasible allocation exceeds a direct actuator torque limit.
+3. GREEN: the same fixture passes after increasing actuator torque capacity.
+4. GREEN: local contact normals are rotated into world space at the winning pose.
+5. RED: common contact pose exists but `load.at` or `normalForceN` is missing.
+6. Regression: unreachable contacts still produce reachability diagnostics without extra statics noise.
+7. Regression: the function-first bar-grasp skeleton is evaluated by the new gate; if it fails, preserve the failure and repair the model or contract rather than weakening validation.
+8. Regression: the rejected five-finger hand remains rejected.
diff --git a/docs/superpowers/specs/2026-07-11-simultaneous-grasp-reachability-design.md b/docs/superpowers/specs/2026-07-11-simultaneous-grasp-reachability-design.md
new file mode 100644
index 000000000..1aab4d8c5
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-11-simultaneous-grasp-reachability-design.md
@@ -0,0 +1,42 @@
+# Simultaneous Grasp Reachability Design
+
+## Problem
+
+The targeted physical-use-case reachability gate currently minimizes every declared contact independently across all sampled actuator poses. That can accept an impossible grasp: one finger may reach its target only while open and another only while closed, even though no single mechanism state satisfies both contacts.
+
+## First-Principles Requirement
+
+A grasp is one physical state. Every contact declared by a physical use case must therefore be evaluated against the same solved actuator pose. Independent best poses are useful diagnostics, but they are not evidence that the grasp exists.
+
+## Chosen Approach
+
+Retain the deterministic targeted sampler and coupling expansion. For every successfully solved sample, calculate all declared contact distances together. Continue tracking each contact's independent minimum so a specifically unreachable or uncheckable contact keeps the existing diagnostic. When every contact is individually reachable but no complete sample places all contacts within `maxSlipMm`, emit one use-case-level `assembly.physical-use-case.simultaneous-contacts-unreachable` error.
+
+The diagnostic records the contact distances from the best common sample, chosen by the smallest worst contact distance. This makes the failure actionable without pretending that a discrete sampler is a continuous dynamics solver.
+
+## Alternatives
+
+- Keep independent per-contact minima: rejected because it proves several different configurations, not one grasp.
+- Run continuous optimization or dynamics simulation now: deferred because topology, limits, couplings, and discrete common-pose feasibility must be coherent before a more expensive solver is meaningful.
+
+## Behavior
+
+- A contact that is never resolved or never enters tolerance produces the existing `contact-unreachable` diagnostic.
+- A use case with two or more individually reachable contacts but no common passing sample produces one `simultaneous-contacts-unreachable` diagnostic.
+- A use case with one contact keeps the existing behavior and never gets a redundant simultaneous-contact diagnostic.
+- Failed mate solves are ignored as candidate states.
+- A sample with an unresolved declared contact is not a complete common-pose candidate.
+- Pose-envelope and targeted per-contact diagnostics remain deduplicated as they are today.
+- `design_loop` preserves the simultaneous-contact error as a physical acceptance fact and includes it in the next repair prompt.
+
+## Scope
+
+This slice changes validation only. It does not alter hand geometry, add a continuous optimizer, claim force closure, or claim dynamic stability.
+
+## Verification
+
+1. A regression model has two contacts on one rotating link: contact A reaches at 0 degrees and contact B reaches at 90 degrees. The old implementation returns no issue; the new implementation must emit the simultaneous-contact issue.
+2. Existing coupling-expansion and unusable-solve tests continue to pass.
+3. The function-first bar-grasp skeleton still passes because all three contacts are aligned at one common actuator sample.
+4. The rejected five-finger hand remains rejected; this slice must not weaken existing gates.
+5. The agent design loop carries the exact simultaneous-contact code and repair hint into the next attempt.
diff --git a/examples/robot-hand/workflow-candidates-comparison.kcad.ts b/examples/robot-hand/workflow-candidates-comparison.kcad.ts
new file mode 100644
index 000000000..770688dd7
--- /dev/null
+++ b/examples/robot-hand/workflow-candidates-comparison.kcad.ts
@@ -0,0 +1,191 @@
+// Five actual robot-hand workflow candidate models on one comparison board.
+//
+// A: mechanism-template first
+// B: reference-conditioned visible fit + physical completion
+// C: mesh-feature fitting
+// D: master skeleton
+// E: validation-loop view
+
+setCameraTarget(0, 0, 35);
+setCameraDistance(620);
+
+const beige = '#d8d3c9';
+const tan = '#b9b3a8';
+const dark = '#111827';
+const metal = '#d9dee5';
+const blue = '#2563eb';
+const red = '#dc2626';
+const green = '#16a34a';
+const orange = '#f59e0b';
+const ghost = '#cbd5e1';
+const graphite = '#475569';
+
+function solid(w, d, h, x, y, z, color) {
+ return box(w, d, h, true).translate(x, y, z).color(color);
+}
+
+function rodXZ(x1, z1, x2, z2, y, thickness, color) {
+ const dx = x2 - x1;
+ const dz = z2 - z1;
+ const len = Math.sqrt(dx * dx + dz * dz);
+ const angle = Math.atan2(dx, dz) * 180 / Math.PI;
+ return box(thickness, 4, len, true)
+ .rotate([0, 1, 0], angle)
+ .translate((x1 + x2) / 2, y, (z1 + z2) / 2)
+ .color(color);
+}
+
+function pin(x, z, y = -11, r = 4) {
+ return cylinder(5, r, 20).alongAxis([0, 1, 0]).translate(x, y, z).color(metal);
+}
+
+function basePanel(cx, label, color) {
+ return solid(92, 8, 12, cx, 7, -76, color)
+ .union(blockLetter(label, cx - 33, -9, -80, dark));
+}
+
+function stroke(w, h, x, y, z, color) {
+ return solid(w, 3, h, x, y, z, color);
+}
+
+function blockLetter(label, x, y, z, color) {
+ if (label === 'A') {
+ return rodXZ(x - 8, z - 8, x, z + 10, y, 3.2, color)
+ .union(rodXZ(x + 8, z - 8, x, z + 10, y, 3.2, color))
+ .union(stroke(12, 3, x, y, z, color));
+ }
+ if (label === 'B') {
+ return stroke(3, 22, x - 7, y, z, color)
+ .union(stroke(12, 3, x, y, z + 10, color))
+ .union(stroke(12, 3, x, y, z, color))
+ .union(stroke(12, 3, x, y, z - 10, color))
+ .union(stroke(3, 9, x + 7, y, z + 5, color))
+ .union(stroke(3, 9, x + 7, y, z - 5, color));
+ }
+ if (label === 'C') {
+ return stroke(3, 22, x - 7, y, z, color)
+ .union(stroke(14, 3, x, y, z + 10, color))
+ .union(stroke(14, 3, x, y, z - 10, color));
+ }
+ if (label === 'D') {
+ return stroke(3, 22, x - 7, y, z, color)
+ .union(stroke(12, 3, x, y, z + 10, color))
+ .union(stroke(12, 3, x, y, z - 10, color))
+ .union(stroke(3, 18, x + 7, y, z, color));
+ }
+ return stroke(3, 22, x - 7, y, z, color)
+ .union(stroke(14, 3, x, y, z + 10, color))
+ .union(stroke(12, 3, x - 1, y, z, color))
+ .union(stroke(14, 3, x, y, z - 10, color));
+}
+
+function simplePalm(cx, color = tan, y = 0) {
+ return solid(72, 16, 70, cx, y, 6, color)
+ .union(solid(56, 18, 16, cx, y - 1, -42, dark))
+ .union(solid(42, 18, 22, cx, y - 1, -60, graphite));
+}
+
+function simpleFinger(rootX, rootZ, lengths, width, angleDeg, color = beige, y = 0) {
+ const [a, b, c] = lengths;
+ const root = solid(width, 10, a, 0, y, a / 2, color);
+ const mid = solid(width * 0.82, 9, b, 0, y, a + b / 2 + 5, color);
+ const tip = solid(width * 0.70, 8, c, 0, y, a + b + c / 2 + 10, dark);
+ return root.union(mid).union(tip)
+ .rotate([0, 1, 0], angleDeg)
+ .translate(rootX, 0, rootZ);
+}
+
+function basicHand(cx, opts = {}) {
+ const y = opts.y ?? 0;
+ const palmColor = opts.palmColor ?? tan;
+ const linkColor = opts.linkColor ?? beige;
+ let model = simplePalm(cx, palmColor, y)
+ .union(simpleFinger(cx - 36, 42, [34, 24, 16], 10, -4, linkColor, y))
+ .union(simpleFinger(cx - 12, 44, [42, 29, 20], 11, -1, linkColor, y))
+ .union(simpleFinger(cx + 12, 45, [46, 32, 22], 11, 0, linkColor, y))
+ .union(simpleFinger(cx + 36, 42, [38, 27, 18], 10, 4, linkColor, y))
+ .union(simpleFinger(cx + 52, -4, [30, 22, 16], 10, 38, linkColor, y));
+ for (const x of [cx - 36, cx - 12, cx + 12, cx + 36]) {
+ model = model.union(pin(x, 42, y - 11, 3.8));
+ }
+ model = model.union(pin(cx + 52, -4, y - 11, 3.8));
+ return model;
+}
+
+function mechanismTemplate(cx) {
+ let model = basePanel(cx, 'A', '#e0e7ff').union(basicHand(cx));
+ for (const x of [cx - 36, cx - 12, cx + 12, cx + 36, cx + 52]) {
+ model = model
+ .union(solid(16, 6, 9, x, -14, 39, graphite))
+ .union(solid(10, 6, 7, x, -17, 31, metal));
+ }
+ return model.union(rodXZ(cx - 34, -40, cx - 36, 42, -16, 2, metal))
+ .union(rodXZ(cx - 10, -42, cx - 12, 44, -16, 2, metal))
+ .union(rodXZ(cx + 12, -42, cx + 12, 45, -16, 2, metal));
+}
+
+function referenceConditioned(cx) {
+ let model = basePanel(cx, 'B', '#cffafe')
+ .union(solid(82, 4, 78, cx, 8, 8, ghost))
+ .union(solid(18, 4, 82, cx - 38, 8, 78, ghost))
+ .union(solid(18, 4, 94, cx - 12, 8, 83, ghost))
+ .union(solid(18, 4, 98, cx + 12, 8, 85, ghost))
+ .union(solid(18, 4, 84, cx + 38, 8, 78, ghost))
+ .union(rodXZ(cx + 50, -2, cx + 92, 54, 8, 9, ghost))
+ .union(basicHand(cx, { y: -2 }));
+ for (const x of [cx - 26, cx, cx + 26]) {
+ model = model.union(solid(10, 3, 24, x, -13, 12, dark));
+ }
+ return model;
+}
+
+function meshFeatureFitting(cx) {
+ let model = basePanel(cx, 'C', '#fef3c7')
+ .union(solid(82, 14, 58, cx, 5, 8, ghost))
+ .union(solid(22, 14, 72, cx - 38, 5, 72, ghost))
+ .union(solid(24, 14, 86, cx - 10, 5, 82, ghost))
+ .union(solid(24, 14, 90, cx + 16, 5, 84, ghost))
+ .union(solid(22, 14, 76, cx + 42, 5, 74, ghost))
+ .union(solid(72, 6, 48, cx, -10, 8, orange));
+ for (const x of [cx - 38, cx - 10, cx + 16, cx + 42]) {
+ model = model
+ .union(solid(14, 6, 66, x, -10, 66, orange))
+ .union(pin(x, 39, -14, 3.5));
+ }
+ return model.union(rodXZ(cx + 46, -4, cx + 88, 48, -10, 8, orange));
+}
+
+function masterSkeleton(cx) {
+ let model = basePanel(cx, 'D', '#dcfce7')
+ .union(basicHand(cx, { y: 0, palmColor: '#e6dfd2', linkColor: '#e9e2d5' }))
+ .union(rodXZ(cx, -58, cx, 122, -18, 2.5, blue))
+ .union(solid(118, 3, 2, cx, -18, 42, red));
+ for (const x of [cx - 36, cx - 12, cx + 12, cx + 36]) {
+ model = model
+ .union(rodXZ(x, 42, x - 6, 112, -18, 2.2, blue))
+ .union(pin(x, 42, -20, 3.2));
+ }
+ model = model.union(rodXZ(cx + 52, -4, cx + 92, 55, -18, 2.2, blue));
+ return model;
+}
+
+function validationLoop(cx) {
+ const model = basePanel(cx, 'E', '#fee2e2')
+ .union(basicHand(cx))
+ .union(solid(20, 5, 20, cx - 46, -18, -40, green))
+ .union(rodXZ(cx - 52, -40, cx - 46, -32, -21, 3, green))
+ .union(rodXZ(cx - 46, -32, cx - 35, -50, -21, 3, green))
+ .union(solid(22, 5, 22, cx + 64, -18, 82, red))
+ .union(rodXZ(cx + 56, 74, cx + 72, 90, -21, 4, red))
+ .union(rodXZ(cx + 72, 74, cx + 56, 90, -21, 4, red));
+ return model;
+}
+
+const centers = [-250, -125, 0, 125, 250];
+const comparison = mechanismTemplate(centers[0])
+ .union(referenceConditioned(centers[1]))
+ .union(meshFeatureFitting(centers[2]))
+ .union(masterSkeleton(centers[3]))
+ .union(validationLoop(centers[4]));
+
+return comparison;
diff --git a/scripts/robotHandFunctionalRequirements.ts b/scripts/robotHandFunctionalRequirements.ts
new file mode 100644
index 000000000..13604c7ea
--- /dev/null
+++ b/scripts/robotHandFunctionalRequirements.ts
@@ -0,0 +1,108 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+export type RobotHandCheck =
+ | 'reachable-contact-points'
+ | 'joint-limits-respected'
+ | 'no-self-collision'
+ | 'load-path-to-palm'
+ | 'opposing-contact-normals'
+ | 'object-clearance'
+ | 'actuation-anchored';
+
+export interface RobotHandGraspTask {
+ id: string;
+ name: string;
+ object: string;
+ purpose: string;
+ contacts: string[];
+ requiredChecks: RobotHandCheck[];
+}
+
+export const FUNCTION_FIRST_ROBOT_HAND_PRINCIPLES = [
+ 'Function before form: define grasp tasks and object contacts before visual styling.',
+ 'Contacts before fingers: finger count, thumb placement, and palm shape come from required contact geometry.',
+ 'Skeleton before solids: joint centers, axes, envelopes, and limits are authored before decorative bodies.',
+ 'Validation before polish: a hand that cannot hold target objects is rejected even if it looks plausible.',
+ 'Reference after function: visual references tune proportions only after the grasp tasks pass.',
+] as const;
+
+const BASE_CHECKS: RobotHandCheck[] = [
+ 'reachable-contact-points',
+ 'joint-limits-respected',
+ 'no-self-collision',
+ 'load-path-to-palm',
+];
+
+export const ROBOT_HAND_GRASP_TASKS: RobotHandGraspTask[] = [
+ {
+ id: 'pinch-thin-plate',
+ name: 'Pinch thin plate',
+ object: '2-5 mm plate or card edge',
+ purpose: 'Prove fingertip opposition and fine-object aperture without cheating through interpenetration.',
+ contacts: ['thumb pad', 'index fingertip'],
+ requiredChecks: [...BASE_CHECKS, 'opposing-contact-normals', 'object-clearance'],
+ },
+ {
+ id: 'power-cylinder',
+ name: 'Power grasp cylinder',
+ object: '30-55 mm diameter cylinder such as a bottle neck or handle',
+ purpose: 'Prove wraparound grasp and palm/finger load path under torque.',
+ contacts: ['thumb side', 'index phalanx', 'middle phalanx', 'palm saddle'],
+ requiredChecks: [...BASE_CHECKS, 'opposing-contact-normals', 'actuation-anchored'],
+ },
+ {
+ id: 'spherical-object',
+ name: 'Spherical grasp',
+ object: '35-65 mm sphere',
+ purpose: 'Prove multi-point enclosure rather than one flat clamp line.',
+ contacts: ['thumb pad', 'index fingertip', 'middle fingertip'],
+ requiredChecks: [...BASE_CHECKS, 'opposing-contact-normals', 'object-clearance'],
+ },
+ {
+ id: 'box-grasp',
+ name: 'Box grasp',
+ object: '45 x 30 x 25 mm rectangular block',
+ purpose: 'Prove stable grasp on flat-sided objects without relying only on fingertip points.',
+ contacts: ['thumb pad', 'index inner face', 'middle inner face', 'palm face'],
+ requiredChecks: [...BASE_CHECKS, 'opposing-contact-normals', 'object-clearance'],
+ },
+ {
+ id: 'hook-handle',
+ name: 'Hook or handle pull',
+ object: '8-16 mm handle or ring section',
+ purpose: 'Prove load-bearing hook geometry and pin/load path through the palm.',
+ contacts: ['curled finger inner surface', 'palm reaction support'],
+ requiredChecks: [...BASE_CHECKS, 'actuation-anchored'],
+ },
+ {
+ id: 'wide-object',
+ name: 'Wide object aperture',
+ object: 'object wider than relaxed palm contact span',
+ purpose: 'Prove the hand opens far enough before closing around the target.',
+ contacts: ['thumb outer reach', 'opposing finger outer reach'],
+ requiredChecks: [...BASE_CHECKS, 'object-clearance'],
+ },
+];
+
+export const ROBOT_HAND_ACCEPTANCE_GATES = [
+ 'grasp-aperture-covers-target-object',
+ 'opposing-contact-normals-resist-escape',
+ 'pose-envelope-has-no-breaking-collisions',
+ 'all-loaded-parts-are-in-mate-graph',
+ 'actuation-path-has-anchored-transmission',
+ 'all-contacting-fingers-have-physically-realized-joints',
+ 'visual-reference-is-applied-only-after-functional-gates-pass',
+] as const;
+
+export function summarizeRobotHandFunctionalBrief() {
+ return {
+ firstArtifact: 'three-finger functional hand',
+ deferred: [
+ 'five-finger visual styling',
+ 'mesh feature fitting',
+ 'cosmetic palm shell',
+ 'extra non-contact fingers',
+ ],
+ why: 'A three-finger hand can cover the grasp tests with fewer joints, making disconnected parts, invalid axes, and fake load paths easier to detect before adding visual complexity.',
+ };
+}
diff --git a/scripts/robotHandWorkflowCompare.ts b/scripts/robotHandWorkflowCompare.ts
new file mode 100644
index 000000000..bf2e676d1
--- /dev/null
+++ b/scripts/robotHandWorkflowCompare.ts
@@ -0,0 +1,290 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export const ROBOT_HAND_WORKFLOW_WEIGHTS = {
+ physicalCompleteness: 0.30,
+ referenceFit: 0.22,
+ parametricStability: 0.20,
+ automationPotential: 0.13,
+ validationCoverage: 0.15,
+} as const;
+
+export type RobotHandWorkflowId =
+ | 'mechanism-templates'
+ | 'reference-conditioned'
+ | 'mesh-feature-fitting'
+ | 'master-skeleton'
+ | 'validation-loop';
+
+export interface WorkflowScore {
+ physicalCompleteness: number;
+ referenceFit: number;
+ parametricStability: number;
+ automationPotential: number;
+ validationCoverage: number;
+}
+
+export interface WorkflowCandidate {
+ id: RobotHandWorkflowId;
+ label: string;
+ role: 'generator' | 'evidence-to-generator' | 'skeleton' | 'validator';
+ inputs: string;
+ builds: string;
+ failureCaught: string;
+ caveat: string;
+ score: WorkflowScore;
+}
+
+export interface ScoredWorkflowCandidate extends WorkflowCandidate {
+ weightedScore: number;
+}
+
+export interface WorkflowComparisonResult {
+ weights: typeof ROBOT_HAND_WORKFLOW_WEIGHTS;
+ candidates: ScoredWorkflowCandidate[];
+ bestIndividual: ScoredWorkflowCandidate;
+ recommendedCombination: {
+ ids: RobotHandWorkflowId[];
+ score: number;
+ reason: string;
+ };
+}
+
+const CANDIDATES: WorkflowCandidate[] = [
+ {
+ id: 'mechanism-templates',
+ label: 'Mechanism Templates',
+ role: 'generator',
+ inputs: 'mechanism family, target DOF, rough envelope',
+ builds: 'known-good palm, clevis, pin, tendon, and finger modules',
+ failureCaught: 'missing joints, unsupported pins, floating visual parts',
+ caveat: 'Reliable mechanically, but can drift visually when the reference has strong style cues.',
+ score: {
+ physicalCompleteness: 92,
+ referenceFit: 58,
+ parametricStability: 82,
+ automationPotential: 72,
+ validationCoverage: 68,
+ },
+ },
+ {
+ id: 'reference-conditioned',
+ label: 'Reference-Conditioned CAD',
+ role: 'evidence-to-generator',
+ inputs: 'reference image or mesh landmarks plus mechanism family',
+ builds: 'landmark-driven visible proportions with mechanical completion',
+ failureCaught: 'visual drift, wrong thumb angle, lost palm/wrist language',
+ caveat: 'Landmarks are manual until mesh or image extraction is added.',
+ score: {
+ physicalCompleteness: 88,
+ referenceFit: 91,
+ parametricStability: 76,
+ automationPotential: 66,
+ validationCoverage: 72,
+ },
+ },
+ {
+ id: 'mesh-feature-fitting',
+ label: 'Mesh Feature Fitting',
+ role: 'evidence-to-generator',
+ inputs: 'segmented mesh regions, fitted planes, cylinders, boxes, axes',
+ builds: 'CAD primitives fitted to visible mesh features',
+ failureCaught: 'bad primitive fit, missing shaft axes, repeated-module mismatch',
+ caveat: 'Promising for automation, but bad segmentation can create false confidence.',
+ score: {
+ physicalCompleteness: 58,
+ referenceFit: 86,
+ parametricStability: 54,
+ automationPotential: 88,
+ validationCoverage: 50,
+ },
+ },
+ {
+ id: 'master-skeleton',
+ label: 'Master Skeleton',
+ role: 'skeleton',
+ inputs: 'datums, joint centers, axes, envelopes, motion arcs',
+ builds: 'stable parametric skeleton that downstream solids follow',
+ failureCaught: 'sideways hands, broken axes, unstable edits, impossible motion',
+ caveat: 'Best as a control layer; still needs either templates or reference evidence for solids.',
+ score: {
+ physicalCompleteness: 84,
+ referenceFit: 64,
+ parametricStability: 94,
+ automationPotential: 62,
+ validationCoverage: 74,
+ },
+ },
+ {
+ id: 'validation-loop',
+ label: 'Validation Loop',
+ role: 'validator',
+ inputs: 'candidate assembly, reference evidence, physical requirements',
+ builds: 'acceptance gates, scoring, repair hints, reject/pass decision',
+ failureCaught: 'floating parts, invalid mates, collisions, weak loads, visual drift',
+ caveat: 'This is not a generator; it decides whether a generated model is acceptable.',
+ score: {
+ physicalCompleteness: 78,
+ referenceFit: 72,
+ parametricStability: 70,
+ automationPotential: 76,
+ validationCoverage: 96,
+ },
+ },
+];
+
+function weightedScore(score: WorkflowScore): number {
+ const weights = ROBOT_HAND_WORKFLOW_WEIGHTS;
+ return Math.round(
+ score.physicalCompleteness * weights.physicalCompleteness
+ + score.referenceFit * weights.referenceFit
+ + score.parametricStability * weights.parametricStability
+ + score.automationPotential * weights.automationPotential
+ + score.validationCoverage * weights.validationCoverage,
+ );
+}
+
+export function compareRobotHandWorkflows(): WorkflowComparisonResult {
+ const candidates = CANDIDATES
+ .map((candidate) => ({ ...candidate, weightedScore: weightedScore(candidate.score) }))
+ .sort((a, b) => b.weightedScore - a.weightedScore);
+
+ const byId = new Map(candidates.map((candidate) => [candidate.id, candidate]));
+ const comboIds: RobotHandWorkflowId[] = ['reference-conditioned', 'master-skeleton', 'validation-loop'];
+ const comboScore = Math.round(comboIds.reduce((sum, id) => sum + (byId.get(id)?.weightedScore ?? 0), 0) / comboIds.length);
+
+ return {
+ weights: ROBOT_HAND_WORKFLOW_WEIGHTS,
+ candidates,
+ bestIndividual: candidates[0],
+ recommendedCombination: {
+ ids: comboIds,
+ score: comboScore,
+ reason: 'Reference-conditioned CAD preserves visible fit, master skeletons provide stable parametrics, and the validation loop supplies physical acceptance.',
+ },
+ };
+}
+
+function bar(value: number): string {
+ return ``;
+}
+
+export function renderWorkflowComparisonHtml(result = compareRobotHandWorkflows()): string {
+ const cards = result.candidates.map((candidate, index) => `
+
+
+
+
#${index + 1} ${candidate.role}
+
${candidate.label}
+
+
${candidate.weightedScore}
+
+
+ ${candidate.id === 'reference-conditioned' ? referenceSvg() : ''}
+ ${candidate.id === 'mechanism-templates' ? templateSvg() : ''}
+ ${candidate.id === 'mesh-feature-fitting' ? meshFitSvg() : ''}
+ ${candidate.id === 'master-skeleton' ? skeletonSvg() : ''}
+ ${candidate.id === 'validation-loop' ? validationSvg() : ''}
+
+ Inputs: ${candidate.inputs}
+ Builds: ${candidate.builds}
+ Failure caught: ${candidate.failureCaught}
+ Caveat: ${candidate.caveat}
+
+ Physics ${bar(candidate.score.physicalCompleteness)}
+ Reference fit ${bar(candidate.score.referenceFit)}
+ Stability ${bar(candidate.score.parametricStability)}
+ Automation ${bar(candidate.score.automationPotential)}
+ Validation ${bar(candidate.score.validationCoverage)}
+
+
+ `).join('');
+
+ const html = `
+ Robot Hand Workflow Benchmark
+ Thin prototypes scored against the same target: a reference-matched, physically valid parametric robot hand.
+
+
Recommended path: ${result.recommendedCombination.ids.join(' + ')}
+
${result.recommendedCombination.reason}
+
+ ${cards}
+ `;
+
+ return `${html.replace(/[ \t]+$/gm, '').trim()}\n`;
+}
+
+function templateSvg(): string {
+ return `
+
+
+
+
+
+
+
+
+ `;
+}
+
+function referenceSvg(): string {
+ return `
+
+
+
+
+
+
+ `;
+}
+
+function meshFitSvg(): string {
+ return `
+
+
+
+
+
+ `;
+}
+
+function skeletonSvg(): string {
+ return `
+
+
+
+
+
+
+
+
+ `;
+}
+
+function validationSvg(): string {
+ return `
+
+
+
+
+
+
+
+ `;
+}
+
+export function writeWorkflowComparisonHtml(path = 'artifacts/robot-hand-workflow-comparison/index.html'): string {
+ const absPath = resolve(path);
+ mkdirSync(dirname(absPath), { recursive: true });
+ writeFileSync(absPath, renderWorkflowComparisonHtml());
+ return absPath;
+}
+
+const invokedPath = process.argv[1] ? resolve(process.argv[1]) : undefined;
+const currentPath = fileURLToPath(import.meta.url);
+if (invokedPath === currentPath) {
+ const output = writeWorkflowComparisonHtml();
+ console.log(output);
+}
diff --git a/src/agent/mcp/registry/reviewPipelineTools.ts b/src/agent/mcp/registry/reviewPipelineTools.ts
index f2f4142d5..957139dea 100644
--- a/src/agent/mcp/registry/reviewPipelineTools.ts
+++ b/src/agent/mcp/registry/reviewPipelineTools.ts
@@ -28,6 +28,31 @@ export const reviewPipelineToolEntries: ToolRegistryEntry[] = [
},
includePoseEnvelope: { type: 'boolean', description: 'Whether to sample declared mate limits. Default true.' },
includeInterference: { type: 'boolean', description: 'Whether sampled poses run BREP interference checks. Default true.' },
+ requirePhysicalUseCase: {
+ type: 'boolean',
+ description: 'When true, articulated assemblies must declare arm.physicalUseCase(...) evidence: loads, contacts, stable parts, and actuator limits.',
+ },
+ includePhysicalUseCaseReachability: {
+ type: 'boolean',
+ description: 'Run targeted physical-use-case reachability sampling over scalar-limited mates named in actuatorLimits. Reject contacts that cannot get within criteria.maxSlipMm and multi-contact use cases that cannot satisfy every contact in the same sampled actuator pose. Samples revolute/cylindrical/pin-slot limitsDeg and prismatic limitsMm. Defaults to requirePhysicalUseCase.',
+ },
+ includePhysicalUseCaseStatics: {
+ type: 'boolean',
+ description: 'Run opt-in pose-bound quasi-static certification at the exact common-contact samples: conservative friction/capacity, world force and moment balance, and finite-difference revolute actuator torque. Returns physicalUseCaseStaticCertificates on success; sampled linearized failures remain blocking diagnostics.',
+ },
+ includePhysicalUseCaseJointReactions: {
+ type: 'boolean',
+ description: 'Derive exact-pose reaction wrenches through uniquely rooted articulated trees and compare every loaded mate against a complete declared resultant force/moment envelope. Implies physical-use-case reachability and statics.',
+ },
+ includePhysicalUseCaseJointStructure: {
+ type: 'boolean',
+ description: 'Run geometry/material clevis double-shear, pin-bending, bearing, tear-out, and net-section checks with minimum factor of safety 2. Unsupported axial or perpendicular-moment load cases remain blockers. Implies joint reactions, statics, and reachability.',
+ },
+ physicalUseCaseReachabilitySamplesPerMate: {
+ type: 'integer',
+ minimum: 1,
+ description: 'Samples per scalar-limited actuator mate for physical-use-case contact reachability. Samples revolute/cylindrical/pin-slot limitsDeg and prismatic limitsMm. Default 3; total targeted combinations are capped.',
+ },
samplesPerMate: {
type: 'integer',
minimum: 1,
@@ -166,6 +191,10 @@ export const reviewPipelineToolEntries: ToolRegistryEntry[] = [
gripperAperture: { type: 'object', description: 'Optional gripper aperture request forwarded to review_cad.' },
stopOnPass: { type: 'boolean', description: 'Stop after the first attempt that is functional and passes the quality gate. Default true.' },
requireVisualReview: { type: 'boolean', description: 'Require screenshot-backed visualReview with structured checks before accepting an attempt. Default true; set false only for explicit non-visual batch checks.' },
+ requirePhysicalAcceptance: {
+ type: 'boolean',
+ description: 'Require declared physicalUseCase common-pose reachability and pose-bound quasi-static certification before accepting an attempt. Design-loop also enables this automatically when an attempt script calls physicalUseCase(...).',
+ },
allowReviewWarnings: {
type: 'array',
items: { type: 'string' },
diff --git a/src/agent/mcp/toolOutputSchemas.ts b/src/agent/mcp/toolOutputSchemas.ts
index 11d370f58..9c346435b 100644
--- a/src/agent/mcp/toolOutputSchemas.ts
+++ b/src/agent/mcp/toolOutputSchemas.ts
@@ -315,9 +315,86 @@ export const TOOL_OUTPUT_SCHEMAS: Record = {
description: 'Connector workspace bounds.',
},
gripperAperture: { type: 'object', additionalProperties: true },
+ physicalUseCaseStaticCertificates: {
+ type: 'array',
+ items: { type: 'object', additionalProperties: true },
+ description: 'Verified sampled quasi-static certificates with residual wrench, contact forces, and actuator torque evidence.',
+ },
+ physicalUseCaseJointReactionCertificates: {
+ type: 'array',
+ description: 'Exact-pose parent-on-child joint reaction wrench certificates in N, mm, and Nmm.',
+ items: {
+ type: 'object',
+ properties: {
+ useCaseName: { type: 'string' },
+ poses: { type: 'object', additionalProperties: true },
+ reactions: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ mateName: { type: 'string' },
+ parentPart: { type: 'string' },
+ childPart: { type: 'string' },
+ pointWorldMm: { type: 'array', items: { type: 'number' } },
+ axisWorld: { type: 'array', items: { type: 'number' } },
+ forceWorldN: { type: 'array', items: { type: 'number' } },
+ momentWorldNmm: { type: 'array', items: { type: 'number' } },
+ resultantForceN: { type: 'number' },
+ resultantMomentNmm: { type: 'number' },
+ axialForceN: { type: 'number' },
+ radialForceN: { type: 'number' },
+ axisMomentNmm: { type: 'number' },
+ bendingMomentNmm: { type: 'number' },
+ },
+ required: [
+ 'mateName', 'parentPart', 'childPart', 'pointWorldMm', 'axisWorld',
+ 'forceWorldN', 'momentWorldNmm', 'resultantForceN',
+ 'resultantMomentNmm', 'axialForceN', 'radialForceN',
+ 'axisMomentNmm', 'bendingMomentNmm',
+ ],
+ additionalProperties: false,
+ },
+ },
+ },
+ required: ['useCaseName', 'poses', 'reactions'],
+ additionalProperties: false,
+ },
+ },
+ physicalUseCaseJointStructuralCertificates: {
+ type: 'array',
+ description: 'Per-joint declared-envelope and geometry/material clevis strength evidence.',
+ items: {
+ type: 'object',
+ properties: {
+ useCaseName: { type: 'string' },
+ poses: { type: 'object', additionalProperties: true },
+ joints: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ mateName: { type: 'string' },
+ envelope: { type: 'object', additionalProperties: true },
+ structure: { type: 'object', additionalProperties: true },
+ },
+ required: ['mateName', 'envelope'],
+ additionalProperties: false,
+ },
+ },
+ },
+ required: ['useCaseName', 'poses', 'joints'],
+ additionalProperties: false,
+ },
+ },
fitness: { type: 'object', additionalProperties: true, description: 'Mechanism fitness verdict incl. repairMode.' },
repairContext: { type: 'object', additionalProperties: true },
rawInterferencePairs: { type: 'array', items: { type: 'object', additionalProperties: true } },
+ interferenceSummary: {
+ type: 'object',
+ additionalProperties: true,
+ description: 'Classified interference counts and pairs: raw, contact-noise, actionable, and capMm3.',
+ },
mechanism: { type: 'string' },
mechanismFailures: { type: 'array', items: { type: 'object', additionalProperties: true } },
suggestedRepairPrompt: { type: 'string', description: 'Structured repair prompt (failure / repair path).' },
diff --git a/src/agent/mcp/tools/designLoop.ts b/src/agent/mcp/tools/designLoop.ts
index 8857713a5..11f32f5e8 100644
--- a/src/agent/mcp/tools/designLoop.ts
+++ b/src/agent/mcp/tools/designLoop.ts
@@ -49,6 +49,7 @@ export interface DesignLoopInput {
stopOnPass?: boolean;
allowReviewWarnings?: string[];
requireVisualReview?: boolean;
+ requirePhysicalAcceptance?: boolean;
outputRecordPath?: string;
recordTitle?: string;
}
@@ -171,6 +172,10 @@ export async function designLoopTool(input: DesignLoopInput): Promise diagnostic.severity === 'warning')
- .filter((diagnostic) => !input.allowReviewWarnings.includes(diagnostic.code))
+ .concat(input.review.diagnostics.filter(isPreservedReviewFactDiagnostic))
+ .filter((diagnostic) =>
+ isPreservedReviewFactDiagnostic(diagnostic) ||
+ !input.allowReviewWarnings.includes(diagnostic.code),
+ )
.map((diagnostic) => ({
code: diagnostic.code,
severity: diagnostic.severity,
@@ -280,17 +297,56 @@ function toAttemptResult(input: {
? buildQualityRepairPrompt(reviewFacts)
: input.review.ok
? fitness?.repairDirective ?? 'No repair needed. Preserve the current design and rerun review_cad after changes.'
- : buildFailureRepairPrompt(input.review),
+ : buildFailureRepairPrompt(input.review, reviewFacts),
};
}
-function buildFailureRepairPrompt(review: Extract): string {
+function containsPhysicalUseCaseDeclaration(source: string): boolean {
+ return /\bphysicalUseCase\s*\(/.test(source);
+}
+
+function isPreservedPhysicalAcceptanceDiagnostic(
+ diagnostic: ReviewCadOutput['diagnostics'][number],
+): boolean {
+ return isPhysicalAcceptanceCode(diagnostic.code);
+}
+
+function isPhysicalAcceptanceCode(code: string): boolean {
+ return (
+ code === 'assembly.physical-use-case.contact-unreachable' ||
+ code === 'assembly.physical-use-case.simultaneous-contacts-unreachable' ||
+ code === 'assembly.physical-use-case.static-input-incomplete' ||
+ code === 'assembly.physical-use-case.static-equilibrium-unmet' ||
+ code === 'assembly.physical-use-case.static-actuator-torque-insufficient' ||
+ code.startsWith('assembly.physical-use-case.joint-')
+ );
+}
+
+function isPreservedTopologyDiagnostic(
+ diagnostic: ReviewCadOutput['diagnostics'][number],
+): boolean {
+ return (
+ diagnostic.code.startsWith('assembly.connectivity.') ||
+ diagnostic.code.startsWith('assembly.joint-topology.')
+ );
+}
+
+function isPreservedReviewFactDiagnostic(
+ diagnostic: ReviewCadOutput['diagnostics'][number],
+): boolean {
+ return isPreservedPhysicalAcceptanceDiagnostic(diagnostic) || isPreservedTopologyDiagnostic(diagnostic);
+}
+
+function buildFailureRepairPrompt(
+ review: Extract,
+ reviewFacts: readonly DesignLoopAttemptResult['reviewFacts'][number][] = [],
+): string {
const context = review.repairContext;
if (context === undefined) {
// Defensive fallback: predecessor commits guarantee repairContext on every
// review output, but keep the prior string for callers that injected an
// older ReviewCadOutput shape (unit tests, fixtures).
- return review.suggestedRepairPrompt;
+ return appendPreservedReviewFacts(review.suggestedRepairPrompt, reviewFacts);
}
const severityByKey = indexDiagnosticSeverity(review.diagnostics);
const blockingLines = context.blockingReasons.map((reason) => `- ${reason}`);
@@ -321,9 +377,24 @@ function buildFailureRepairPrompt(review: Extract
+ isPhysicalAcceptanceCode(fact.code) ||
+ fact.code.startsWith('assembly.connectivity.') ||
+ fact.code.startsWith('assembly.joint-topology.'));
+ if (preservedFacts.length === 0) return prompt;
+ const lines = preservedFacts.map((fact) =>
+ `- ${fact.code}: ${fact.message}${fact.hint ? ` Hint: ${fact.hint}` : ''}`,
+ );
+ return `${prompt}\n\nPreserved acceptance facts:\n${lines.join('\n')}`;
}
function renderTopDiagnostic(
diff --git a/src/agent/mcp/tools/listApi.ts b/src/agent/mcp/tools/listApi.ts
index 542389f80..601547e08 100644
--- a/src/agent/mcp/tools/listApi.ts
+++ b/src/agent/mcp/tools/listApi.ts
@@ -131,7 +131,7 @@ export const GLOBALS: ApiEntry[] = [
{ name: 'sew', signature: '(surfaces: Surface[], opts?: { tolerance?: number; requireClosed?: boolean }) => Shape', description: 'Stitch N surfaces into a shell or closed solid via OCCT `BRepBuilderAPI_Sewing`. Pass surfaces from `nurbsSurface()`, `surfaceFromCurves()`, `surfaceFromBoundary()`, or chained `.trimTo()` calls. Edges within `tolerance` mm (default 1e-6) of each other are merged. When `requireClosed: true` and the result is still an open shell at lower time, the lowerer emits `feature.surface-sew.open-shell` (error) instead of returning the partial shell. Returns a `Shape` that flows into booleans, export, and fillet pipelines. Throws `feature.invalid-args` if no surfaces are supplied.' },
{ name: 'q', signature: '{ face, edge, vertex, connector, part, solid, createdBy, ownedByPart, ownerPart, union, intersection, subtraction, containsPoint, closestTo, geometryType, withLabel, withFeatureName, nthElement, fromString, nothing, everything }', description: 'Query DSL constructor namespace. Every constructor builds a lazy `Query` value (phantom-typed `Query`, `Query`, etc.) carrying a serializable AST. Chain with `.and(filter)` / `.or(other)` / `.minus(other)` / `.nth(i)` / `.asLenient()`; consume with `.evaluate(scene)` / `.evaluateUnique(scene)` or pass through to a feature op once consumer integration ships. Strings (`@kc[owner/kind/name]`) are sugar over the same internal Query value — both forms produce identical OCCT handles. The namespace is also reachable as `kc.q.*`. See `kernelcad-features/SKILL.md` (Query selectors, Cookbook — Query DSL) and `kernelcad-assemblies/SKILL.md` (Cookbook — Query DSL for assemblies).' },
{ name: 'kinematic', signature: 'KinematicFacade', description: 'Namespace with four in-process feasibility checks an agent can run before declaring a mechanism design done: `kinematic.checkMountingHoleConsistency(arm)` (fastener-side hole agreement; dispatches to the v0.7.4 substrate), `kinematic.checkSweptCollision(arm, opts?)` (sampled-pose collision sweep across declared joint ranges), `kinematic.checkReachable(arm, opts)` (IK reachability — analytical Pieper first, DLS numeric fallback), `kinematic.checkLoadCapacity(arm, opts?)` (closed-form Euler-Bernoulli beam load check). Every entry is sync compute wrapped in async and returns a typed envelope with `source: "local"` and a `diagnostics` array.' },
- { name: 'joint', signature: '{ clevis(opts: ClevisJointOptions): ClevisJoint }', description: 'G1 (mechanism delivery): constructive joint-hardware primitives. `joint.clevis({ parentBody, childBody, axis, pivotParent, pivotChild?, limitsDeg?, style?, liftPivot?, liftDir? })` builds the canonical revolute-joint hardware (two fork plates on the parent, one tongue on the child, a pin drilled through both knuckles) guaranteed correct by construction: bridge tabs outside the tongue\'s swing envelope, pivot lifted by max rotated-tongue reach, one-pass drill through both knuckles AFTER fork/tongue are unioned into their bodies, and pin cap heads flush against the outer fork faces. Returns `{ parentGeometry, childGeometry, parentConnector, childConnector, pivot, style }` — assign the geometry back to each part\'s Shape and wire `partRef.connector(name, { type: "axis", origin, axis })` + the `arm.mate(..., "revolute", ...)` directly to the returned connector specs (no coordinate fiddling). Use INSTEAD of hand-rolling forks/tongues/pins from box/cylinder/union — see `kernelcad-kinematic/SKILL.md` "Use joint.clevis(...) for revolute joints".' },
+ { name: 'joint', signature: '{ clevis(opts: ClevisJointOptions): ClevisJoint; supportedServoRevolute(arm: Assembly, opts: SupportedServoRevoluteOptions): SupportedServoRevoluteResult }', description: 'Mechanism-delivery joint helpers. `joint.clevis({ parentBody, childBody, axis, pivotParent, pivotChild?, limitsDeg?, style?, liftPivot?, liftDir? })` builds canonical revolute-joint hardware (fork, tongue, drilled bore, pin) and returns geometry plus connector specs for `arm.mate(..., "revolute", ...)`. `joint.supportedServoRevolute(arm, { name, mate, support, supportMount, output, axis, minBearingLengthMm?, bodySizeMm? })` adds a seated servo actuator part named `${name}-servo`, fastens its `mount` frame to `supportMount`, and declares `arm.mechanicalJoint(name, { mate, actuator, shaft: support, supports: [support], output, requiredSupport: { kind: "hinge-bracket", around: axis, supports: [support], minBearingLengthMm: minBearingLengthMm ?? 8 } })`. Current helper shape requires `supportMount` to be a frame connector on `support` itself, and `axis` to be the support-side axis connector of the named revolute mate. It preflights generated part/mate/intent names, required revolute mate, support/output parts, supportMount connector, axis connector, non-empty string fields, and positive finite `bodySizeMm` before mutating the assembly. Use these helpers instead of inventing floating/disconnected actuator or hand-rolled joint geometry.' },
];
export const SHAPE_METHODS: ApiEntry[] = [
diff --git a/src/agent/review/reviewPipeline.ts b/src/agent/review/reviewPipeline.ts
index 712a245b8..ac6284084 100644
--- a/src/agent/review/reviewPipeline.ts
+++ b/src/agent/review/reviewPipeline.ts
@@ -25,10 +25,25 @@ import {
reviewMechanicalTransmission,
type MechanicalTransmissionDiagnostic,
} from '../../modeling/mates/mechanicalTransmission';
+import {
+ reviewPhysicalUseCasesWithReachability,
+ type PhysicalUseCaseDiagnostic,
+ type PhysicalUseCaseJointStructuralCertificate,
+} from '../../modeling/mates/physicalUseCase';
+import type { PhysicalUseCaseStaticCertificate } from '../../modeling/mates/physicalUseCaseStatics';
+import type { PhysicalUseCaseJointReactionCertificate } from '../../modeling/mates/physicalUseCaseJointReactions';
+import {
+ reviewJointTopology,
+ type JointTopologyDiagnostic,
+} from '../../modeling/mates/jointTopology';
import type { ValidatorDiagnostic, ValidatorStatus } from '../../modeling/mates/validator';
import { validateAssemblyWithMates } from '../../modeling/mates/validator';
import type { InterferencePair } from '../../modeling/runtime/detectInterferences';
import { detectInterferences } from '../../modeling/runtime/detectInterferences';
+import {
+ summarizeInterferencePairs,
+ type InterferenceSummary,
+} from '../../modeling/runtime/interferenceClassification';
import { isSceneBackend } from '../../kernel/backends/sceneBackend';
import { analyzeContactGraph, type ContactGraphResult } from '../../modeling/runtime/contactGraph';
import type { BuiltModel } from '../../modeling/buildModel';
@@ -55,6 +70,12 @@ export interface ReviewCadInput {
gripperAperture?: GripperApertureRequest;
samplesPerMate?: number;
combinatorial?: boolean;
+ requirePhysicalUseCase?: boolean;
+ includePhysicalUseCaseReachability?: boolean;
+ includePhysicalUseCaseStatics?: boolean;
+ includePhysicalUseCaseJointReactions?: boolean;
+ includePhysicalUseCaseJointStructure?: boolean;
+ physicalUseCaseReachabilitySamplesPerMate?: number;
}
export interface RepairContext {
@@ -86,7 +107,7 @@ export type ReviewCadOutput =
| {
ok: true;
featureCount: number;
- diagnostics: Array;
+ diagnostics: Array;
assembly: string;
validator: {
status: ValidatorStatus;
@@ -97,6 +118,9 @@ export type ReviewCadOutput =
poseEnvelope?: PoseEnvelopeReviewResult;
connectorWorkspace?: PoseEnvelopeReviewResult['connectorWorkspace'];
gripperAperture?: PoseEnvelopeReviewResult['gripperAperture'];
+ physicalUseCaseStaticCertificates?: readonly PhysicalUseCaseStaticCertificate[];
+ physicalUseCaseJointReactionCertificates?: readonly PhysicalUseCaseJointReactionCertificate[];
+ physicalUseCaseJointStructuralCertificates?: readonly PhysicalUseCaseJointStructuralCertificate[];
fitness: MechanismFitnessResult;
repairContext: RepairContext;
/**
@@ -109,6 +133,7 @@ export type ReviewCadOutput =
* Validity tab + throw path.
*/
rawInterferencePairs: ReadonlyArray;
+ interferenceSummary: InterferenceSummary;
/** Physics-loop verdict (P1). Always present when an assembly was
* selected. `'unverified'` when the mechanism probe didn't run. */
mechanism: MechanismVerdict;
@@ -124,7 +149,7 @@ export type ReviewCadOutput =
| {
ok: false;
featureCount: number;
- diagnostics: Array;
+ diagnostics: Array;
assembly?: string;
validator?: {
status: ValidatorStatus;
@@ -135,6 +160,9 @@ export type ReviewCadOutput =
poseEnvelope?: PoseEnvelopeReviewResult;
connectorWorkspace?: PoseEnvelopeReviewResult['connectorWorkspace'];
gripperAperture?: PoseEnvelopeReviewResult['gripperAperture'];
+ physicalUseCaseStaticCertificates?: readonly PhysicalUseCaseStaticCertificate[];
+ physicalUseCaseJointReactionCertificates?: readonly PhysicalUseCaseJointReactionCertificate[];
+ physicalUseCaseJointStructuralCertificates?: readonly PhysicalUseCaseJointStructuralCertificate[];
fitness?: MechanismFitnessResult;
repairContext: RepairContext;
suggestedRepairPrompt: string;
@@ -144,6 +172,7 @@ export type ReviewCadOutput =
* `ok: false`, so the Studio HUD can still report the count.
*/
rawInterferencePairs?: ReadonlyArray;
+ interferenceSummary?: InterferenceSummary;
/** Physics-loop verdict (P1). `'unverified'` for pre-build failures
* where no assembly reached the probe. */
mechanism?: MechanismVerdict;
@@ -159,7 +188,9 @@ type ReviewDiagnostic =
| PoseEnvelopeDiagnostic
| MechanicalPlausibilityDiagnostic
| MechanicalIntentDiagnostic
- | MechanicalTransmissionDiagnostic;
+ | MechanicalTransmissionDiagnostic
+ | JointTopologyDiagnostic
+ | PhysicalUseCaseDiagnostic;
export const REVIEW_PIPELINE_STAGES = [
'evaluate-source',
@@ -167,6 +198,7 @@ export const REVIEW_PIPELINE_STAGES = [
'default-pose-geometry',
'mechanical-review',
'pose-envelope',
+ 'physical-use-case',
'mechanism-truth',
'fitness-and-repair',
] as const;
@@ -208,8 +240,9 @@ export async function runReviewPipeline(input: ReviewCadInput): Promise,
+ poseEnvelope: PoseEnvelopeReviewResult | undefined,
+) {
+ const includeReachability =
+ input.includePhysicalUseCaseReachability ?? input.requirePhysicalUseCase === true;
+ return reviewPhysicalUseCasesWithReachability(arm, {
+ requirePhysicalUseCase: input.requirePhysicalUseCase,
+ poseEnvelope,
+ includeReachability,
+ includeStatics: input.includePhysicalUseCaseStatics,
+ includeJointReactions: input.includePhysicalUseCaseJointReactions,
+ includeJointStructure: input.includePhysicalUseCaseJointStructure,
+ reachabilitySamplesPerMate: input.physicalUseCaseReachabilitySamplesPerMate,
+ });
+}
+
function collectReviewDiagnostics(
evaluation: Awaited>['evaluation'],
mechanicalReview: Awaited>,
+ physicalUseCases: Awaited>,
poseEnvelope: PoseEnvelopeReviewResult | undefined,
): ReviewDiagnostic[] {
return [
...withNextActions(evaluation.diagnostics),
+ ...mechanicalReview.jointTopology.diagnostics,
...mechanicalReview.validator.diagnostics,
...mechanicalReview.mechanicalPlausibility.diagnostics,
...mechanicalReview.mechanicalIntent.diagnostics,
...mechanicalReview.mechanicalTransmission.diagnostics,
+ ...physicalUseCases.diagnostics,
...(poseEnvelope?.diagnostics ?? []),
];
}
@@ -445,6 +521,7 @@ async function runFitnessAndRepairStage(input: {
input: ReviewCadInput;
mechanism: MechanismVerdict;
mechanicalReview: Awaited>;
+ physicalUseCases: Awaited>;
poseEnvelope: PoseEnvelopeReviewResult | undefined;
}): Promise<{ fitness: MechanismFitnessResult; ok: boolean; repairContext: RepairContext }> {
const fitness = summarizeMechanismFitness({
@@ -452,6 +529,9 @@ async function runFitnessAndRepairStage(input: {
mechanicalPlausibilityDiagnostics: input.mechanicalReview.mechanicalPlausibility.diagnostics,
mechanicalIntentDiagnostics: input.mechanicalReview.mechanicalIntent.diagnostics,
mechanicalTransmissionDiagnostics: input.mechanicalReview.mechanicalTransmission.diagnostics,
+ jointTopologyDiagnostics: input.mechanicalReview.jointTopology.diagnostics,
+ physicalUseCaseDiagnostics: input.physicalUseCases.diagnostics,
+ physicalUseCaseCount: input.physicalUseCases.checkedUseCaseCount,
poseEnvelope: input.poseEnvelope,
trackConnectors: input.poseEnvelope !== undefined ? input.input.trackConnectors : undefined,
});
@@ -484,6 +564,25 @@ function safeAnalyzeContactGraph(model: BuiltModel): ContactGraphResult | undefi
}
}
+function physicalUseCaseConnectorRefs(arm: Assembly): string[] {
+ const refs = new Set();
+ for (const useCase of arm.__physicalUseCases()) {
+ for (const contact of useCase.contacts) {
+ refs.add(contact.a);
+ refs.add(contact.b);
+ }
+ }
+ return [...refs];
+}
+
+function mergeConnectorRefs(
+ explicitRefs: readonly string[] | undefined,
+ inferredRefs: readonly string[],
+): string[] | undefined {
+ if (explicitRefs === undefined && inferredRefs.length === 0) return undefined;
+ return [...new Set([...(explicitRefs ?? []), ...inferredRefs])];
+}
+
/**
* Run pairwise BREP interference detection on the BuiltModel's lowered
* scene for the Studio HUD's raw-count channel. We deliberately read the
@@ -662,7 +761,7 @@ async function buildRepairContext(
}
function buildSuggestedRepairPrompt(
- diagnostics: readonly (CompilerDiagnostic | ValidatorDiagnostic | PoseEnvelopeDiagnostic | MechanicalPlausibilityDiagnostic | MechanicalIntentDiagnostic | MechanicalTransmissionDiagnostic)[],
+ diagnostics: readonly ReviewDiagnostic[],
fitness?: MechanismFitnessResult,
input?: Pick,
): string {
diff --git a/src/modeling/api.ts b/src/modeling/api.ts
index e1255a89f..316366e6d 100644
--- a/src/modeling/api.ts
+++ b/src/modeling/api.ts
@@ -54,7 +54,12 @@ import * as kinematic from '../kinematic';
import type { KinematicFacade } from '../kinematic/types';
import { q as queryNamespace } from '../kernel/naming/queryConstructors';
import { makeJointNamespace } from './joints';
-import type { ClevisJoint, ClevisJointOptions } from './joints/types';
+import type {
+ ClevisJoint,
+ ClevisJointOptions,
+ SupportedServoRevoluteOptions,
+ SupportedServoRevoluteResult,
+} from './joints';
export interface ApiContext {
session: CaptureSession;
@@ -429,7 +434,7 @@ export interface KernelCadApi {
kinematic: KinematicFacade;
/**
- * G1 (mechanism delivery): constructive joint-hardware primitives.
+ * Mechanism-delivery joint helpers.
*
* `joint.clevis({ parentBody, childBody, axis, pivotParent, ... })` builds
* the canonical revolute-joint hardware (two fork plates on the parent,
@@ -447,9 +452,19 @@ export interface KernelCadApi {
* `box`/`cylinder`/`union` — hand-rolled clevises are the leading cause
* of mechanism-delivery failures (see `kernelcad-kinematic` SKILL.md
* "Mechanism delivery — non-bypassable").
+ *
+ * `joint.supportedServoRevolute(arm, { name, mate, support, supportMount,
+ * output, axis, ... })` adds a seated servo actuator part, fastens its
+ * mount to a frame connector on the support part, and declares the
+ * `mechanicalJoint(...)` support contract for the driven revolute mate.
+ * The helper preflights names, refs, mate type, support/output presence,
+ * supportMount frame type, `axis` as the support-side axis connector of
+ * the named revolute mate, and body dimensions before mutating the
+ * assembly.
*/
joint: {
clevis(opts: ClevisJointOptions): ClevisJoint;
+ supportedServoRevolute(arm: Assembly, opts: SupportedServoRevoluteOptions): SupportedServoRevoluteResult;
};
}
diff --git a/src/modeling/capture/assembly.ts b/src/modeling/capture/assembly.ts
index 96635b334..b3387a850 100644
--- a/src/modeling/capture/assembly.ts
+++ b/src/modeling/capture/assembly.ts
@@ -16,12 +16,14 @@ import {
import type { MateCouplingRecord } from '../mates/coupledPoses';
import {
parseConnectorRef,
+ type MateCapacity,
type MateLimitRange,
type MateLoadLimit,
type MatePose,
type MateRecord,
} from '../mates/mate';
import { isCompatiblePair, type MateType } from '../mates/mateTypes';
+import type { ClevisStructuralModel, StructuralMaterial } from '../joints/types';
import {
TENDON_DEFAULT_COIL_DIAMETER_MM,
TENDON_DEFAULT_COIL_TURNS,
@@ -44,6 +46,11 @@ import {
type WorkspaceTargetOpts,
type WorkspaceTargetRecord,
} from '../mates/workspaceTarget';
+import {
+ makePhysicalUseCaseRecord,
+ type PhysicalUseCaseOptions,
+ type PhysicalUseCaseRecord,
+} from '../mates/physicalUseCase';
import { currentValue, toParam, toVec3Param } from '../../shared/runtime/editableHelpers';
import { isParamRef, paramExprToDebugString, type Editable, type ParamRefExpr } from '../../shared/runtime/paramRef';
import { Transform } from '../../shared/runtime/se3';
@@ -153,6 +160,224 @@ function validateLimitRange(
}
}
+const NMM_PER_NM = 1000;
+
+function validateMateCapacityOptions(
+ mateName: string,
+ mateType: MateType,
+ opts: { capacity?: MateCapacity; maxLoad?: MateLoadLimit } | undefined,
+): void {
+ const capacity: unknown = opts?.capacity;
+ const maxLoad: unknown = opts?.maxLoad;
+ if (capacity !== undefined && maxLoad !== undefined) {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `assembly.mate.capacity-conflict: mate '${mateName}' cannot declare both capacity.envelope (N and Nmm) and deprecated maxLoad (N and Nm); use capacity.envelope only.`,
+ undefined,
+ `invalid-args.assembly.mate-capacity-conflict — replace maxLoad with capacity: { envelope: { maxResultantForceN, maxResultantMomentNmm } } using N and Nmm.`,
+ );
+ }
+
+ if (capacity !== undefined) {
+ if (!isMateOptionObject(capacity)) {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `assembly.mate.invalid-capacity: mate '${mateName}' capacity must be an object with an optional envelope.`,
+ undefined,
+ `invalid-args.assembly.mate-invalid-capacity — pass capacity: {} or capacity: { envelope: { maxResultantForceN, maxResultantMomentNmm } } using N and Nmm.`,
+ );
+ }
+ const envelope = capacity.envelope;
+ if (envelope !== undefined) {
+ if (!isMateOptionObject(envelope)) {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `assembly.mate.invalid-capacity: mate '${mateName}' capacity.envelope must be an object containing force and moment ratings.`,
+ undefined,
+ `invalid-args.assembly.mate-invalid-capacity — pass capacity.envelope: { maxResultantForceN, maxResultantMomentNmm } using positive finite N and Nmm values.`,
+ );
+ }
+ validatePositiveFiniteMateValue(
+ mateName,
+ 'capacity.envelope.maxResultantForceN',
+ envelope.maxResultantForceN,
+ 'N',
+ );
+ validatePositiveFiniteMateValue(
+ mateName,
+ 'capacity.envelope.maxResultantMomentNmm',
+ envelope.maxResultantMomentNmm,
+ 'Nmm',
+ );
+ }
+ const structure = capacity.structure;
+ if (structure !== undefined) {
+ if (mateType !== 'revolute') {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `assembly.mate.invalid-capacity: mate '${mateName}' capacity.structure is a clevis revolute model but mate type is '${mateType}'.`,
+ undefined,
+ `invalid-args.assembly.mate-invalid-capacity — attach joint.clevis(...).structural only to its revolute mate.`,
+ );
+ }
+ validateClevisStructuralModel(mateName, structure);
+ }
+ }
+
+ if (maxLoad !== undefined) {
+ if (!isMateOptionObject(maxLoad)) {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `assembly.mate.invalid-capacity: mate '${mateName}' maxLoad must be an object with optional force and torque ratings.`,
+ undefined,
+ `invalid-args.assembly.mate-invalid-capacity — pass maxLoad: {} or maxLoad: { force, torque } using positive finite N and Nm values.`,
+ );
+ }
+ if (maxLoad.force !== undefined) {
+ validatePositiveFiniteMateValue(mateName, 'maxLoad.force', maxLoad.force, 'N');
+ }
+ if (maxLoad.torque !== undefined) {
+ const torqueNm = maxLoad.torque;
+ validatePositiveFiniteMateValue(mateName, 'maxLoad.torque', torqueNm, 'Nm');
+ if (typeof torqueNm === 'number' && !Number.isFinite(torqueNm * NMM_PER_NM)) {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `assembly.mate.invalid-capacity: mate '${mateName}' maxLoad.torque=${torqueNm} Nm converts to Nmm as a non-finite value.`,
+ undefined,
+ `invalid-args.assembly.mate-invalid-capacity — reduce maxLoad.torque so its Nm-to-Nmm conversion remains finite, or use capacity.envelope.maxResultantMomentNmm directly.`,
+ );
+ }
+ }
+ }
+}
+
+function validateClevisStructuralModel(mateName: string, value: unknown): asserts value is ClevisStructuralModel {
+ if (!isMateOptionObject(value)) {
+ throwInvalidStructuralModel(mateName, 'capacity.structure must be an object emitted by joint.clevis().');
+ }
+ if (value.kind !== 'clevis-double-shear-v1' || value.source !== 'joint.clevis') {
+ throwInvalidStructuralModel(mateName, "capacity.structure must have kind 'clevis-double-shear-v1' and source 'joint.clevis'.");
+ }
+ if (value.forkPlateCount !== 2) {
+ throwInvalidStructuralModel(mateName, 'capacity.structure.forkPlateCount must equal 2.');
+ }
+ for (const field of [
+ 'pinDiameterMm',
+ 'boreDiameterMm',
+ 'forkPlateThicknessMm',
+ 'tongueThicknessMm',
+ 'forkGapMm',
+ 'supportSpanMm',
+ 'edgeDistanceMm',
+ ] as const) {
+ if (typeof value[field] !== 'number' || !Number.isFinite(value[field]) || value[field] <= 0) {
+ throwInvalidStructuralModel(mateName, `capacity.structure.${field} must be a positive finite mm value.`);
+ }
+ }
+ if (value.materials !== undefined) {
+ if (!isMateOptionObject(value.materials)) {
+ throwInvalidStructuralModel(mateName, 'capacity.structure.materials must be an object when declared.');
+ }
+ for (const role of ['pin', 'fork', 'tongue'] as const) {
+ validateStructuralMaterialDeclaration(mateName, role, value.materials[role]);
+ }
+ }
+}
+
+function validateStructuralMaterialDeclaration(
+ mateName: string,
+ role: 'pin' | 'fork' | 'tongue',
+ value: unknown,
+): asserts value is StructuralMaterial {
+ if (!isMateOptionObject(value)) {
+ throwInvalidStructuralModel(mateName, `capacity.structure.materials.${role} must be an object.`);
+ }
+ if (
+ typeof value.name !== 'string' || value.name.trim() === '' ||
+ value.model !== 'isotropic-ductile' ||
+ typeof value.yieldStrengthMPa !== 'number' || !Number.isFinite(value.yieldStrengthMPa) || value.yieldStrengthMPa <= 0 ||
+ typeof value.bearingStrengthMPa !== 'number' || !Number.isFinite(value.bearingStrengthMPa) || value.bearingStrengthMPa <= 0 ||
+ (value.shearStrengthMPa !== undefined &&
+ (typeof value.shearStrengthMPa !== 'number' || !Number.isFinite(value.shearStrengthMPa) || value.shearStrengthMPa <= 0))
+ ) {
+ throwInvalidStructuralModel(mateName, `capacity.structure.materials.${role} has invalid engineering strength evidence.`);
+ }
+}
+
+function throwInvalidStructuralModel(mateName: string, detail: string): never {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `assembly.mate.invalid-capacity: mate '${mateName}' ${detail}`,
+ undefined,
+ `invalid-args.assembly.mate-invalid-capacity — pass the structural descriptor returned by joint.clevis() without modifying its geometry or material fields.`,
+ );
+}
+
+function copyStructuralMaterial(material: StructuralMaterial): StructuralMaterial {
+ return {
+ name: material.name,
+ model: material.model,
+ yieldStrengthMPa: material.yieldStrengthMPa,
+ bearingStrengthMPa: material.bearingStrengthMPa,
+ ...(material.shearStrengthMPa === undefined ? {} : { shearStrengthMPa: material.shearStrengthMPa }),
+ };
+}
+
+function copyClevisStructuralModel(model: ClevisStructuralModel): ClevisStructuralModel {
+ return {
+ kind: model.kind,
+ source: model.source,
+ pinDiameterMm: model.pinDiameterMm,
+ boreDiameterMm: model.boreDiameterMm,
+ forkPlateThicknessMm: model.forkPlateThicknessMm,
+ forkPlateCount: 2,
+ tongueThicknessMm: model.tongueThicknessMm,
+ forkGapMm: model.forkGapMm,
+ supportSpanMm: model.supportSpanMm,
+ edgeDistanceMm: model.edgeDistanceMm,
+ ...(model.materials === undefined ? {} : {
+ materials: {
+ pin: copyStructuralMaterial(model.materials.pin),
+ fork: copyStructuralMaterial(model.materials.fork),
+ tongue: copyStructuralMaterial(model.materials.tongue),
+ },
+ }),
+ };
+}
+
+function copyMateCapacity(capacity: MateCapacity): MateCapacity {
+ return {
+ ...(capacity.envelope === undefined ? {} : {
+ envelope: {
+ maxResultantForceN: capacity.envelope.maxResultantForceN,
+ maxResultantMomentNmm: capacity.envelope.maxResultantMomentNmm,
+ },
+ }),
+ ...(capacity.structure === undefined ? {} : {
+ structure: copyClevisStructuralModel(capacity.structure),
+ }),
+ };
+}
+
+function isMateOptionObject(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function validatePositiveFiniteMateValue(
+ mateName: string,
+ field: string,
+ value: unknown,
+ unit: 'N' | 'Nm' | 'Nmm',
+): void {
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) return;
+ throw new KernelError(
+ 'feature.invalid-args',
+ `assembly.mate.invalid-capacity: mate '${mateName}' ${field} must be a positive finite ${unit} value.`,
+ undefined,
+ `invalid-args.assembly.mate-invalid-capacity — pass ${field} as a positive finite value in ${unit}.`,
+ );
+}
+
function validateMechanicalIntentName(field: string, value: string): void {
if (typeof value === 'string' && value.trim().length > 0) return;
throw new KernelError(
@@ -224,6 +449,8 @@ export type AssemblyCrossSection =
readonly lengthMm: number;
};
+export type AssemblyPartRole = 'structure' | 'contact-target';
+
export interface AssemblyPartOpts {
at?: EditableVec3;
connectors?: Record;
@@ -243,6 +470,10 @@ export interface AssemblyPartOpts {
* declaration the beam path fires K7 `kinematic.load.beam-not-applicable`
* on any load applied to the part. */
crossSection?: AssemblyCrossSection;
+ /** Topology role. Defaults to `structure`; `contact-target` marks external
+ * objects used for contact/load scenarios that are not structural members
+ * of the mechanism graph. */
+ role?: AssemblyPartRole;
}
export interface MechanicalJointIntentOpts {
@@ -258,6 +489,18 @@ export interface MechanicalJointIntentRecord extends MechanicalJointIntentOpts {
readonly name: string;
}
+export interface JointSupportIntentOpts {
+ readonly mate: string;
+ readonly shaft: string;
+ readonly supports: readonly string[];
+ readonly output: string;
+ readonly requiredSupport?: MechanicalJointSupportRequirement;
+}
+
+export interface JointSupportIntentRecord extends JointSupportIntentOpts {
+ readonly name: string;
+}
+
export type TransmissionKind =
| 'direct-horn'
| 'link-rod'
@@ -372,6 +615,7 @@ export interface AssemblyJointStored {
export interface AssemblyPartStored extends AssemblyPartRef {
readonly originalShape: Shape;
readonly connectParentId?: FeatureId;
+ readonly role?: AssemblyPartRole;
/** Per-part material density in kg/m^3, copied from `AssemblyPartOpts.density`.
* Read by the URDF / SDF export inertial-block emitters. Undefined when
* the script did not declare a density on `arm.part(...)`. */
@@ -444,7 +688,9 @@ export class Assembly {
*/
private readonly tendons: TendonRecord[] = [];
private readonly mechanicalJointIntents: MechanicalJointIntentRecord[] = [];
+ private readonly jointSupportIntents: JointSupportIntentRecord[] = [];
private readonly transmissionIntents: TransmissionIntentRecord[] = [];
+ private readonly physicalUseCases: PhysicalUseCaseRecord[] = [];
/**
* v0.7 Slice 1 — declarative workspace-reachability targets from
* `arm.workspace(connectorRef, opts)`. Consumed by
@@ -497,6 +743,14 @@ export class Assembly {
if (opts.crossSection !== undefined) {
validateCrossSection(name, shape.id, opts.crossSection);
}
+ if (opts.role !== undefined && opts.role !== 'structure' && opts.role !== 'contact-target') {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `assembly.part.invalid-role: part '${name}' role must be 'structure' or 'contact-target'; got ${formatScalarForError(opts.role)}.`,
+ shape.id,
+ `invalid-args.assembly.part-invalid-role — pass role: 'contact-target' only for external contact/load targets, or omit role for structural parts.`,
+ );
+ }
const connectors = normalizeConnectors(name, shape.id, opts.connectors);
const at = resolvePartPlacement(this.name, name, shape.id, opts.at, connectors, opts.connect);
const record = this.session.assemblyPart(this.name, name, shape, { at, connectors, placedBy: opts.connect });
@@ -530,6 +784,7 @@ export class Assembly {
...(opts.connect !== undefined ? { connectParentId: opts.connect.to.partId } : {}),
...(opts.density !== undefined ? { density: opts.density } : {}),
...(opts.crossSection !== undefined ? { crossSection: opts.crossSection } : {}),
+ ...(opts.role !== undefined ? { role: opts.role } : {}),
};
this.parts.push(stored);
if (opts.connect) {
@@ -624,6 +879,15 @@ export class Assembly {
...(om.pose !== undefined ? { pose: om.pose } : {}),
...(om.limitsDeg !== undefined ? { limitsDeg: om.limitsDeg } : {}),
...(om.limitsMm !== undefined ? { limitsMm: om.limitsMm } : {}),
+ ...(om.capacity !== undefined ? { capacity: copyMateCapacity(om.capacity) } : {}),
+ ...(om.maxLoad !== undefined
+ ? {
+ maxLoad: {
+ ...(om.maxLoad.force !== undefined ? { force: om.maxLoad.force } : {}),
+ ...(om.maxLoad.torque !== undefined ? { torque: om.maxLoad.torque } : {}),
+ },
+ }
+ : {}),
});
}
const requireImportedPart = (origPartName: string, method: 'ref' | 'part'): AssemblyPartRef => {
@@ -834,8 +1098,10 @@ export class Assembly {
pose?: MatePose;
limitsDeg?: MateLimitRange;
limitsMm?: MateLimitRange;
- maxLoad?: MateLoadLimit;
exposure?: 'exposed' | 'concealed';
+ capacity?: MateCapacity;
+ /** @deprecated legacy manual-load API */
+ maxLoad?: MateLoadLimit;
},
): this {
const a = this.resolveMateConnector(aRef);
@@ -857,6 +1123,7 @@ export class Assembly {
);
}
this.validateMateLimits(name, type, opts);
+ validateMateCapacityOptions(name, type, opts);
this.mates.push({
name,
a: aRef,
@@ -865,8 +1132,16 @@ export class Assembly {
...(opts?.pose !== undefined ? { pose: opts.pose } : {}),
...(opts?.limitsDeg !== undefined ? { limitsDeg: opts.limitsDeg } : {}),
...(opts?.limitsMm !== undefined ? { limitsMm: opts.limitsMm } : {}),
- ...(opts?.maxLoad !== undefined ? { maxLoad: opts.maxLoad } : {}),
...(opts?.exposure !== undefined ? { exposure: opts.exposure } : {}),
+ ...(opts?.capacity !== undefined ? { capacity: copyMateCapacity(opts.capacity) } : {}),
+ ...(opts?.maxLoad !== undefined
+ ? {
+ maxLoad: {
+ ...(opts.maxLoad.force !== undefined ? { force: opts.maxLoad.force } : {}),
+ ...(opts.maxLoad.torque !== undefined ? { torque: opts.maxLoad.torque } : {}),
+ },
+ }
+ : {}),
});
return this;
}
@@ -1113,6 +1388,25 @@ export class Assembly {
return this;
}
+ /**
+ * Declare the physical task this assembly must be able to survive or perform.
+ * This is intentionally generic: loads, contacts, stable parts, and actuator
+ * limits are evidence consumed by review gates before task-specific statics
+ * or MuJoCo simulations are added.
+ */
+ physicalUseCase(name: string, opts: PhysicalUseCaseOptions): this {
+ if (this.physicalUseCases.some((useCase) => useCase.name === name)) {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `assembly.physicalUseCase.duplicate-name: physical use case '${name}' is already declared.`,
+ undefined,
+ `invalid-args.assembly.physical-use-case-duplicate-name — use a unique physicalUseCase name.`,
+ );
+ }
+ this.physicalUseCases.push(makePhysicalUseCaseRecord(name, opts));
+ return this;
+ }
+
/**
* Declare an SRDF planning group. Either a chain form (base->tip) or an
* enumeration of joint / link names. Consumed by `export_model({
@@ -1283,6 +1577,76 @@ export class Assembly {
return this;
}
+ jointSupport(name: string, opts: JointSupportIntentOpts): this {
+ validateMechanicalIntentName('name', name);
+ if (this.jointSupportIntents.some((intent) => intent.name === name)) {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `assembly.jointSupport.duplicate-name: joint support intent '${name}' is already declared.`,
+ undefined,
+ `invalid-args.assembly.joint-support-duplicate-name — use a unique jointSupport name.`,
+ );
+ }
+ validateMechanicalIntentName('mate', opts.mate);
+ validateMechanicalIntentName('shaft', opts.shaft);
+ validateMechanicalIntentName('output', opts.output);
+ if (!Array.isArray(opts.supports) || opts.supports.length === 0) {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `assembly.jointSupport.invalid-ref: joint support intent '${name}' requires at least one support part.`,
+ undefined,
+ `invalid-args.assembly.joint-support-invalid-ref — pass supports: ['support-part-name', ...].`,
+ );
+ }
+ for (const support of opts.supports) {
+ validateMechanicalIntentName('supports[]', support);
+ }
+ if (opts.requiredSupport !== undefined) {
+ validateMechanicalIntentName('requiredSupport.kind', opts.requiredSupport.kind);
+ validateMechanicalIntentName('requiredSupport.around', opts.requiredSupport.around);
+ for (const support of opts.requiredSupport.supports ?? []) {
+ validateMechanicalIntentName('requiredSupport.supports[]', support);
+ }
+ if (
+ opts.requiredSupport.minBearingLengthMm !== undefined &&
+ (!Number.isFinite(opts.requiredSupport.minBearingLengthMm) || opts.requiredSupport.minBearingLengthMm <= 0)
+ ) {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `assembly.jointSupport.invalid-required-support: minBearingLengthMm must be a positive finite number.`,
+ undefined,
+ `invalid-args.assembly.joint-support-invalid-required-support — pass minBearingLengthMm > 0, or omit it.`,
+ );
+ }
+ if (
+ opts.requiredSupport.clearanceMm !== undefined &&
+ (!Number.isFinite(opts.requiredSupport.clearanceMm) || opts.requiredSupport.clearanceMm < 0)
+ ) {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `assembly.jointSupport.invalid-required-support: clearanceMm must be a non-negative finite number.`,
+ undefined,
+ `invalid-args.assembly.joint-support-invalid-required-support — pass clearanceMm >= 0, or omit it.`,
+ );
+ }
+ }
+
+ this.jointSupportIntents.push({
+ name,
+ mate: opts.mate,
+ shaft: opts.shaft,
+ supports: [...opts.supports],
+ output: opts.output,
+ ...(opts.requiredSupport !== undefined ? {
+ requiredSupport: {
+ ...opts.requiredSupport,
+ ...(opts.requiredSupport.supports !== undefined ? { supports: [...opts.requiredSupport.supports] } : {}),
+ },
+ } : {}),
+ });
+ return this;
+ }
+
transmission(name: string, opts: TransmissionIntentOpts): this {
validateMechanicalIntentName('name', name);
if (this.transmissionIntents.some((intent) => intent.name === name)) {
@@ -1521,6 +1885,10 @@ export class Assembly {
return this.workspaceTargets;
}
+ __physicalUseCases(): readonly PhysicalUseCaseRecord[] {
+ return this.physicalUseCases;
+ }
+
/** SRDF planning groups declared via `arm.planningGroup(...)`. */
__planningGroups(): readonly PlanningGroupRecord[] {
return this.planningGroups;
@@ -1562,6 +1930,10 @@ export class Assembly {
return this.mechanicalJointIntents;
}
+ __jointSupportIntents(): readonly JointSupportIntentRecord[] {
+ return this.jointSupportIntents;
+ }
+
__transmissionIntents(): readonly TransmissionIntentRecord[] {
return this.transmissionIntents;
}
diff --git a/src/modeling/joints/clevis.test.ts b/src/modeling/joints/clevis.test.ts
index 92d3f7d11..05252f5bb 100644
--- a/src/modeling/joints/clevis.test.ts
+++ b/src/modeling/joints/clevis.test.ts
@@ -297,6 +297,99 @@ describe('joint.clevis — G1 design locks', () => {
)).toHaveLength(2);
});
+ it('emits structural dimensions from the same resolved style used by geometry', () => {
+ const session = new CaptureSession();
+ const kc = createApi({ session });
+ const steel = {
+ name: 'test steel',
+ model: 'isotropic-ductile' as const,
+ yieldStrengthMPa: 250,
+ bearingStrengthMPa: 400,
+ };
+
+ const result = kc.joint.clevis({
+ parentBody: kc.box(40, 40, 20, true),
+ childBody: kc.box(50, 20, 20, true),
+ axis: 'Y',
+ pivotParent: [0, 0, 10],
+ pivotChild: [0, 0, 0],
+ liftPivot: false,
+ style: {
+ pinR: 3,
+ holeClearance: 0.2,
+ plateT: 4,
+ tongueY: 5,
+ forkGapY: 6,
+ knuckleR: 10,
+ },
+ engineering: { pin: steel, fork: steel, tongue: steel },
+ });
+
+ expect(result.structural).toEqual({
+ kind: 'clevis-double-shear-v1',
+ source: 'joint.clevis',
+ pinDiameterMm: 6,
+ boreDiameterMm: 6.4,
+ forkPlateThicknessMm: 4,
+ forkPlateCount: 2,
+ tongueThicknessMm: 5,
+ forkGapMm: 6,
+ supportSpanMm: 10,
+ edgeDistanceMm: 10,
+ materials: { pin: steel, fork: steel, tongue: steel },
+ });
+ expect(result.structural.materials?.pin).not.toBe(steel);
+ });
+
+ it('emits structural geometry without inventing engineering material', () => {
+ const session = new CaptureSession();
+ const kc = createApi({ session });
+ const result = kc.joint.clevis({
+ parentBody: kc.box(40, 40, 20, true),
+ childBody: kc.box(50, 20, 20, true),
+ axis: 'Y',
+ pivotParent: [0, 0, 10],
+ liftPivot: false,
+ });
+
+ expect(result.structural.source).toBe('joint.clevis');
+ expect(result.structural.materials).toBeUndefined();
+ });
+
+ it.each([
+ ['empty name', { name: '', model: 'isotropic-ductile', yieldStrengthMPa: 250, bearingStrengthMPa: 400 }],
+ ['bad model', { name: 'steel', model: 'unknown', yieldStrengthMPa: 250, bearingStrengthMPa: 400 }],
+ ['zero yield', { name: 'steel', model: 'isotropic-ductile', yieldStrengthMPa: 0, bearingStrengthMPa: 400 }],
+ ['bad bearing', { name: 'steel', model: 'isotropic-ductile', yieldStrengthMPa: 250, bearingStrengthMPa: Number.NaN }],
+ ['bad shear', { name: 'steel', model: 'isotropic-ductile', yieldStrengthMPa: 250, bearingStrengthMPa: 400, shearStrengthMPa: -1 }],
+ ])('rejects invalid structural material: %s', (_label, material) => {
+ const session = new CaptureSession();
+ const kc = createApi({ session });
+ expect(() => kc.joint.clevis({
+ parentBody: kc.box(40, 40, 20, true),
+ childBody: kc.box(50, 20, 20, true),
+ axis: 'Y',
+ pivotParent: [0, 0, 10],
+ engineering: {
+ pin: material,
+ fork: { name: 'steel', model: 'isotropic-ductile', yieldStrengthMPa: 250, bearingStrengthMPa: 400 },
+ tongue: { name: 'steel', model: 'isotropic-ductile', yieldStrengthMPa: 250, bearingStrengthMPa: 400 },
+ },
+ } as Parameters[0])).toThrow(/engineering|material|strength|model|name/i);
+ });
+
+ it('rejects a null engineering declaration with a kernel argument error', () => {
+ const session = new CaptureSession();
+ const kc = createApi({ session });
+ expect(() => kc.joint.clevis({
+ parentBody: kc.box(40, 40, 20, true),
+ childBody: kc.box(50, 20, 20, true),
+ axis: 'Y',
+ pivotParent: [0, 0, 10],
+ engineering: null,
+ } as unknown as Parameters[0])).toThrow(/engineering.*object/i);
+ });
+
// ---------------------------------------------------------------------------
// Validation gates
// ---------------------------------------------------------------------------
diff --git a/src/modeling/joints/clevis.ts b/src/modeling/joints/clevis.ts
index 02470b4f2..4a383fe28 100644
--- a/src/modeling/joints/clevis.ts
+++ b/src/modeling/joints/clevis.ts
@@ -36,7 +36,9 @@ import type {
ClevisJoint,
ClevisJointOptions,
ClevisStyle,
+ ClevisStructuralModel,
ResolvedClevisStyle,
+ StructuralMaterial,
} from './types';
// =============================================================================
@@ -102,6 +104,102 @@ function assertPositive(name: string, value: number): void {
}
}
+function validateStructuralMaterial(role: 'pin' | 'fork' | 'tongue', material: unknown): asserts material is StructuralMaterial {
+ if (typeof material !== 'object' || material === null || Array.isArray(material)) {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `joint.clevis: engineering.${role} must be a structural material object.`,
+ 'joint.clevis',
+ `Pass engineering.${role}: { name, model: 'isotropic-ductile', yieldStrengthMPa, bearingStrengthMPa, shearStrengthMPa? }.`,
+ );
+ }
+ const candidate = material as Partial;
+ if (typeof candidate.name !== 'string' || candidate.name.trim() === '') {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `joint.clevis: engineering.${role}.name must be a non-empty material name.`,
+ 'joint.clevis',
+ `Name the ${role} engineering material used for the strength declaration.`,
+ );
+ }
+ if (candidate.model !== 'isotropic-ductile') {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `joint.clevis: engineering.${role}.model must be 'isotropic-ductile'.`,
+ 'joint.clevis',
+ `Only the explicit isotropic-ductile closed-form model is supported for ${role}.`,
+ );
+ }
+ for (const field of ['yieldStrengthMPa', 'bearingStrengthMPa'] as const) {
+ if (typeof candidate[field] !== 'number' || !Number.isFinite(candidate[field]) || candidate[field] <= 0) {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `joint.clevis: engineering.${role}.${field} must be a positive finite MPa value.`,
+ 'joint.clevis',
+ `Pass measured or datasheet ${field} for the ${role} material in MPa.`,
+ );
+ }
+ }
+ if (
+ candidate.shearStrengthMPa !== undefined &&
+ (typeof candidate.shearStrengthMPa !== 'number' ||
+ !Number.isFinite(candidate.shearStrengthMPa) ||
+ candidate.shearStrengthMPa <= 0)
+ ) {
+ throw new KernelError(
+ 'feature.invalid-args',
+ `joint.clevis: engineering.${role}.shearStrengthMPa must be a positive finite MPa value when declared.`,
+ 'joint.clevis',
+ `Omit engineering.${role}.shearStrengthMPa to derive yield/sqrt(3), or pass a positive measured value.`,
+ );
+ }
+}
+
+function copyStructuralMaterial(material: StructuralMaterial): StructuralMaterial {
+ return {
+ name: material.name,
+ model: material.model,
+ yieldStrengthMPa: material.yieldStrengthMPa,
+ bearingStrengthMPa: material.bearingStrengthMPa,
+ ...(material.shearStrengthMPa === undefined ? {} : { shearStrengthMPa: material.shearStrengthMPa }),
+ };
+}
+
+function makeStructuralModel(opts: ClevisJointOptions, style: ResolvedClevisStyle): ClevisStructuralModel {
+ let materials: ClevisStructuralModel['materials'];
+ if (opts.engineering !== undefined) {
+ if (typeof opts.engineering !== 'object' || opts.engineering === null || Array.isArray(opts.engineering)) {
+ throw new KernelError(
+ 'feature.invalid-args',
+ 'joint.clevis: engineering must be an object containing pin, fork, and tongue structural materials.',
+ 'joint.clevis',
+ `Pass engineering: { pin, fork, tongue }, with explicit material strengths for each role.`,
+ );
+ }
+ validateStructuralMaterial('pin', opts.engineering.pin);
+ validateStructuralMaterial('fork', opts.engineering.fork);
+ validateStructuralMaterial('tongue', opts.engineering.tongue);
+ materials = {
+ pin: copyStructuralMaterial(opts.engineering.pin),
+ fork: copyStructuralMaterial(opts.engineering.fork),
+ tongue: copyStructuralMaterial(opts.engineering.tongue),
+ };
+ }
+ return {
+ kind: 'clevis-double-shear-v1',
+ source: 'joint.clevis',
+ pinDiameterMm: 2 * style.pinR,
+ boreDiameterMm: 2 * (style.pinR + style.holeClearance),
+ forkPlateThicknessMm: style.plateT,
+ forkPlateCount: 2,
+ tongueThicknessMm: style.tongueY,
+ forkGapMm: style.forkGapY,
+ supportSpanMm: style.forkGapY + style.plateT,
+ edgeDistanceMm: style.knuckleR,
+ ...(materials === undefined ? {} : { materials }),
+ };
+}
+
function normalizeAxis(hint: AxisHint): Vec3 {
if (hint === 'X') return [1, 0, 0];
if (hint === 'Y') return [0, 1, 0];
@@ -306,6 +404,9 @@ function buildClevis(kc: KernelCadApi, opts: ClevisJointOptions): ClevisJoint {
);
}
const style = withDefaults(opts.style);
+ // Validate and copy engineering evidence before capturing any geometry so
+ // an invalid declaration cannot leave a partially built clevis in session.
+ const structural = makeStructuralModel(opts, style);
const liftDir = normalizeLiftDir(opts.liftDir);
const liftPivot = opts.liftPivot ?? true;
@@ -382,6 +483,7 @@ function buildClevis(kc: KernelCadApi, opts: ClevisJointOptions): ClevisJoint {
childConnector,
pivot: pivotParentLifted,
style,
+ structural,
};
}
diff --git a/src/modeling/joints/index.ts b/src/modeling/joints/index.ts
index b5711c9b2..7ed11fe0d 100644
--- a/src/modeling/joints/index.ts
+++ b/src/modeling/joints/index.ts
@@ -6,12 +6,43 @@
// G1 slice ships `joint.clevis(...)` as the canonical revolute-joint
// constructive primitive; G2+ will add `joint.prismatic`, `joint.ball`, etc.
-export { makeJointNamespace } from './clevis';
+import type { KernelCadApi } from '../api';
+import type { Assembly } from '../capture/assembly';
+import { makeJointNamespace as makeClevisJointNamespace } from './clevis';
+import {
+ supportedServoRevolute,
+ type SupportedServoRevoluteOptions,
+ type SupportedServoRevoluteResult,
+} from './supportedServoRevolute';
+import type {
+ ClevisJoint as ClevisJointResult,
+ ClevisJointOptions as ClevisJointOpts,
+} from './types';
+
+export function makeJointNamespace(kc: KernelCadApi): {
+ clevis(opts: ClevisJointOpts): ClevisJointResult;
+ supportedServoRevolute(arm: Assembly, opts: SupportedServoRevoluteOptions): SupportedServoRevoluteResult;
+} {
+ return {
+ ...makeClevisJointNamespace(kc),
+ supportedServoRevolute(arm, opts): SupportedServoRevoluteResult {
+ return supportedServoRevolute(kc, arm, opts);
+ },
+ };
+}
+
export type {
AxisHint,
ClevisConnectorSpec,
ClevisJoint,
ClevisJointOptions,
+ ClevisEngineeringMaterials,
+ ClevisStructuralModel,
ClevisStyle,
ResolvedClevisStyle,
+ StructuralMaterial,
} from './types';
+export type {
+ SupportedServoRevoluteOptions,
+ SupportedServoRevoluteResult,
+} from './supportedServoRevolute';
diff --git a/src/modeling/joints/supportedServoRevolute.test.ts b/src/modeling/joints/supportedServoRevolute.test.ts
new file mode 100644
index 000000000..74df3d441
--- /dev/null
+++ b/src/modeling/joints/supportedServoRevolute.test.ts
@@ -0,0 +1,326 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+
+import { beforeAll, describe, expect, it } from 'vitest';
+import { reviewCadTool } from '../../agent/mcp/tools/reviewCad';
+import { initOcct } from '../../kernel/backends/occt/occtBackend';
+import { createApi } from '../api';
+import type { Assembly } from '../capture/assembly';
+import { CaptureSession } from '../capture/captureSession';
+
+describe('joint.supportedServoRevolute', () => {
+ beforeAll(async () => { await initOcct(); }, 60000);
+
+ it('creates a seated servo actuator, fastened mount, and mechanical joint support contract', () => {
+ const { kcad, arm } = makeSupportedArm();
+
+ const result = kcad.joint.supportedServoRevolute(arm, {
+ name: 'curl-drive',
+ mate: 'curl',
+ support: 'base',
+ supportMount: 'base.servo-mount',
+ output: 'link',
+ axis: 'base.axis',
+ });
+
+ expect(result.actuatorPartName).toBe('curl-drive-servo');
+ const servoPart = arm.__parts().find((part) => part.name === 'curl-drive-servo');
+ expect(servoPart?.mateConnectors).toContainEqual(expect.objectContaining({
+ name: 'mount',
+ type: 'frame',
+ }));
+ expect(arm.__mechanicalJointIntents()).toContainEqual({
+ name: 'curl-drive',
+ mate: 'curl',
+ actuator: 'curl-drive-servo',
+ shaft: 'base',
+ supports: ['base'],
+ output: 'link',
+ requiredSupport: {
+ kind: 'hinge-bracket',
+ around: 'base.axis',
+ supports: ['base'],
+ minBearingLengthMm: 8,
+ },
+ });
+ expect(arm.__mates()).toContainEqual(
+ expect.objectContaining({
+ name: 'curl-drive-servo-fix',
+ a: 'base.servo-mount',
+ b: 'curl-drive-servo.mount',
+ type: 'fastened',
+ }),
+ );
+ });
+
+ it('rejects supportMount on the moving output before mutating the assembly', () => {
+ const { kcad, arm } = makeSupportedArm({ linkServoMount: true });
+ const before = snapshotAssembly(arm);
+
+ expect(() => kcad.joint.supportedServoRevolute(arm, {
+ name: 'curl-drive',
+ mate: 'curl',
+ support: 'base',
+ supportMount: 'link.servo-mount',
+ output: 'link',
+ axis: 'base.axis',
+ })).toThrow(/supportMount.*must be on support part 'base'/);
+
+ expect(snapshotAssembly(arm)).toEqual(before);
+ });
+
+ it('rejects a non-frame supportMount connector before mutating the assembly', () => {
+ const { kcad, arm } = makeSupportedArm();
+ const before = snapshotAssembly(arm);
+
+ expect(() => kcad.joint.supportedServoRevolute(arm, {
+ name: 'curl-drive',
+ mate: 'curl',
+ support: 'base',
+ supportMount: 'base.axis',
+ output: 'link',
+ axis: 'base.axis',
+ })).toThrow(/supportMount.*must be a frame connector/);
+
+ expect(snapshotAssembly(arm)).toEqual(before);
+ });
+
+ it('rejects a duplicate helper call before adding another generated part or mate', () => {
+ const { kcad, arm } = makeSupportedArm();
+ const opts = {
+ name: 'curl-drive',
+ mate: 'curl',
+ support: 'base',
+ supportMount: 'base.servo-mount',
+ output: 'link',
+ axis: 'base.axis',
+ };
+
+ kcad.joint.supportedServoRevolute(arm, opts);
+ const before = snapshotAssembly(arm);
+
+ expect(() => kcad.joint.supportedServoRevolute(arm, opts))
+ .toThrow(/actuator part 'curl-drive-servo' already exists/);
+ expect(snapshotAssembly(arm)).toEqual(before);
+ });
+
+ it('validates bodySizeMm before creating servo geometry', () => {
+ const { kcad, arm } = makeSupportedArm();
+ const before = snapshotAssembly(arm);
+
+ expect(() => kcad.joint.supportedServoRevolute(arm, {
+ name: 'curl-drive',
+ mate: 'curl',
+ support: 'base',
+ supportMount: 'base.servo-mount',
+ output: 'link',
+ axis: 'base.axis',
+ bodySizeMm: [24, -12, 24],
+ })).toThrow(/bodySizeMm.*positive finite 3-tuple/);
+
+ expect(snapshotAssembly(arm)).toEqual(before);
+ });
+
+ it('validates required string options before mutating the assembly', () => {
+ const { kcad, arm } = makeSupportedArm();
+ const before = snapshotAssembly(arm);
+
+ expect(() => kcad.joint.supportedServoRevolute(arm, {
+ name: '',
+ mate: 'curl',
+ support: 'base',
+ supportMount: 'base.servo-mount',
+ output: 'link',
+ axis: 'base.axis',
+ })).toThrow(/name.*non-empty string/);
+
+ expect(snapshotAssembly(arm)).toEqual(before);
+ });
+
+ it('rejects a missing axis connector before mutating the assembly', () => {
+ const { kcad, arm } = makeSupportedArm();
+ const before = snapshotAssembly(arm);
+
+ expect(() => kcad.joint.supportedServoRevolute(arm, {
+ name: 'curl-drive',
+ mate: 'curl',
+ support: 'base',
+ supportMount: 'base.servo-mount',
+ output: 'link',
+ axis: 'base.missing',
+ })).toThrow(/axis connector 'missing' does not exist on part 'base'/);
+
+ expect(snapshotAssembly(arm)).toEqual(before);
+ });
+
+ it('rejects a non-axis axis connector before mutating the assembly', () => {
+ const { kcad, arm } = makeSupportedArm();
+ const before = snapshotAssembly(arm);
+
+ expect(() => kcad.joint.supportedServoRevolute(arm, {
+ name: 'curl-drive',
+ mate: 'curl',
+ support: 'base',
+ supportMount: 'base.servo-mount',
+ output: 'link',
+ axis: 'base.servo-mount',
+ })).toThrow(/axis.*must be an axis connector/);
+
+ expect(snapshotAssembly(arm)).toEqual(before);
+ });
+
+ it('rejects the output-side axis connector before mutating the assembly', () => {
+ const { kcad, arm } = makeSupportedArm();
+ const before = snapshotAssembly(arm);
+
+ expect(() => kcad.joint.supportedServoRevolute(arm, {
+ name: 'curl-drive',
+ mate: 'curl',
+ support: 'base',
+ supportMount: 'base.servo-mount',
+ output: 'link',
+ axis: 'link.axis',
+ })).toThrow(/axis 'link\.axis' must match support-side connector 'base\.axis'/);
+
+ expect(snapshotAssembly(arm)).toEqual(before);
+ });
+
+ it('rejects a support-side axis that is not the driven mate support connector before mutating', () => {
+ const { kcad, arm } = makeSupportedArm({ extraSupportAxis: true });
+ const before = snapshotAssembly(arm);
+
+ expect(() => kcad.joint.supportedServoRevolute(arm, {
+ name: 'curl-drive',
+ mate: 'curl',
+ support: 'base',
+ supportMount: 'base.servo-mount',
+ output: 'link',
+ axis: 'base.other-axis',
+ })).toThrow(/axis 'base\.other-axis' must match support-side connector 'base\.axis'/);
+
+ expect(snapshotAssembly(arm)).toEqual(before);
+ });
+
+ it('review_cad accepts a helper-built rig and bad supportMount fails before review mutation', async () => {
+ const result = await reviewCadTool({
+ includeInterference: false,
+ includePhysics: false,
+ samplesPerMate: 3,
+ code: `
+ const arm = assembly('servo rig');
+ const clevis = joint.clevis({
+ parentBody: box(40, 30, 10, true),
+ childBody: box(50, 8, 8, true).translate(25, 0, 0),
+ axis: 'Y',
+ pivotParent: [0, 0, 5],
+ pivotChild: [0, 0, 0],
+ limitsDeg: [-45, 45],
+ style: { knuckleR: 5 },
+ });
+ arm.part('base', clevis.parentGeometry)
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: clevis.parentConnector.origin },
+ axis: clevis.parentConnector.axis,
+ jointClearanceRadius: clevis.parentConnector.clearanceRadius,
+ })
+ .connector('servo-mount', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [0, -14, 8] },
+ });
+ arm.part('link', clevis.childGeometry)
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: clevis.childConnector.origin },
+ axis: clevis.childConnector.axis,
+ jointClearanceRadius: clevis.childConnector.clearanceRadius,
+ })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [50, 0, 0] } });
+ arm.mate('curl', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-45, 45] });
+ joint.supportedServoRevolute(arm, {
+ name: 'curl-drive',
+ mate: 'curl',
+ support: 'base',
+ supportMount: 'base.servo-mount',
+ output: 'link',
+ axis: 'base.axis',
+ });
+ return arm.model();
+ `,
+ });
+
+ expect(result.ok).toBe(true);
+
+ const { kcad, arm } = makeSupportedArm({ linkServoMount: true });
+ const before = snapshotAssembly(arm);
+ expect(() => kcad.joint.supportedServoRevolute(arm, {
+ name: 'curl-drive',
+ mate: 'curl',
+ support: 'base',
+ supportMount: 'link.servo-mount',
+ output: 'link',
+ axis: 'base.axis',
+ })).toThrow(/supportMount.*must be on support part 'base'/);
+ expect(snapshotAssembly(arm)).toEqual(before);
+ });
+});
+
+function makeSupportedArm(opts: { extraSupportAxis?: boolean; linkServoMount?: boolean } = {}): {
+ kcad: ReturnType;
+ arm: Assembly;
+} {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ const arm = kcad.assembly('arm');
+
+ const base = arm
+ .part('base', kcad.box(40, 30, 10, true))
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 5] },
+ axis: [0, 1, 0],
+ })
+ .connector('servo-mount', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [0, -18, 8] },
+ });
+ if (opts.extraSupportAxis) {
+ base.connector('other-axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [5, 0, 5] },
+ axis: [0, 1, 0],
+ });
+ }
+
+ const link = arm
+ .part('link', kcad.box(50, 8, 8, true).translate(25, 0, 5))
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 1, 0],
+ });
+ if (opts.linkServoMount) {
+ link.connector('servo-mount', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [0, -18, 8] },
+ });
+ }
+
+ arm.mate('curl', 'base.axis', 'link.axis', 'revolute', {
+ limitsDeg: [-45, 45],
+ });
+
+ return { kcad, arm };
+}
+
+function snapshotAssembly(arm: Assembly): {
+ parts: string[];
+ mates: string[];
+ intents: string[];
+} {
+ return {
+ parts: arm.__parts().map((part) => part.name),
+ mates: arm.__mates().map((mate) => `${mate.name}:${mate.type}:${mate.a}->${mate.b}`),
+ intents: arm.__mechanicalJointIntents().map((intent) => intent.name),
+ };
+}
diff --git a/src/modeling/joints/supportedServoRevolute.ts b/src/modeling/joints/supportedServoRevolute.ts
new file mode 100644
index 000000000..f48498afd
--- /dev/null
+++ b/src/modeling/joints/supportedServoRevolute.ts
@@ -0,0 +1,281 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+
+import type { KernelCadApi } from '../api';
+import type { Assembly } from '../capture/assembly';
+import type { Shape } from '../capture/proxy';
+import { parseConnectorRef } from '../mates/mate';
+import { KernelError } from '../../shared/intent/kernelError';
+
+export interface SupportedServoRevoluteOptions {
+ /** Intent name. Also prefixes the generated actuator part and mate names. */
+ name: string;
+ /** Existing driven revolute mate name. */
+ mate: string;
+ /** Part that carries the shaft / support structure for the driven mate. */
+ support: string;
+ /** Frame connector ref on `support` where the servo actuator is seated, e.g. "base.servo-mount". */
+ supportMount: string;
+ /** Moving output part driven by the actuator. */
+ output: string;
+ /** Connector ref for the supported revolute axis, e.g. "base.axis". */
+ axis: string;
+ /** Minimum bearing/bracket support length required around the revolute axis. */
+ minBearingLengthMm?: number;
+ /** Optional nominal servo body dimensions in millimetres. */
+ bodySizeMm?: readonly [number, number, number];
+}
+
+export interface SupportedServoRevoluteResult {
+ actuatorPartName: string;
+ actuatorMountRef: string;
+ fastenedMateName: string;
+}
+
+export function supportedServoRevolute(
+ kc: KernelCadApi,
+ arm: Assembly,
+ opts: SupportedServoRevoluteOptions,
+): SupportedServoRevoluteResult {
+ preflightSupportedServoRevolute(arm, opts);
+
+ const actuatorPartName = `${opts.name}-servo`;
+ const fastenedMateName = `${opts.name}-servo-fix`;
+ const actuatorMountRef = `${actuatorPartName}.mount`;
+
+ arm
+ .part(actuatorPartName, buildDefaultServo(kc, opts))
+ .connector('mount', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ });
+
+ arm.mate(fastenedMateName, opts.supportMount, actuatorMountRef, 'fastened');
+ arm.mechanicalJoint(opts.name, {
+ mate: opts.mate,
+ actuator: actuatorPartName,
+ shaft: opts.support,
+ supports: [opts.support],
+ output: opts.output,
+ requiredSupport: {
+ kind: 'hinge-bracket',
+ around: opts.axis,
+ supports: [opts.support],
+ minBearingLengthMm: opts.minBearingLengthMm ?? 8,
+ },
+ });
+
+ return {
+ actuatorPartName,
+ actuatorMountRef,
+ fastenedMateName,
+ };
+}
+
+function preflightSupportedServoRevolute(arm: Assembly, opts: SupportedServoRevoluteOptions): void {
+ for (const field of ['name', 'mate', 'support', 'supportMount', 'output', 'axis'] as const) {
+ assertNonEmptyString(field, opts[field]);
+ }
+ validateBodySizeMm(opts.bodySizeMm);
+ if (
+ opts.minBearingLengthMm !== undefined &&
+ (!Number.isFinite(opts.minBearingLengthMm) || opts.minBearingLengthMm <= 0)
+ ) {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: minBearingLengthMm must be a positive finite number; got ${opts.minBearingLengthMm}.`,
+ 'Pass minBearingLengthMm > 0, or omit it to use the 8 mm default.',
+ );
+ }
+
+ const supportMount = parseConnectorRefForHelper('supportMount', opts.supportMount);
+ const axisRef = parseConnectorRefForHelper('axis', opts.axis);
+ if (supportMount.partName !== opts.support) {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: supportMount '${opts.supportMount}' must be on support part '${opts.support}' for this helper.`,
+ `Move the mount connector to '${opts.support}', or add a future helper variant with explicit remote support parts.`,
+ );
+ }
+
+ const parts = arm.__parts();
+ const mates = arm.__mates();
+ const intents = arm.__mechanicalJointIntents();
+ const actuatorPartName = `${opts.name}-servo`;
+ const fastenedMateName = `${opts.name}-servo-fix`;
+
+ if (parts.some((part) => part.name === actuatorPartName)) {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: actuator part '${actuatorPartName}' already exists.`,
+ `Choose a different supportedServoRevolute name, or remove the existing '${actuatorPartName}' part before adding the helper.`,
+ );
+ }
+ if (mates.some((mate) => mate.name === fastenedMateName)) {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: fastened mate '${fastenedMateName}' already exists.`,
+ `Choose a different supportedServoRevolute name, or remove the existing '${fastenedMateName}' mate before adding the helper.`,
+ );
+ }
+ if (intents.some((intent) => intent.name === opts.name)) {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: mechanical joint intent '${opts.name}' already exists.`,
+ `Choose a unique supportedServoRevolute name before adding another helper.`,
+ );
+ }
+
+ const drivenMate = mates.find((mate) => mate.name === opts.mate);
+ if (drivenMate === undefined) {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: required mate '${opts.mate}' does not exist.`,
+ `Declare arm.mate('${opts.mate}', ..., 'revolute') before adding the supported servo helper.`,
+ );
+ }
+ if (drivenMate.type !== 'revolute') {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: mate '${opts.mate}' must be revolute; got '${drivenMate.type}'.`,
+ `Use this helper only for driven revolute mates, or choose a helper for '${drivenMate.type}' mates.`,
+ );
+ }
+
+ const supportPart = parts.find((part) => part.name === opts.support);
+ if (supportPart === undefined) {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: support part '${opts.support}' does not exist.`,
+ `Declare arm.part('${opts.support}', ...) before adding the supported servo helper.`,
+ );
+ }
+ if (parts.every((part) => part.name !== opts.output)) {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: output part '${opts.output}' does not exist.`,
+ `Declare arm.part('${opts.output}', ...) before adding the supported servo helper.`,
+ );
+ }
+ const axisPart = parts.find((part) => part.name === axisRef.partName);
+ if (axisPart === undefined) {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: axis part '${axisRef.partName}' does not exist.`,
+ `Declare arm.part('${axisRef.partName}', ...) before referencing '${opts.axis}'.`,
+ );
+ }
+ const axisConnector = axisPart.mateConnectors.find((connector) => connector.name === axisRef.connectorName);
+ if (axisConnector === undefined) {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: axis connector '${axisRef.connectorName}' does not exist on part '${axisRef.partName}'.`,
+ `Register '${opts.axis}' with partRef.connector('${axisRef.connectorName}', { type: 'axis', ... }) before adding the helper.`,
+ );
+ }
+ if (axisConnector.type !== 'axis') {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: axis '${opts.axis}' must be an axis connector; got '${axisConnector.type}'.`,
+ `Use the support-side axis connector from the driven revolute mate '${opts.mate}'.`,
+ );
+ }
+ const supportSideAxisRef = supportSideConnectorRef(drivenMate.a, drivenMate.b, opts.support, opts.mate);
+ if (opts.axis !== supportSideAxisRef) {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: axis '${opts.axis}' must match support-side connector '${supportSideAxisRef}' from mate '${opts.mate}'.`,
+ `Pass axis: '${supportSideAxisRef}' so the requiredSupport contract names the driven revolute shaft axis.`,
+ );
+ }
+
+ const mountPart = parts.find((part) => part.name === supportMount.partName);
+ if (mountPart === undefined) {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: supportMount part '${supportMount.partName}' does not exist.`,
+ `Declare arm.part('${supportMount.partName}', ...) before referencing '${opts.supportMount}'.`,
+ );
+ }
+ const mountConnector = mountPart.mateConnectors.find((connector) => connector.name === supportMount.connectorName);
+ if (mountConnector === undefined) {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: supportMount connector '${supportMount.connectorName}' does not exist on part '${supportMount.partName}'.`,
+ `Register '${opts.supportMount}' with partRef.connector('${supportMount.connectorName}', { type: 'frame', ... }) before adding the helper.`,
+ );
+ }
+ if (mountConnector.type !== 'frame') {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: supportMount '${opts.supportMount}' must be a frame connector; got '${mountConnector.type}'.`,
+ `Register '${opts.supportMount}' with partRef.connector('${supportMount.connectorName}', { type: 'frame', ... }) before adding the helper.`,
+ );
+ }
+}
+
+function assertNonEmptyString(field: string, value: string): void {
+ if (typeof value === 'string' && value.trim().length > 0) return;
+ throw invalidArgs(
+ `joint.supportedServoRevolute: ${field} must be a non-empty string.`,
+ `Pass a non-empty string for ${field}.`,
+ );
+}
+
+function validateBodySizeMm(bodySizeMm: SupportedServoRevoluteOptions['bodySizeMm']): void {
+ if (bodySizeMm === undefined) return;
+ if (
+ !Array.isArray(bodySizeMm) ||
+ bodySizeMm.length !== 3 ||
+ !bodySizeMm.every((value) => typeof value === 'number' && Number.isFinite(value) && value > 0)
+ ) {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: bodySizeMm must be a positive finite 3-tuple; got ${JSON.stringify(bodySizeMm)}.`,
+ 'Pass bodySizeMm as [x, y, z] with positive finite millimetre dimensions, or omit it.',
+ );
+ }
+}
+
+function parseConnectorRefForHelper(
+ field: string,
+ ref: string,
+): { partName: string; connectorName: string } {
+ try {
+ return parseConnectorRef(ref);
+ } catch {
+ throw invalidArgs(
+ `joint.supportedServoRevolute: ${field} '${ref}' must be a 'part.connector' reference.`,
+ `Pass ${field} in the form '.'.`,
+ );
+ }
+}
+
+function supportSideConnectorRef(aRef: string, bRef: string, support: string, mate: string): string {
+ const a = parseConnectorRefForHelper(`mate '${mate}' side a`, aRef);
+ const b = parseConnectorRefForHelper(`mate '${mate}' side b`, bRef);
+ if (a.partName === support) return aRef;
+ if (b.partName === support) return bRef;
+ throw invalidArgs(
+ `joint.supportedServoRevolute: mate '${mate}' must have one connector on support part '${support}'.`,
+ `Use support: '' matching the shaft/support side of the driven revolute mate.`,
+ );
+}
+
+function invalidArgs(message: string, hint: string): KernelError {
+ return new KernelError(
+ 'feature.invalid-args',
+ message,
+ 'joint.supportedServoRevolute',
+ `invalid-args.joint.supported-servo-revolute — ${hint}`,
+ );
+}
+
+function buildDefaultServo(kc: KernelCadApi, opts: SupportedServoRevoluteOptions): Shape {
+ const [x, y, z] = opts.bodySizeMm ?? [24, 12, 24];
+ const body = kc.box(x, y, z, true).material({
+ baseColor: '#2f3437',
+ metalness: 0.1,
+ roughness: 0.45,
+ });
+ const shaft = kc.cylinder(6, 2.5, 24)
+ .rotate([1, 0, 0], 90)
+ .translate(0, y / 2 + 3, z * 0.25)
+ .material({
+ baseColor: '#b7bcc2',
+ metalness: 0.7,
+ roughness: 0.25,
+ });
+ const horn = kc.box(18, 2, 4, true)
+ .translate(0, y / 2 + 6.5, z * 0.25)
+ .material({
+ baseColor: '#d9dde2',
+ metalness: 0.35,
+ roughness: 0.3,
+ });
+
+ return body.union(shaft).union(horn);
+}
diff --git a/src/modeling/joints/types.ts b/src/modeling/joints/types.ts
index b6e975cfd..7e38ae7de 100644
--- a/src/modeling/joints/types.ts
+++ b/src/modeling/joints/types.ts
@@ -69,6 +69,39 @@ export interface ResolvedClevisStyle {
pinMaterial?: { baseColor: string; metalness?: number; roughness?: number };
}
+/** Engineering strength evidence. This is intentionally separate from PBR
+ * material and part density; neither visual appearance nor mass proves
+ * structural capacity. */
+export interface StructuralMaterial {
+ readonly name: string;
+ readonly model: 'isotropic-ductile';
+ readonly yieldStrengthMPa: number;
+ readonly bearingStrengthMPa: number;
+ readonly shearStrengthMPa?: number;
+}
+
+export interface ClevisEngineeringMaterials {
+ readonly pin: StructuralMaterial;
+ readonly fork: StructuralMaterial;
+ readonly tongue: StructuralMaterial;
+}
+
+/** Nominal double-shear clevis dimensions emitted by joint.clevis from the
+ * same resolved style that constructs the geometry. */
+export interface ClevisStructuralModel {
+ readonly kind: 'clevis-double-shear-v1';
+ readonly source: 'joint.clevis';
+ readonly pinDiameterMm: number;
+ readonly boreDiameterMm: number;
+ readonly forkPlateThicknessMm: number;
+ readonly forkPlateCount: 2;
+ readonly tongueThicknessMm: number;
+ readonly forkGapMm: number;
+ readonly supportSpanMm: number;
+ readonly edgeDistanceMm: number;
+ readonly materials?: ClevisEngineeringMaterials;
+}
+
/**
* Connector spec returned by `joint.clevis(...)`. Each side carries the
* `origin` in its OWN PART-LOCAL FRAME (URDF/MuJoCo convention) plus a shared
@@ -121,6 +154,9 @@ export interface ClevisJointOptions {
pivotChild?: Vec3;
limitsDeg?: [number, number];
style?: ClevisStyle;
+ /** Optional engineering strength evidence copied into the returned
+ * structural model. Visual style materials are never used as a fallback. */
+ engineering?: ClevisEngineeringMaterials;
liftPivot?: boolean;
liftDir?: Vec3;
}
@@ -148,4 +184,5 @@ export interface ClevisJoint {
childConnector: ClevisConnectorSpec;
pivot: Vec3;
style: ResolvedClevisStyle;
+ structural: ClevisStructuralModel;
}
diff --git a/src/modeling/mates/clevisJointStructure.test.ts b/src/modeling/mates/clevisJointStructure.test.ts
new file mode 100644
index 000000000..00684bf0e
--- /dev/null
+++ b/src/modeling/mates/clevisJointStructure.test.ts
@@ -0,0 +1,187 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+import { describe, expect, it } from 'vitest';
+import type { ClevisStructuralModel, StructuralMaterial } from '../joints/types';
+import type { PhysicalUseCaseJointReactionEvidence } from './physicalUseCaseJointReactions';
+import { reviewClevisJointStructure } from './clevisJointStructure';
+
+const strongSteel: StructuralMaterial = {
+ name: 'strong steel',
+ model: 'isotropic-ductile',
+ yieldStrengthMPa: 250,
+ bearingStrengthMPa: 400,
+};
+
+function model(
+ overrides: Partial = {},
+): ClevisStructuralModel {
+ return {
+ kind: 'clevis-double-shear-v1',
+ source: 'joint.clevis',
+ pinDiameterMm: 10,
+ boreDiameterMm: 10.4,
+ forkPlateThicknessMm: 5,
+ forkPlateCount: 2,
+ tongueThicknessMm: 8,
+ forkGapMm: 9,
+ supportSpanMm: 14,
+ edgeDistanceMm: 15,
+ materials: {
+ pin: strongSteel,
+ fork: strongSteel,
+ tongue: strongSteel,
+ },
+ ...overrides,
+ };
+}
+
+function reaction(
+ overrides: Partial = {},
+): PhysicalUseCaseJointReactionEvidence {
+ return {
+ mateName: 'hinge',
+ parentPart: 'base',
+ childPart: 'finger',
+ pointWorldMm: [0, 0, 0],
+ axisWorld: [0, 1, 0],
+ forceWorldN: [100, 0, 0],
+ momentWorldNmm: [0, 0, 0],
+ resultantForceN: 100,
+ resultantMomentNmm: 0,
+ axialForceN: 0,
+ radialForceN: 100,
+ axisMomentNmm: 0,
+ bendingMomentNmm: 0,
+ ...overrides,
+ };
+}
+
+describe('reviewClevisJointStructure', () => {
+ it('computes the v1 double-shear and centered pin-bending equations', () => {
+ const result = reviewClevisJointStructure({
+ reaction: reaction(),
+ model: model(),
+ minSafetyFactor: 2,
+ });
+
+ expect(result.status).toBe('pass');
+ expect(result.checks.pinDoubleShear.stressMPa).toBeCloseTo(
+ 100 / (2 * Math.PI * 10 ** 2 / 4),
+ 10,
+ );
+ expect(result.checks.pinBending.stressMPa).toBeCloseTo(
+ 32 * (100 * 14 / 4) / (Math.PI * 10 ** 3),
+ 10,
+ );
+ expect(result.checks.pinVonMises.stressMPa).toBeCloseTo(
+ Math.hypot(
+ 32 * (100 * 14 / 4) / (Math.PI * 10 ** 3),
+ Math.sqrt(3) * (100 / (2 * Math.PI * 10 ** 2 / 4)),
+ ),
+ 10,
+ );
+ expect(result.checks.tongueBearing.stressMPa).toBeCloseTo(100 / (10 * 8), 10);
+ expect(result.checks.forkBearing.stressMPa).toBeCloseTo(100 / (2 * 10 * 5), 10);
+ expect(result.assumptions).toEqual(expect.arrayContaining([
+ expect.stringMatching(/yield.*sqrt\(3\)|sqrt\(3\).*yield/i),
+ ]));
+ });
+
+ it('changes status when only pin diameter is reduced', () => {
+ const baseline = reviewClevisJointStructure({ reaction: reaction({ forceWorldN: [400, 0, 0], resultantForceN: 400, radialForceN: 400 }), model: model() });
+ const reduced = reviewClevisJointStructure({
+ reaction: reaction({ forceWorldN: [400, 0, 0], resultantForceN: 400, radialForceN: 400 }),
+ model: model({ pinDiameterMm: 3, boreDiameterMm: 3.4 }),
+ });
+
+ expect(baseline.status).toBe('pass');
+ expect(reduced.status).toBe('failed');
+ });
+
+ it('changes status when only declared material strength is reduced', () => {
+ const load = reaction({ forceWorldN: [600, 0, 0], resultantForceN: 600, radialForceN: 600 });
+ const weak: StructuralMaterial = {
+ ...strongSteel,
+ name: 'weak test material',
+ yieldStrengthMPa: 4,
+ bearingStrengthMPa: 4,
+ shearStrengthMPa: 2,
+ };
+ const baseline = reviewClevisJointStructure({ reaction: load, model: model() });
+ const reduced = reviewClevisJointStructure({
+ reaction: load,
+ model: model({ materials: { pin: weak, fork: weak, tongue: weak } }),
+ });
+
+ expect(baseline.status).toBe('pass');
+ expect(reduced.status).toBe('failed');
+ });
+
+ it('rejects missing materials and invalid ligament as input-incomplete', () => {
+ const missing = reviewClevisJointStructure({ reaction: reaction(), model: model({ materials: undefined }) });
+ const badLigament = reviewClevisJointStructure({ reaction: reaction(), model: model({ edgeDistanceMm: 5 }) });
+ const partialMaterials = reviewClevisJointStructure({
+ reaction: reaction(),
+ model: model({
+ materials: { pin: strongSteel, fork: strongSteel } as unknown as ClevisStructuralModel['materials'],
+ }),
+ });
+ const understatedSpan = reviewClevisJointStructure({
+ reaction: reaction(),
+ model: model({ supportSpanMm: 1 }),
+ });
+
+ expect(missing).toMatchObject({ status: 'input-incomplete', checks: {} });
+ expect(badLigament).toMatchObject({ status: 'input-incomplete', checks: {} });
+ expect(partialMaterials).toMatchObject({ status: 'input-incomplete', checks: {} });
+ expect(understatedSpan).toMatchObject({ status: 'input-incomplete', checks: {} });
+ });
+
+ it('blocks axial force and perpendicular reaction moment as unsupported', () => {
+ const axial = reviewClevisJointStructure({
+ reaction: reaction({
+ forceWorldN: [100, 0.02, 0],
+ resultantForceN: Math.hypot(100, 0.02),
+ axialForceN: 0.02,
+ }),
+ model: model(),
+ });
+ const moment = reviewClevisJointStructure({
+ reaction: reaction({
+ momentWorldNmm: [0, 0, 0.2],
+ resultantMomentNmm: 0.2,
+ bendingMomentNmm: 0.2,
+ }),
+ model: model(),
+ });
+
+ expect(axial.status).toBe('unsupported-load-case');
+ expect(axial.message).toMatch(/axial/i);
+ expect(moment.status).toBe('unsupported-load-case');
+ expect(moment.message).toMatch(/moment/i);
+ });
+
+ it('accepts the exact axial and perpendicular-moment support tolerances', () => {
+ const result = reviewClevisJointStructure({
+ reaction: reaction({
+ forceWorldN: [100, 0.01, 0],
+ momentWorldNmm: [0, 0, 0.1],
+ resultantForceN: Math.hypot(100, 0.01),
+ resultantMomentNmm: 0.1,
+ axialForceN: 0.01,
+ bendingMomentNmm: 0.1,
+ }),
+ model: model(),
+ });
+
+ expect(result.status).toBe('pass');
+ });
+
+ it('rejects a safety factor below 2 and inconsistent reaction decomposition', () => {
+ const weakCriterion = reviewClevisJointStructure({ reaction: reaction(), model: model(), minSafetyFactor: 1.99 });
+ const inconsistent = reviewClevisJointStructure({ reaction: reaction({ radialForceN: 50 }), model: model() });
+
+ expect(weakCriterion.status).toBe('input-incomplete');
+ expect(inconsistent.status).toBe('input-incomplete');
+ });
+});
diff --git a/src/modeling/mates/clevisJointStructure.ts b/src/modeling/mates/clevisJointStructure.ts
new file mode 100644
index 000000000..a157cd492
--- /dev/null
+++ b/src/modeling/mates/clevisJointStructure.ts
@@ -0,0 +1,305 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+import type {
+ ClevisStructuralModel,
+ StructuralMaterial,
+} from '../joints/types';
+import type { Vec3 } from '../../shared/intent/types';
+import type { PhysicalUseCaseJointReactionEvidence } from './physicalUseCaseJointReactions';
+
+export const DEFAULT_MIN_JOINT_SAFETY_FACTOR = 2;
+export const MAX_CLEVIS_AXIAL_FORCE_N = 0.01;
+export const MAX_CLEVIS_BENDING_MOMENT_NMM = 0.1;
+const EVIDENCE_TOLERANCE = 1e-6;
+
+export type ClevisJointStructureStatus =
+ | 'pass'
+ | 'failed'
+ | 'input-incomplete'
+ | 'unsupported-load-case';
+
+export interface StructuralCheckEvidence {
+ readonly stressMPa: number;
+ readonly allowableMPa: number;
+ /** Null means the stress is zero and the factor of safety is unbounded. */
+ readonly safetyFactor: number | null;
+ readonly passed: boolean;
+ readonly materialName: string;
+}
+
+export interface ClevisJointStructureReview {
+ readonly mateName: string;
+ readonly status: ClevisJointStructureStatus;
+ readonly minSafetyFactor: number;
+ readonly checks: Readonly>;
+ readonly assumptions: readonly string[];
+ readonly message?: string;
+}
+
+export interface ReviewClevisJointStructureInput {
+ readonly reaction: PhysicalUseCaseJointReactionEvidence;
+ readonly model: ClevisStructuralModel;
+ readonly minSafetyFactor?: number;
+}
+
+export function reviewClevisJointStructure(
+ input: ReviewClevisJointStructureInput,
+): ClevisJointStructureReview {
+ const { reaction, model } = input;
+ const minSafetyFactor = input.minSafetyFactor ?? DEFAULT_MIN_JOINT_SAFETY_FACTOR;
+ const base = { mateName: reaction.mateName, minSafetyFactor };
+ if (!Number.isFinite(minSafetyFactor) || minSafetyFactor < DEFAULT_MIN_JOINT_SAFETY_FACTOR) {
+ return incomplete(base, `Minimum joint safety factor must be at least ${DEFAULT_MIN_JOINT_SAFETY_FACTOR}.`);
+ }
+
+ const reactionIssue = validateReactionEvidence(reaction);
+ if (reactionIssue !== undefined) return incomplete(base, reactionIssue);
+ const geometryIssue = validateModelGeometry(model);
+ if (geometryIssue !== undefined) return incomplete(base, geometryIssue);
+ if (model.materials === undefined) {
+ return incomplete(base, 'Clevis structural review requires explicit pin, fork, and tongue engineering materials.');
+ }
+ const materialIssue = validateMaterials(model.materials);
+ if (materialIssue !== undefined) return incomplete(base, materialIssue);
+
+ if (reaction.axialForceN > MAX_CLEVIS_AXIAL_FORCE_N + EVIDENCE_TOLERANCE) {
+ return unsupported(
+ base,
+ `Clevis v1 does not model ${reaction.axialForceN.toFixed(6)} N axial pin load; maximum supported numerical residue is ${MAX_CLEVIS_AXIAL_FORCE_N} N.`,
+ );
+ }
+ if (reaction.bendingMomentNmm > MAX_CLEVIS_BENDING_MOMENT_NMM + EVIDENCE_TOLERANCE) {
+ return unsupported(
+ base,
+ `Clevis v1 does not model ${reaction.bendingMomentNmm.toFixed(6)} Nmm perpendicular reaction moment; maximum supported numerical residue is ${MAX_CLEVIS_BENDING_MOMENT_NMM} Nmm.`,
+ );
+ }
+
+ const assumptions: string[] = [];
+ const pinShear = shearAllowable(model.materials.pin, 'pin', assumptions);
+ const forkShear = shearAllowable(model.materials.fork, 'fork', assumptions);
+ const tongueShear = shearAllowable(model.materials.tongue, 'tongue', assumptions);
+ if (reaction.axisMomentNmm > MAX_CLEVIS_BENDING_MOMENT_NMM) {
+ assumptions.push('Revolute-axis moment is delegated to the separately checked actuator/transmission path.');
+ }
+
+ const forceN = reaction.radialForceN;
+ const pinAreaMm2 = Math.PI * model.pinDiameterMm ** 2 / 4;
+ const pinShearMPa = forceN / (2 * pinAreaMm2);
+ const pinMomentNmm = forceN * model.supportSpanMm / 4;
+ const pinBendingMPa = 32 * pinMomentNmm / (Math.PI * model.pinDiameterMm ** 3);
+ const pinVonMisesMPa = Math.sqrt(pinBendingMPa ** 2 + 3 * pinShearMPa ** 2);
+ const ligamentMm = model.edgeDistanceMm - model.boreDiameterMm / 2;
+ const netWidthMm = 2 * model.edgeDistanceMm - model.boreDiameterMm;
+
+ const checks: Record = {
+ pinDoubleShear: makeCheck(pinShearMPa, pinShear, model.materials.pin, minSafetyFactor),
+ pinBending: makeCheck(pinBendingMPa, model.materials.pin.yieldStrengthMPa, model.materials.pin, minSafetyFactor),
+ pinVonMises: makeCheck(pinVonMisesMPa, model.materials.pin.yieldStrengthMPa, model.materials.pin, minSafetyFactor),
+ tongueBearing: makeCheck(
+ forceN / (model.pinDiameterMm * model.tongueThicknessMm),
+ model.materials.tongue.bearingStrengthMPa,
+ model.materials.tongue,
+ minSafetyFactor,
+ ),
+ forkBearing: makeCheck(
+ forceN / (model.forkPlateCount * model.pinDiameterMm * model.forkPlateThicknessMm),
+ model.materials.fork.bearingStrengthMPa,
+ model.materials.fork,
+ minSafetyFactor,
+ ),
+ tongueTearOut: makeCheck(
+ forceN / (2 * ligamentMm * model.tongueThicknessMm),
+ tongueShear,
+ model.materials.tongue,
+ minSafetyFactor,
+ ),
+ forkTearOut: makeCheck(
+ forceN / (2 * model.forkPlateCount * ligamentMm * model.forkPlateThicknessMm),
+ forkShear,
+ model.materials.fork,
+ minSafetyFactor,
+ ),
+ tongueNetSection: makeCheck(
+ forceN / (netWidthMm * model.tongueThicknessMm),
+ model.materials.tongue.yieldStrengthMPa,
+ model.materials.tongue,
+ minSafetyFactor,
+ ),
+ forkNetSection: makeCheck(
+ forceN / (model.forkPlateCount * netWidthMm * model.forkPlateThicknessMm),
+ model.materials.fork.yieldStrengthMPa,
+ model.materials.fork,
+ minSafetyFactor,
+ ),
+ };
+ const passed = Object.values(checks).every((check) => check.passed);
+ return {
+ ...base,
+ status: passed ? 'pass' : 'failed',
+ checks,
+ assumptions,
+ ...(passed ? {} : { message: `Clevis '${reaction.mateName}' does not meet minimum factor of safety ${minSafetyFactor}.` }),
+ };
+}
+
+function validateReactionEvidence(reaction: PhysicalUseCaseJointReactionEvidence): string | undefined {
+ const vectors = [
+ reaction.pointWorldMm,
+ reaction.axisWorld,
+ reaction.forceWorldN,
+ reaction.momentWorldNmm,
+ ];
+ if (!vectors.every(isFiniteVec3)) return 'Joint reaction contains a non-finite vector.';
+ const scalars = [
+ reaction.resultantForceN,
+ reaction.resultantMomentNmm,
+ reaction.axialForceN,
+ reaction.radialForceN,
+ reaction.axisMomentNmm,
+ reaction.bendingMomentNmm,
+ ];
+ if (!scalars.every((value) => Number.isFinite(value) && value >= 0)) {
+ return 'Joint reaction contains a non-finite or negative magnitude.';
+ }
+ const axisLength = norm(reaction.axisWorld);
+ if (axisLength <= 0) return 'Joint reaction axis must be non-zero.';
+ const axis = scale(reaction.axisWorld, 1 / axisLength);
+ const axial = Math.abs(dot(reaction.forceWorldN, axis));
+ const radial = norm(sub(reaction.forceWorldN, scale(axis, dot(reaction.forceWorldN, axis))));
+ const axisMoment = Math.abs(dot(reaction.momentWorldNmm, axis));
+ const bendingMoment = norm(sub(
+ reaction.momentWorldNmm,
+ scale(axis, dot(reaction.momentWorldNmm, axis)),
+ ));
+ if (
+ !close(reaction.resultantForceN, norm(reaction.forceWorldN)) ||
+ !close(reaction.resultantMomentNmm, norm(reaction.momentWorldNmm)) ||
+ !close(reaction.axialForceN, axial) ||
+ !close(reaction.radialForceN, radial) ||
+ !close(reaction.axisMomentNmm, axisMoment) ||
+ !close(reaction.bendingMomentNmm, bendingMoment)
+ ) {
+ return 'Joint reaction scalar decomposition does not match its force, moment, and axis vectors.';
+ }
+ return undefined;
+}
+
+function validateModelGeometry(model: ClevisStructuralModel): string | undefined {
+ if (model.kind !== 'clevis-double-shear-v1' || model.source !== 'joint.clevis') {
+ return 'Structural model must be a joint.clevis clevis-double-shear-v1 descriptor.';
+ }
+ if (model.forkPlateCount !== 2) return 'Clevis v1 requires exactly two fork plates.';
+ for (const [name, value] of Object.entries({
+ pinDiameterMm: model.pinDiameterMm,
+ boreDiameterMm: model.boreDiameterMm,
+ forkPlateThicknessMm: model.forkPlateThicknessMm,
+ tongueThicknessMm: model.tongueThicknessMm,
+ forkGapMm: model.forkGapMm,
+ supportSpanMm: model.supportSpanMm,
+ edgeDistanceMm: model.edgeDistanceMm,
+ })) {
+ if (!Number.isFinite(value) || value <= 0) return `Clevis structural ${name} must be positive and finite.`;
+ }
+ if (model.boreDiameterMm < model.pinDiameterMm) {
+ return 'Clevis bore diameter cannot be smaller than pin diameter.';
+ }
+ if (model.tongueThicknessMm >= model.forkGapMm) {
+ return 'Clevis tongue thickness must be smaller than fork gap.';
+ }
+ if (!close(model.supportSpanMm, model.forkGapMm + model.forkPlateThicknessMm)) {
+ return 'Clevis support span must equal fork gap plus one fork plate thickness.';
+ }
+ if (model.edgeDistanceMm - model.boreDiameterMm / 2 <= 0) {
+ return 'Clevis bore leaves no positive edge ligament.';
+ }
+ if (2 * model.edgeDistanceMm - model.boreDiameterMm <= 0) {
+ return 'Clevis bore leaves no positive net section.';
+ }
+ return undefined;
+}
+
+function validateMaterials(materials: NonNullable): string | undefined {
+ for (const role of ['pin', 'fork', 'tongue'] as const) {
+ const material = materials[role];
+ if (
+ typeof material !== 'object' || material === null ||
+ typeof material.name !== 'string' || material.name.trim() === '' ||
+ material.model !== 'isotropic-ductile' ||
+ !positiveFinite(material.yieldStrengthMPa) ||
+ !positiveFinite(material.bearingStrengthMPa) ||
+ (material.shearStrengthMPa !== undefined && !positiveFinite(material.shearStrengthMPa))
+ ) {
+ return `Clevis ${role} structural material declaration is incomplete or invalid.`;
+ }
+ }
+ return undefined;
+}
+
+function shearAllowable(
+ material: StructuralMaterial,
+ role: string,
+ assumptions: string[],
+): number {
+ if (material.shearStrengthMPa !== undefined) return material.shearStrengthMPa;
+ assumptions.push(`${role} shear allowable derived as yieldStrengthMPa / sqrt(3) for isotropic ductile material '${material.name}'.`);
+ return material.yieldStrengthMPa / Math.sqrt(3);
+}
+
+function makeCheck(
+ stressMPa: number,
+ allowableMPa: number,
+ material: StructuralMaterial,
+ minSafetyFactor: number,
+): StructuralCheckEvidence {
+ const safetyFactor = stressMPa === 0 ? null : allowableMPa / stressMPa;
+ return {
+ stressMPa,
+ allowableMPa,
+ safetyFactor,
+ passed: safetyFactor === null || safetyFactor >= minSafetyFactor,
+ materialName: material.name,
+ };
+}
+
+function incomplete(
+ base: { mateName: string; minSafetyFactor: number },
+ message: string,
+): ClevisJointStructureReview {
+ return { ...base, status: 'input-incomplete', checks: {}, assumptions: [], message };
+}
+
+function unsupported(
+ base: { mateName: string; minSafetyFactor: number },
+ message: string,
+): ClevisJointStructureReview {
+ return { ...base, status: 'unsupported-load-case', checks: {}, assumptions: [], message };
+}
+
+function positiveFinite(value: number): boolean {
+ return Number.isFinite(value) && value > 0;
+}
+
+function isFiniteVec3(value: readonly number[]): value is Vec3 {
+ return Array.isArray(value) && value.length === 3 && value.every(Number.isFinite);
+}
+
+function close(a: number, b: number): boolean {
+ return Math.abs(a - b) <= EVIDENCE_TOLERANCE * Math.max(1, Math.abs(a), Math.abs(b));
+}
+
+function dot(a: Vec3, b: Vec3): number {
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
+}
+
+function sub(a: Vec3, b: Vec3): Vec3 {
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
+}
+
+function scale(value: Vec3, scalar: number): Vec3 {
+ return [value[0] * scalar, value[1] * scalar, value[2] * scalar];
+}
+
+function norm(value: Vec3): number {
+ return Math.hypot(value[0], value[1], value[2]);
+}
diff --git a/src/modeling/mates/jointLoadCapacity.test.ts b/src/modeling/mates/jointLoadCapacity.test.ts
index bd9c991f1..75a96c833 100644
--- a/src/modeling/mates/jointLoadCapacity.test.ts
+++ b/src/modeling/mates/jointLoadCapacity.test.ts
@@ -5,17 +5,12 @@
// v0.7.4 Gate 3 — joint-load capacity STUB. Each mate of the four gated
// types (prismatic, revolute, cylindrical, ball) with a declared
// `maxLoad` checks the summed `externalLoads` on its two bound parts
-// against the declared capacity. The module is dead code until Phase 6
-// wires it into `validateAssemblyWithMates`; these tests pin the
-// diagnostic shape and the per-mate-type behaviour per spec
+// against the declared capacity. These tests pin the legacy diagnostic shape
+// and the per-mate-type behaviour per spec
// `2026-05-15-v0.7-kinematic-grounding-design.md` §Gate 3.
//
-// Test-only `maxLoad` injection: `arm.mate(...)` opts does NOT yet accept
-// `maxLoad` (the v0.7.4 wiring lands in Phase 6 alongside `solvedModel`
-// integration). Tests reach into `arm.__mates()` and patch the just-pushed
-// record. Cast through `MateRecord[]` because the public accessor returns
-// a `readonly` view; the underlying array is mutable. This pattern stays
-// local to the test file — production code never patches mate records.
+// Tests inject `maxLoad` directly into captured records to isolate this
+// deprecated manual-load checker from the public capture-time validation.
import { describe, it, expect } from 'vitest';
import { validateJointLoadCapacity } from './jointLoadCapacity';
@@ -32,19 +27,15 @@ function makeArm() {
}
/**
- * Patch `maxLoad` onto the last-declared mate. Cast through `MateRecord[]`
- * is intentional — `__mates()` returns `readonly MateRecord[]` for the
- * public surface, but the underlying array is mutable and Gate 3 reads
- * `mate.maxLoad` directly off each record. This helper isolates the cast
- * so individual tests stay readable.
+ * Patch `maxLoad` onto a declared mate. Cast through `MateRecord[]` is
+ * intentional because `__mates()` exposes a readonly view while this legacy
+ * checker reads the captured records directly.
*/
function setMaxLoad(arm: Assembly, mateName: string, maxLoad: MateLoadLimit): void {
const mates = arm.__mates() as MateRecord[];
const mate = mates.find((m) => m.name === mateName);
if (!mate) throw new Error(`test fixture error: mate '${mateName}' not found on arm '${arm.name}'`);
- // Field is declared `readonly` for the public surface; this test-only
- // mutation matches what Phase 6's `arm.mate(..., { maxLoad })` opts-extension
- // will do under the hood once it lands.
+ // Field is readonly on the public record; this mutation is test-only.
(mate as { maxLoad?: MateLoadLimit }).maxLoad = maxLoad;
}
@@ -247,15 +238,13 @@ describe('validateJointLoadCapacity', () => {
expect(torqueDiag?.hint).toMatch(/torque/);
});
- it('revolute with topology-origin side: emits 1 info-severity deferred note, skips load summation', () => {
+ it('revolute with topology-origin side silently skips load summation', () => {
const { arm, kcad } = makeArm();
// Side A's connector uses a topology query origin — Gate 3 in v0.7.4
- // does not support sync topology resolution and surfaces an
- // info-severity deferred note for that side; side B uses a vec3
- // origin. The mate's load summation is silently SKIPPED, so even
- // with externalLoads that would otherwise blow past the declared
- // torque cap there is no error-severity diagnostic — only the one
- // info note from the topology side.
+ // does not support sync topology resolution; side B uses a vec3 origin.
+ // The mate's load summation is silently skipped, so external loads that
+ // would otherwise exceed the declaration must not produce a false
+ // load-exceeded diagnostic.
const a = kcad.box(20, 20, 5).hole('top', { u: 0, v: 0, diameter: 5, depth: 'through' });
arm
.part('a', a, { at: [50, 0, 0] })
@@ -271,17 +260,7 @@ describe('validateJointLoadCapacity', () => {
};
const diags = validateJointLoadCapacity(arm, externalLoads);
- expect(diags).toHaveLength(1);
- expect(diags[0].code).toBe('assembly.joint.load-exceeded');
- expect(diags[0].severity).toBe('info');
- expect(diags[0].mateName).toBe('hinge');
- expect(diags[0].partA).toBe('a');
- // The deferred-note builder for side 'a' sets `partA` only (not `partB`).
- expect(diags[0].partB).toBeUndefined();
- expect(diags[0].hint).toMatch(/topology connector origin/);
- expect(diags[0].hint).toMatch(/v0\.7\.4/);
- // No error-severity diagnostic — load summation was skipped.
- expect(diags.filter((d) => d.severity === 'error')).toHaveLength(0);
+ expect(diags).toEqual([]);
});
it('ball with maxLoad.force AND maxLoad.torque declared: only force checked, torque silently ignored', () => {
diff --git a/src/modeling/mates/jointLoadCapacity.ts b/src/modeling/mates/jointLoadCapacity.ts
index d34804670..81bf05c2b 100644
--- a/src/modeling/mates/jointLoadCapacity.ts
+++ b/src/modeling/mates/jointLoadCapacity.ts
@@ -2,22 +2,18 @@
// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
// src/lib/mates/jointLoadCapacity.ts
//
-// v0.7.4 Gate 3 — joint-load capacity STUB.
+// Deprecated v0.7.4 Gate 3 — manual external-load capacity STUB.
//
// Spec: `2026-05-15-v0.7-kinematic-grounding-design.md` §Gate 3.
// Plan : `2026-05-15-v0.7-kinematic-grounding.md` §Phase 5.
//
-// For each mate that DECLARED a `maxLoad` and whose type is one of the four
+// This checker does not propagate reactions across joints. For each mate that
+// DECLARED a `maxLoad` and whose type is one of the four
// gated types (`prismatic`, `revolute`, `cylindrical`, `ball`), verify that
// the per-side `externalLoads` applied to the bound parts do not exceed the
// declared capacity. On exceed, emit `assembly.joint.load-exceeded`
// (severity `error`).
//
-// Dead code in this slice — Phase 6 wires it into
-// `validateAssemblyWithMates`. Keeping it import-isolated lets the
-// validator stitch all three Gate 1/2/3 modules together once Task 0's
-// envelope auto-wiring lands.
-//
// ## STUB CAVEATS — read before touching this module
//
// Per the spec's Gate 3 §"Explicit limitation" and §OUT-of-scope:
@@ -47,10 +43,9 @@
// Resolving a `topology` connector origin to a numeric Vec3 requires lowering
// the part shape (`resolveConnectorOrigin` is async). Gate 3 is sync. For
// `maxLoad`-declared mates whose connectors use topology origins on either
-// side, the gate emits one `assembly.joint.load-exceeded`-coded info-severity
-// note per side and SKIPS the mate (no false-positive error from a missing
-// origin). This matches spec open-question 5/2 resolution: vec3-origin
-// requirement for v0.7.4, topology support is a v0.7.x followup.
+// side are silently skipped because no comparison can run. This matches spec
+// open-question 5/2 resolution: vec3-origin requirement for v0.7.4, topology
+// support is a v0.7.x followup.
import type { Assembly, AssemblyPartStored } from '../capture/assembly';
import type { Vec3 } from '../../shared/intent/types';
@@ -90,8 +85,12 @@ const MM_PER_M = 1000;
* independently on a `cylindrical` mate, so a single mate may emit two
* diagnostics).
*
- * Dead code in this slice — Phase 6 of the v0.7.4 plan wires it into
- * `validateAssemblyWithMates`.
+ * Retained for legacy validator callers that still provide manual
+ * `externalLoads`.
+ *
+ * @deprecated This manual `externalLoads` stub is not reaction propagation.
+ * Use physical-use-case joint reactions and `reviewJointReactionCapacity` for
+ * unit-bearing resultant-envelope comparisons.
*/
export function validateJointLoadCapacity(
arm: Assembly,
@@ -115,16 +114,8 @@ export function validateJointLoadCapacity(
const aSide = resolveSide(mate.a, partsByName);
const bSide = resolveSide(mate.b, partsByName);
- // Topology-origin sides: surface a capability-not-supported note per
- // affected side (info severity — this is a documented v0.7.x deferral,
- // not an error in the agent's input). Skip the mate's load summation
- // when either side is unresolvable.
- if (aSide.kind === 'deferred') {
- out.push(makeTopologyDeferredDiag(mate, aSide.partName, aSide.connectorName, 'a'));
- }
- if (bSide.kind === 'deferred') {
- out.push(makeTopologyDeferredDiag(mate, bSide.partName, bSide.connectorName, 'b'));
- }
+ // Topology-origin or missing sides cannot be compared synchronously.
+ // Skip silently rather than emitting a false load-exceeded diagnostic.
if (aSide.kind !== 'resolved' || bSide.kind !== 'resolved') continue;
// Per-mate-type force/torque check. Each branch reads `externalLoads`
@@ -185,8 +176,8 @@ type SideResolution = ResolvedSide | DeferredSide | MissingSide;
* - `'resolved'` when the connector has a `vec3` origin (the gate's
* supported shape).
* - `'deferred'` when the connector uses a `topology` origin — Gate 3
- * in v0.7.4 does not support sync topology resolution; surfaced as an
- * info-severity note upstream.
+ * in v0.7.4 does not support sync topology resolution and is silently
+ * skipped upstream.
* - `'missing'` defensively, when the part/connector ref doesn't
* resolve (shouldn't happen — `arm.mate(...)` validates names at
* capture time).
@@ -370,31 +361,3 @@ function makeLoadExceededDiag(
`an additional joint.`,
};
}
-
-function makeTopologyDeferredDiag(
- mate: MateRecord,
- partName: string,
- connectorName: string,
- side: 'a' | 'b',
-): ValidatorDiagnostic {
- // Info severity — this is a documented v0.7.x deferral, not an error
- // in the agent's input. The mate's load summation is silently skipped
- // when either side is topology-bound; this note tells the agent why
- // the gate didn't fire on a `maxLoad`-tagged mate.
- return {
- code: 'assembly.joint.load-exceeded',
- severity: 'info',
- mateName: mate.name,
- ...(side === 'a' ? { partA: partName } : { partB: partName }),
- message:
- `Mate '${mate.name}' (${mate.type}) side '${partName}.${connectorName}': ` +
- `topology connector origin not supported by Gate 3 in v0.7.4; load capacity check skipped for this side.`,
- hint:
- `invalid-args.assembly.joint-load-exceeded — mate '${mate.name}' (${mate.type}) ` +
- `side '${partName}.${connectorName}' uses a topology connector origin; v0.7.4 joint-load ` +
- `capacity only gates vec3-origin connectors. Switch the connector origin to ` +
- `{ kind: 'vec3', value: [x, y, z] } to enable the gate on this side, or accept that ` +
- `this mate is ungated.`,
- };
-}
-
diff --git a/src/modeling/mates/jointTopology.test.ts b/src/modeling/mates/jointTopology.test.ts
new file mode 100644
index 000000000..0130bbd85
--- /dev/null
+++ b/src/modeling/mates/jointTopology.test.ts
@@ -0,0 +1,370 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+import { describe, expect, it } from 'vitest';
+import { createApi } from '../api';
+import { CaptureSession } from '../capture/captureSession';
+import type { Assembly } from '../capture/assembly';
+import { reviewJointTopology, type JointTopologyDiagnosticCode } from './jointTopology';
+
+function makeApi() {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ return { arm: kcad.assembly('hand'), kcad };
+}
+
+function codesOf(arm: Assembly): JointTopologyDiagnosticCode[] {
+ return reviewJointTopology(arm).diagnostics.map((diagnostic) => diagnostic.code);
+}
+
+function armLike(overrides: {
+ parts?: unknown[];
+ mates?: unknown[];
+ physicalUseCases?: unknown[];
+ mechanicalJointIntents?: unknown[];
+ jointSupportIntents?: unknown[];
+}): Assembly {
+ return {
+ __parts: () => overrides.parts ?? [],
+ __mates: () => overrides.mates ?? [],
+ __physicalUseCases: () => overrides.physicalUseCases ?? [],
+ __mechanicalJointIntents: () => overrides.mechanicalJointIntents ?? [],
+ __jointSupportIntents: () => overrides.jointSupportIntents ?? [],
+ } as unknown as Assembly;
+}
+
+describe('reviewJointTopology', () => {
+ it('reports floating moving parts isolated from physical-use-case stable roots', () => {
+ const { arm, kcad } = makeApi();
+ arm.part('base', kcad.box(10, 10, 4));
+ arm
+ .part('finger-proximal', kcad.box(4, 4, 20))
+ .connector('hinge', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm
+ .part('finger-distal', kcad.box(4, 4, 18))
+ .connector('hinge', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.mate('knuckle', 'finger-proximal.hinge', 'finger-distal.hinge', 'revolute', {
+ limitsDeg: [-20, 80],
+ });
+ arm.mechanicalJoint('knuckle-support', {
+ mate: 'knuckle',
+ actuator: 'finger-proximal',
+ shaft: 'finger-proximal',
+ supports: ['finger-proximal'],
+ output: 'finger-distal',
+ });
+ arm.physicalUseCase('grasp', {
+ stableParts: ['base'],
+ loads: [{ part: 'finger-distal', force: [0, 0, -1] }],
+ });
+
+ const result = reviewJointTopology(arm);
+
+ expect(result.checkedMateCount).toBe(1);
+ expect(result.checkedMovingPartCount).toBe(2);
+ expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain(
+ 'assembly.connectivity.floating-moving-part',
+ );
+ expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain(
+ 'assembly.connectivity.no-load-path',
+ );
+ });
+
+ it('reports a missing rotational limit on a revolute mate', () => {
+ const { arm, kcad } = makeApi();
+ arm
+ .part('base', kcad.box(10, 10, 4))
+ .connector('hinge', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm
+ .part('link', kcad.box(4, 4, 20))
+ .connector('hinge', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.mate('hinge', 'base.hinge', 'link.hinge', 'revolute');
+ arm.mechanicalJoint('hinge-support', {
+ mate: 'hinge',
+ actuator: 'base',
+ shaft: 'base',
+ supports: ['base'],
+ output: 'link',
+ });
+
+ expect(codesOf(arm)).toContain('assembly.joint-topology.missing-limit');
+ });
+
+ it('reports unsupported revolute axes without mechanical joint intent', () => {
+ const { arm, kcad } = makeApi();
+ arm
+ .part('base', kcad.box(10, 10, 4))
+ .connector('hinge', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm
+ .part('link', kcad.box(4, 4, 20))
+ .connector('hinge', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.mate('hinge', 'base.hinge', 'link.hinge', 'revolute', { limitsDeg: [-45, 45] });
+
+ const diagnostics = reviewJointTopology(arm).diagnostics;
+ const unsupportedAxis = diagnostics.find((diagnostic) => diagnostic.code === 'assembly.joint-topology.unsupported-axis');
+
+ expect(unsupportedAxis).toBeTruthy();
+ expect(unsupportedAxis?.hint).toContain('jointSupport');
+ expect(unsupportedAxis?.hint).toContain('mechanicalJoint');
+ });
+
+ it('reports missing connectors and invalid axes from stored mate records', () => {
+ const arm = armLike({
+ parts: [
+ {
+ name: 'base',
+ mateConnectors: [
+ { name: 'hinge', type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 0] },
+ ],
+ },
+ { name: 'link', mateConnectors: [] },
+ ],
+ mates: [
+ {
+ name: 'hinge',
+ a: 'base.hinge',
+ b: 'link.missing',
+ type: 'revolute',
+ limitsDeg: [-45, 45],
+ },
+ ],
+ mechanicalJointIntents: [{ mate: 'hinge' }],
+ });
+
+ expect(codesOf(arm)).toEqual(
+ expect.arrayContaining([
+ 'assembly.joint-topology.connector-missing',
+ 'assembly.joint-topology.axis-invalid',
+ ]),
+ );
+ });
+
+ it('reports invalid axes on prismatic mates', () => {
+ const arm = armLike({
+ parts: [
+ {
+ name: 'base',
+ mateConnectors: [
+ { name: 'slide', type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 0] },
+ ],
+ },
+ {
+ name: 'carriage',
+ mateConnectors: [
+ { name: 'slide', type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [1, 0, 0] },
+ ],
+ },
+ ],
+ mates: [
+ {
+ name: 'slide',
+ a: 'base.slide',
+ b: 'carriage.slide',
+ type: 'prismatic',
+ limitsMm: [0, 10],
+ },
+ ],
+ });
+
+ expect(codesOf(arm)).toContain('assembly.joint-topology.axis-invalid');
+ });
+
+ it('reports mismatched endpoint axes on revolute mates', () => {
+ const arm = armLike({
+ parts: [
+ {
+ name: 'base',
+ mateConnectors: [
+ { name: 'hinge', type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] },
+ ],
+ },
+ {
+ name: 'link',
+ mateConnectors: [
+ { name: 'hinge', type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [1, 0, 0] },
+ ],
+ },
+ ],
+ mates: [
+ {
+ name: 'hinge',
+ a: 'base.hinge',
+ b: 'link.hinge',
+ type: 'revolute',
+ limitsDeg: [-45, 45],
+ },
+ ],
+ mechanicalJointIntents: [
+ { mate: 'hinge', actuator: 'base', shaft: 'base', supports: ['base'], output: 'link' },
+ ],
+ });
+
+ expect(codesOf(arm)).toContain('assembly.joint-topology.axis-invalid');
+ });
+
+ it('does not accept fake support intents that do not capture the driven output', () => {
+ const { arm, kcad } = makeApi();
+ arm
+ .part('base', kcad.box(10, 10, 4))
+ .connector('hinge', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm
+ .part('link', kcad.box(4, 4, 20))
+ .connector('hinge', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('fake-output', kcad.box(4, 4, 4));
+ arm.mate('hinge', 'base.hinge', 'link.hinge', 'revolute', { limitsDeg: [-45, 45] });
+ arm.mechanicalJoint('fake-hinge-support', {
+ mate: 'hinge',
+ actuator: 'base',
+ shaft: 'base',
+ supports: ['base'],
+ output: 'fake-output',
+ });
+
+ expect(codesOf(arm)).toContain('assembly.joint-topology.unsupported-axis');
+ });
+
+ it('does not accept support intents whose supports are disconnected from the hinge support side', () => {
+ const arm = armLike({
+ parts: [
+ {
+ name: 'base',
+ mateConnectors: [
+ { name: 'hinge', type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] },
+ ],
+ },
+ {
+ name: 'link',
+ mateConnectors: [
+ { name: 'hinge', type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] },
+ ],
+ },
+ { name: 'fake-actuator', mateConnectors: [] },
+ { name: 'fake-shaft', mateConnectors: [] },
+ { name: 'fake-support', mateConnectors: [] },
+ ],
+ mates: [
+ {
+ name: 'hinge',
+ a: 'base.hinge',
+ b: 'link.hinge',
+ type: 'revolute',
+ limitsDeg: [-45, 45],
+ },
+ ],
+ mechanicalJointIntents: [
+ {
+ mate: 'hinge',
+ actuator: 'fake-actuator',
+ shaft: 'fake-shaft',
+ supports: ['fake-support'],
+ output: 'link',
+ },
+ ],
+ });
+
+ expect(codesOf(arm)).toContain('assembly.joint-topology.unsupported-axis');
+ });
+
+ it('accepts passive support intents for supported revolute hinges', () => {
+ const arm = armLike({
+ parts: [
+ {
+ name: 'proximal',
+ role: 'structure',
+ mateConnectors: [
+ { name: 'pip', type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [1, 0, 0] },
+ ],
+ },
+ {
+ name: 'middle',
+ role: 'structure',
+ mateConnectors: [
+ { name: 'pip', type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [1, 0, 0] },
+ ],
+ },
+ ],
+ mates: [{ name: 'pip', a: 'proximal.pip', b: 'middle.pip', type: 'revolute', limitsDeg: [0, 40] }],
+ jointSupportIntents: [{ mate: 'pip', shaft: 'proximal', supports: ['proximal'], output: 'middle' }],
+ });
+
+ expect(codesOf(arm)).not.toContain('assembly.joint-topology.unsupported-axis');
+ });
+
+ it('rejects passive support intents disconnected from the hinge support side', () => {
+ const arm = armLike({
+ parts: [
+ {
+ name: 'proximal',
+ role: 'structure',
+ mateConnectors: [
+ { name: 'pip', type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [1, 0, 0] },
+ ],
+ },
+ {
+ name: 'middle',
+ role: 'structure',
+ mateConnectors: [
+ { name: 'pip', type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [1, 0, 0] },
+ ],
+ },
+ { name: 'fake-shaft', role: 'structure', mateConnectors: [] },
+ { name: 'fake-support', role: 'structure', mateConnectors: [] },
+ ],
+ mates: [{ name: 'pip', a: 'proximal.pip', b: 'middle.pip', type: 'revolute', limitsDeg: [0, 40] }],
+ jointSupportIntents: [{ mate: 'pip', shaft: 'fake-shaft', supports: ['fake-support'], output: 'middle' }],
+ });
+
+ expect(codesOf(arm)).toContain('assembly.joint-topology.unsupported-axis');
+ });
+
+ it('does not require load paths for contact target load parts', () => {
+ const arm = armLike({
+ parts: [
+ { name: 'palm-root', role: 'structure', mateConnectors: [] },
+ { name: 'grasp-cylinder', role: 'contact-target', mateConnectors: [] },
+ ],
+ physicalUseCases: [
+ {
+ name: 'grasp',
+ stableParts: ['palm-root'],
+ loads: [{ part: 'grasp-cylinder', force: [0, 0, -3] }],
+ },
+ ],
+ });
+
+ expect(codesOf(arm)).not.toContain('assembly.connectivity.no-load-path');
+ });
+
+ it('accepts a supported hinge with a stable root, finite limits, and mechanical intent', () => {
+ const { arm, kcad } = makeApi();
+ arm
+ .part('palm', kcad.box(20, 16, 4))
+ .connector('index-hinge', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [8, 0, 2] },
+ axis: [0, 1, 0],
+ });
+ arm
+ .part('index-proximal', kcad.box(4, 4, 24))
+ .connector('root', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 1, 0] });
+ arm.mate('index-knuckle', 'palm.index-hinge', 'index-proximal.root', 'revolute', {
+ limitsDeg: [-30, 70],
+ });
+ arm.mechanicalJoint('index-knuckle-support', {
+ mate: 'index-knuckle',
+ actuator: 'palm',
+ shaft: 'palm',
+ supports: ['palm'],
+ output: 'index-proximal',
+ });
+ arm.physicalUseCase('pinch', {
+ stableParts: ['palm'],
+ loads: [{ part: 'index-proximal', force: [0, 0, -1] }],
+ });
+
+ expect(reviewJointTopology(arm)).toEqual({
+ diagnostics: [],
+ checkedMateCount: 1,
+ checkedMovingPartCount: 2,
+ });
+ });
+});
diff --git a/src/modeling/mates/jointTopology.ts b/src/modeling/mates/jointTopology.ts
new file mode 100644
index 000000000..882d67fdf
--- /dev/null
+++ b/src/modeling/mates/jointTopology.ts
@@ -0,0 +1,433 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+import type {
+ Assembly,
+ AssemblyPartStored,
+ JointSupportIntentRecord,
+ MechanicalJointIntentRecord,
+} from '../capture/assembly';
+import type { Connector } from './connector';
+import { parseConnectorRef, type MateLimitRange, type MateRecord } from './mate';
+
+export type JointTopologyDiagnosticCode =
+ | 'assembly.connectivity.floating-moving-part'
+ | 'assembly.connectivity.no-load-path'
+ | 'assembly.joint-topology.connector-missing'
+ | 'assembly.joint-topology.axis-invalid'
+ | 'assembly.joint-topology.missing-limit'
+ | 'assembly.joint-topology.unsupported-axis';
+
+export interface JointTopologyDiagnostic {
+ readonly code: JointTopologyDiagnosticCode;
+ readonly severity: 'error';
+ readonly message: string;
+ readonly hint: string;
+ readonly mateName?: string;
+ readonly partName?: string;
+ readonly connectorRef?: string;
+ readonly useCaseName?: string;
+ readonly stableParts?: readonly string[];
+}
+
+export interface JointTopologyReviewResult {
+ readonly diagnostics: readonly JointTopologyDiagnostic[];
+ readonly checkedMateCount: number;
+ readonly checkedMovingPartCount: number;
+}
+
+interface ParsedEndpoint {
+ readonly ref: string;
+ readonly partName: string;
+ readonly connectorName: string;
+ readonly connector?: Connector;
+}
+
+interface JointSupportLikeIntent {
+ readonly mate: string;
+ readonly shaft: string;
+ readonly supports: readonly string[];
+ readonly output: string;
+}
+
+const ROOT_FALLBACK_NAMES = ['palm-root', 'palm', 'base', 'root'] as const;
+const AXIS_ALIGNMENT_TOLERANCE = 0.999;
+const AXIS_REQUIRED_MATES = new Set(['revolute', 'prismatic', 'cylindrical', 'pin_slot']);
+const ROTATIONAL_LIMIT_MATES = new Set(['revolute', 'cylindrical', 'pin_slot']);
+
+export function reviewJointTopology(arm: Assembly): JointTopologyReviewResult {
+ const diagnostics: JointTopologyDiagnostic[] = [];
+ const parts = arm.__parts();
+ const partsByName = new Map(parts.map((part) => [part.name, part]));
+ const mates = arm.__mates();
+ const graph = new Map>();
+ const movingParts = new Set();
+ const supportedRevoluteMates = collectSupportedRevoluteMates(arm, partsByName);
+
+ for (const part of parts) {
+ graph.set(part.name, new Set());
+ }
+
+ const checkedMates = mates.filter((mate) => mate.type !== 'fastened');
+
+ for (const mate of mates) {
+ const a = parseEndpoint(mate.a, partsByName);
+ const b = parseEndpoint(mate.b, partsByName);
+
+ if (a.partName !== undefined && b.partName !== undefined && partsByName.has(a.partName) && partsByName.has(b.partName)) {
+ graph.get(a.partName)?.add(b.partName);
+ graph.get(b.partName)?.add(a.partName);
+ }
+
+ if (mate.type === 'fastened') continue;
+
+ recordMovingPart(a, partsByName, movingParts);
+ recordMovingPart(b, partsByName, movingParts);
+
+ validateEndpointContract(mate, a, diagnostics);
+ validateEndpointContract(mate, b, diagnostics);
+ validateMateAxisAlignment(mate, a, b, diagnostics);
+ validateMateContract(mate, supportedRevoluteMates, diagnostics);
+ }
+
+ const stableRoots = collectStableRoots(arm, partsByName);
+ const reachableFromRoots = findReachableParts(stableRoots, graph);
+
+ for (const partName of movingParts) {
+ if (!reachableFromRoots.has(partName)) {
+ diagnostics.push({
+ code: 'assembly.connectivity.floating-moving-part',
+ severity: 'error',
+ partName,
+ stableParts: [...stableRoots],
+ message: `Moving part '${partName}' has no mate-graph path to a stable root.`,
+ hint: `connectivity.floating-moving-part — connect '${partName}' through mates to a stable root (${formatRoots(stableRoots)}), or declare the intended root in physicalUseCase(...).stableParts.`,
+ });
+ }
+ }
+
+ for (const useCase of arm.__physicalUseCases()) {
+ const useCaseStableParts = stableRootsForUseCase(useCase.stableParts, partsByName);
+ const reachableForUseCase = findReachableParts(useCaseStableParts, graph);
+ for (const load of useCase.loads) {
+ const loadPart = partsByName.get(load.part);
+ if (loadPart === undefined) continue;
+ if (loadPart.role === 'contact-target') continue;
+ if (!reachableForUseCase.has(load.part)) {
+ diagnostics.push({
+ code: 'assembly.connectivity.no-load-path',
+ severity: 'error',
+ useCaseName: useCase.name,
+ partName: load.part,
+ stableParts: [...useCaseStableParts],
+ message: `Physical use case '${useCase.name}' load part '${load.part}' has no mate-graph path to a stable root.`,
+ hint: `connectivity.no-load-path — add mates from '${load.part}' back to a stable part (${formatRoots(useCaseStableParts)}) so applied loads have a structural path.`,
+ });
+ }
+ }
+ }
+
+ return {
+ diagnostics,
+ checkedMateCount: checkedMates.length,
+ checkedMovingPartCount: movingParts.size,
+ };
+}
+
+function parseEndpoint(ref: string, partsByName: ReadonlyMap): Partial {
+ try {
+ const parsed = parseConnectorRef(ref);
+ const part = partsByName.get(parsed.partName);
+ return {
+ ref,
+ partName: parsed.partName,
+ connectorName: parsed.connectorName,
+ connector: part?.mateConnectors.find((connector) => connector.name === parsed.connectorName),
+ };
+ } catch {
+ return { ref };
+ }
+}
+
+function recordMovingPart(
+ endpoint: Partial,
+ partsByName: ReadonlyMap,
+ movingParts: Set,
+): void {
+ if (endpoint.partName !== undefined && partsByName.has(endpoint.partName)) {
+ movingParts.add(endpoint.partName);
+ }
+}
+
+function validateEndpointContract(
+ mate: MateRecord,
+ endpoint: Partial,
+ diagnostics: JointTopologyDiagnostic[],
+): void {
+ if (endpoint.partName === undefined || endpoint.connectorName === undefined || endpoint.connector === undefined) {
+ diagnostics.push({
+ code: 'assembly.joint-topology.connector-missing',
+ severity: 'error',
+ mateName: mate.name,
+ partName: endpoint.partName,
+ connectorRef: endpoint.ref,
+ message: `Mate '${mate.name}' references missing connector '${endpoint.ref}'.`,
+ hint: `joint-topology.connector-missing — declare both mate endpoints as '.' refs on existing parts before reviewing topology.`,
+ });
+ return;
+ }
+
+ if (!isNumericVec3Origin(endpoint.connector.origin)) {
+ diagnostics.push({
+ code: 'assembly.joint-topology.axis-invalid',
+ severity: 'error',
+ mateName: mate.name,
+ partName: endpoint.partName,
+ connectorRef: endpoint.ref,
+ message: `Mate '${mate.name}' connector '${endpoint.ref}' does not have a numeric vec3 origin.`,
+ hint: `joint-topology.axis-invalid — give '${endpoint.ref}' origin: { kind: 'vec3', value: [x, y, z] } with finite numbers.`,
+ });
+ }
+
+ if (AXIS_REQUIRED_MATES.has(mate.type) && !isFiniteNonZeroVec3(endpoint.connector.axis)) {
+ diagnostics.push({
+ code: 'assembly.joint-topology.axis-invalid',
+ severity: 'error',
+ mateName: mate.name,
+ partName: endpoint.partName,
+ connectorRef: endpoint.ref,
+ message: `Mate '${mate.name}' connector '${endpoint.ref}' has an invalid joint axis.`,
+ hint: `joint-topology.axis-invalid — give '${endpoint.ref}' a finite non-zero axis vector aligned with the intended joint.`,
+ });
+ }
+}
+
+function validateMateAxisAlignment(
+ mate: MateRecord,
+ a: Partial,
+ b: Partial,
+ diagnostics: JointTopologyDiagnostic[],
+): void {
+ if (!AXIS_REQUIRED_MATES.has(mate.type)) return;
+
+ const axisA = normaliseAxis(a.connector?.axis);
+ const axisB = normaliseAxis(b.connector?.axis);
+ if (axisA === undefined || axisB === undefined) return;
+
+ if (Math.abs(dot(axisA, axisB)) < AXIS_ALIGNMENT_TOLERANCE) {
+ diagnostics.push({
+ code: 'assembly.joint-topology.axis-invalid',
+ severity: 'error',
+ mateName: mate.name,
+ connectorRef: `${mate.a} / ${mate.b}`,
+ message: `Mate '${mate.name}' endpoint axes are not aligned.`,
+ hint: `joint-topology.axis-invalid — align '${mate.a}' and '${mate.b}' axes so the '${mate.type}' joint has one coherent physical axis.`,
+ });
+ }
+}
+
+function validateMateContract(
+ mate: MateRecord,
+ supportedRevoluteMates: ReadonlySet,
+ diagnostics: JointTopologyDiagnostic[],
+): void {
+ if (ROTATIONAL_LIMIT_MATES.has(mate.type) && !isFiniteRange(mate.limitsDeg)) {
+ diagnostics.push({
+ code: 'assembly.joint-topology.missing-limit',
+ severity: 'error',
+ mateName: mate.name,
+ message: `Mate '${mate.name}' is type '${mate.type}' and needs finite limitsDeg.`,
+ hint: `joint-topology.missing-limit — add limitsDeg: [minDeg, maxDeg] to '${mate.name}'.`,
+ });
+ }
+
+ if (mate.type === 'prismatic' && !isFiniteRange(mate.limitsMm)) {
+ diagnostics.push({
+ code: 'assembly.joint-topology.missing-limit',
+ severity: 'error',
+ mateName: mate.name,
+ message: `Mate '${mate.name}' is prismatic and needs finite limitsMm.`,
+ hint: `joint-topology.missing-limit — add limitsMm: [minMm, maxMm] to '${mate.name}'.`,
+ });
+ }
+
+ if (mate.type === 'revolute' && !supportedRevoluteMates.has(mate.name)) {
+ diagnostics.push({
+ code: 'assembly.joint-topology.unsupported-axis',
+ severity: 'error',
+ mateName: mate.name,
+ message: `Revolute mate '${mate.name}' has no joint support intent declaring support.`,
+ hint: `joint-topology.unsupported-axis — add arm.jointSupport(..., { mate: '${mate.name}', shaft, supports, output }) for passive hinges, or arm.mechanicalJoint(..., { mate: '${mate.name}', actuator, shaft, supports, output }) for driven hinges, so the hinge axis has physical support intent.`,
+ });
+ }
+}
+
+function collectSupportedRevoluteMates(
+ arm: Assembly,
+ partsByName: ReadonlyMap,
+): Set {
+ const supported = new Set();
+ const mates = arm.__mates();
+ const matesByName = new Map(mates.map((mate) => [mate.name, mate]));
+ const fastenedGraph = buildFastenedGraph(mates, partsByName);
+
+ for (const intent of arm.__mechanicalJointIntents()) {
+ if (!isCompleteDrivenMechanicalIntent(intent, matesByName, partsByName, fastenedGraph)) continue;
+ supported.add(intent.mate);
+ }
+
+ for (const intent of arm.__jointSupportIntents()) {
+ if (!isCompleteJointSupportIntent(intent, matesByName, partsByName, fastenedGraph)) continue;
+ supported.add(intent.mate);
+ }
+
+ return supported;
+}
+
+function isCompleteDrivenMechanicalIntent(
+ intent: MechanicalJointIntentRecord,
+ matesByName: ReadonlyMap,
+ partsByName: ReadonlyMap,
+ fastenedGraph: ReadonlyMap>,
+): boolean {
+ if (!partsByName.has(intent.actuator)) return false;
+ return isCompleteJointSupportIntent(intent, matesByName, partsByName, fastenedGraph);
+}
+
+function isCompleteJointSupportIntent(
+ intent: JointSupportIntentRecord | JointSupportLikeIntent,
+ matesByName: ReadonlyMap,
+ partsByName: ReadonlyMap,
+ fastenedGraph: ReadonlyMap>,
+): boolean {
+ const mate = matesByName.get(intent.mate);
+ if (mate === undefined || mate.type !== 'revolute') return false;
+ if (!partsByName.has(intent.shaft)) return false;
+ if (!partsByName.has(intent.output)) return false;
+ if (intent.supports.length === 0 || intent.supports.some((support) => !partsByName.has(support))) return false;
+
+ const endpointParts = mateEndpointParts(mate);
+ if (endpointParts === undefined || !endpointParts.names.has(intent.output)) return false;
+
+ const supportSide = endpointParts.a === intent.output ? endpointParts.b : endpointParts.a;
+ const fastenedToSupportSide = findReachableParts(new Set([supportSide]), fastenedGraph);
+ return fastenedToSupportSide.has(intent.shaft) && intent.supports.some((support) => fastenedToSupportSide.has(support));
+}
+
+function buildFastenedGraph(
+ mates: readonly MateRecord[],
+ partsByName: ReadonlyMap,
+): Map> {
+ const graph = new Map>();
+ for (const partName of partsByName.keys()) graph.set(partName, new Set());
+
+ for (const mate of mates) {
+ if (mate.type !== 'fastened') continue;
+ const endpointParts = mateEndpointParts(mate);
+ if (endpointParts === undefined) continue;
+ if (!partsByName.has(endpointParts.a) || !partsByName.has(endpointParts.b)) continue;
+ graph.get(endpointParts.a)?.add(endpointParts.b);
+ graph.get(endpointParts.b)?.add(endpointParts.a);
+ }
+
+ return graph;
+}
+
+function mateEndpointParts(mate: MateRecord): { readonly a: string; readonly b: string; readonly names: ReadonlySet } | undefined {
+ try {
+ const a = parseConnectorRef(mate.a).partName;
+ const b = parseConnectorRef(mate.b).partName;
+ return { a, b, names: new Set([a, b]) };
+ } catch {
+ return undefined;
+ }
+}
+
+function collectStableRoots(
+ arm: Assembly,
+ partsByName: ReadonlyMap,
+): Set {
+ const roots = new Set();
+ for (const useCase of arm.__physicalUseCases()) {
+ for (const stablePart of useCase.stableParts) {
+ if (partsByName.has(stablePart)) roots.add(stablePart);
+ }
+ }
+ for (const fallback of ROOT_FALLBACK_NAMES) {
+ if (partsByName.has(fallback)) roots.add(fallback);
+ }
+ return roots;
+}
+
+function stableRootsForUseCase(
+ stableParts: readonly string[],
+ partsByName: ReadonlyMap,
+): Set {
+ const roots = new Set();
+ for (const stablePart of stableParts) {
+ if (partsByName.has(stablePart)) roots.add(stablePart);
+ }
+ for (const fallback of ROOT_FALLBACK_NAMES) {
+ if (partsByName.has(fallback)) roots.add(fallback);
+ }
+ return roots;
+}
+
+function findReachableParts(roots: ReadonlySet, graph: ReadonlyMap>): Set {
+ const reachable = new Set();
+ const pending = [...roots].filter((root) => graph.has(root));
+ for (const root of pending) reachable.add(root);
+
+ while (pending.length > 0) {
+ const current = pending.pop();
+ if (current === undefined) break;
+ for (const neighbor of graph.get(current) ?? []) {
+ if (!reachable.has(neighbor)) {
+ reachable.add(neighbor);
+ pending.push(neighbor);
+ }
+ }
+ }
+
+ return reachable;
+}
+
+function isNumericVec3Origin(origin: Connector['origin']): boolean {
+ return origin.kind === 'vec3' && isFiniteVec3(origin.value);
+}
+
+function isFiniteNonZeroVec3(value: unknown): value is readonly [number, number, number] {
+ return isFiniteVec3(value) && (value[0] !== 0 || value[1] !== 0 || value[2] !== 0);
+}
+
+function normaliseAxis(value: unknown): readonly [number, number, number] | undefined {
+ if (!isFiniteNonZeroVec3(value)) return undefined;
+ const length = Math.hypot(value[0], value[1], value[2]);
+ return [value[0] / length, value[1] / length, value[2] / length];
+}
+
+function dot(a: readonly [number, number, number], b: readonly [number, number, number]): number {
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
+}
+
+function isFiniteVec3(value: unknown): value is readonly [number, number, number] {
+ return (
+ Array.isArray(value) &&
+ value.length === 3 &&
+ value.every((coord) => typeof coord === 'number' && Number.isFinite(coord))
+ );
+}
+
+function isFiniteRange(range: MateLimitRange | undefined): range is MateLimitRange {
+ return (
+ Array.isArray(range) &&
+ range.length === 2 &&
+ typeof range[0] === 'number' &&
+ typeof range[1] === 'number' &&
+ Number.isFinite(range[0]) &&
+ Number.isFinite(range[1])
+ );
+}
+
+function formatRoots(roots: ReadonlySet): string {
+ return roots.size === 0 ? 'none declared' : [...roots].join(', ');
+}
diff --git a/src/modeling/mates/mate.ts b/src/modeling/mates/mate.ts
index 0c3ceca42..e89a536d3 100644
--- a/src/modeling/mates/mate.ts
+++ b/src/modeling/mates/mate.ts
@@ -10,6 +10,7 @@
// `Assembly.model()` / `Assembly.solvedModel()`.
import type { Editable } from '../../shared/runtime/paramRef';
+import type { ClevisStructuralModel } from '../joints/types';
import type { MateType } from './mateTypes';
/**
@@ -29,21 +30,20 @@ export type MatePose =
export type MateLimitRange = readonly [number, number];
+export interface MateCapacityEnvelope {
+ readonly maxResultantForceN: number;
+ readonly maxResultantMomentNmm: number;
+}
+
+/** Declared mate ratings. An envelope comparison is not structural proof. */
+export interface MateCapacity {
+ readonly envelope?: MateCapacityEnvelope;
+ readonly structure?: ClevisStructuralModel;
+}
+
/**
- * Optional declared load capacity for a mate. v0.7.4 adds this as a stable
- * agent-facing surface for Gate 3 (joint-load static check).
- *
- * Unit semantics per mate type (see spec
- * `2026-05-15-v0.7-kinematic-grounding-design.md` §Gate 3):
- * - `revolute`: only `torque` is meaningful (N·m). `force` is ignored if set.
- * - `prismatic`: only `force` is meaningful (N). `torque` is ignored if set.
- * - `cylindrical`: both `force` (N) and `torque` (N·m) may be set.
- * - `ball`: only `force` (N).
- * - `fastened` / `planar` / `pin_slot`: `maxLoad` is permitted but **not
- * gated** in v0.7.4 — the type accepts it so the agent surface is stable
- * for the v0.7.x extension, but the validator does not run summation. This
- * is silent acceptance per the spec's open-question 4 resolution (no
- * warning at every script run).
+ * @deprecated Legacy manual-load API. Use the unit-bearing
+ * `MateCapacity.envelope` ratings for reaction comparisons.
*/
export interface MateLoadLimit {
/** Maximum allowable applied force in Newtons. */
@@ -67,9 +67,9 @@ export interface MateRecord {
readonly limitsDeg?: MateLimitRange;
/** Linear scalar pose limits in mm for prismatic mates. */
readonly limitsMm?: MateLimitRange;
- /** Optional static-load capacity. Read by the v0.7.4 Gate 3 stub
- * (`validateJointLoadCapacity`). Per the field's unit semantics, see
- * `MateLoadLimit` JSDoc. */
+ /** Declared resultant rating; comparison against it is not structural proof. */
+ readonly capacity?: MateCapacity;
+ /** @deprecated legacy manual-load API */
readonly maxLoad?: MateLoadLimit;
/** Visual-exposure declaration read by Gate 4 (`jointVisualExposure`).
* Default `'exposed'`: the joint must read as a hinge (fork daylight +
diff --git a/src/modeling/mates/mechanicalPlausibility.ts b/src/modeling/mates/mechanicalPlausibility.ts
index 7fd87ff15..d8ebd87c3 100644
--- a/src/modeling/mates/mechanicalPlausibility.ts
+++ b/src/modeling/mates/mechanicalPlausibility.ts
@@ -260,7 +260,8 @@ export async function reviewMechanicalPlausibility(
const partB = partsByName.get(b.partName);
const connectorA = partA?.mateConnectors.find((c) => c.name === a.connectorName);
const connectorB = partB?.mateConnectors.find((c) => c.name === b.connectorName);
- const hasDeclaredDriveSupport = arm.__mechanicalJointIntents().some((intent) => intent.mate === mate.name);
+ const hasDeclaredDriveSupport = arm.__mechanicalJointIntents().some((intent) => intent.mate === mate.name) ||
+ arm.__jointSupportIntents().some((intent) => intent.mate === mate.name);
if (
!hasDeclaredDriveSupport &&
partA !== undefined &&
diff --git a/src/modeling/mates/mechanismFitness.ts b/src/modeling/mates/mechanismFitness.ts
index 2057d011d..b212330ca 100644
--- a/src/modeling/mates/mechanismFitness.ts
+++ b/src/modeling/mates/mechanismFitness.ts
@@ -5,6 +5,8 @@ import type { ValidatorDiagnostic } from './validator';
import type { MechanicalPlausibilityDiagnostic } from './mechanicalPlausibility';
import type { MechanicalIntentDiagnostic } from './mechanicalIntent';
import type { MechanicalTransmissionDiagnostic } from './mechanicalTransmission';
+import type { PhysicalUseCaseDiagnostic } from './physicalUseCase';
+import type { JointTopologyDiagnostic } from './jointTopology';
export interface MechanismBlockingReason {
readonly code: string;
@@ -30,6 +32,9 @@ export interface MechanismSummary {
readonly mechanicalPlausibilityIssueCount?: number;
readonly mechanicalIntentIssueCount?: number;
readonly mechanicalTransmissionIssueCount?: number;
+ readonly jointTopologyIssueCount?: number;
+ readonly physicalUseCaseCount?: number;
+ readonly physicalUseCaseIssueCount?: number;
}
export interface MechanismFitnessResult {
@@ -46,6 +51,9 @@ export interface MechanismFitnessInput {
readonly mechanicalPlausibilityDiagnostics?: readonly MechanicalPlausibilityDiagnostic[];
readonly mechanicalIntentDiagnostics?: readonly MechanicalIntentDiagnostic[];
readonly mechanicalTransmissionDiagnostics?: readonly MechanicalTransmissionDiagnostic[];
+ readonly jointTopologyDiagnostics?: readonly JointTopologyDiagnostic[];
+ readonly physicalUseCaseDiagnostics?: readonly PhysicalUseCaseDiagnostic[];
+ readonly physicalUseCaseCount?: number;
readonly poseEnvelope?: PoseEnvelopeReviewResult;
readonly trackConnectors?: readonly string[];
}
@@ -56,6 +64,7 @@ const PASSED_CHECKS = {
poseEnvelopeNoInterference: 'pose-envelope-no-interference',
trackedConnectorsMove: 'tracked-connectors-move',
gripperApertureMoves: 'gripper-aperture-moves',
+ physicalUseCaseDeclared: 'physical-use-case-declared',
} as const;
export function summarizeMechanismFitness(
@@ -65,6 +74,9 @@ export function summarizeMechanismFitness(
const mechanicalPlausibilityDiagnostics = input.mechanicalPlausibilityDiagnostics ?? [];
const mechanicalIntentDiagnostics = input.mechanicalIntentDiagnostics ?? [];
const mechanicalTransmissionDiagnostics = input.mechanicalTransmissionDiagnostics ?? [];
+ const jointTopologyDiagnostics = input.jointTopologyDiagnostics ?? [];
+ const physicalUseCaseDiagnostics = input.physicalUseCaseDiagnostics ?? [];
+ const physicalUseCaseCount = input.physicalUseCaseCount ?? 0;
const poseEnvelope = input.poseEnvelope;
const trackConnectors = input.trackConnectors ?? [];
@@ -123,6 +135,28 @@ export function summarizeMechanismFitness(
);
}
+ for (const diagnostic of jointTopologyDiagnostics) {
+ addBlockingReason(
+ diagnostic.code,
+ diagnostic.message,
+ diagnostic.hint,
+ diagnostic,
+ );
+ }
+
+ for (const diagnostic of physicalUseCaseDiagnostics) {
+ addBlockingReason(
+ diagnostic.code,
+ diagnostic.message,
+ diagnostic.hint,
+ diagnostic,
+ );
+ }
+
+ if (physicalUseCaseCount > 0 && physicalUseCaseDiagnostics.length === 0) {
+ passedChecks.push(PASSED_CHECKS.physicalUseCaseDeclared);
+ }
+
const hasPoseEnvelopeErrors = poseEnvelope?.diagnostics.some((diagnostic) => diagnostic.severity === 'error') ?? false;
if (poseEnvelope) {
for (const diagnostic of poseEnvelope.diagnostics) {
@@ -206,6 +240,8 @@ export function summarizeMechanismFitness(
const mechanicalPlausibilityIssueCount = mechanicalPlausibilityDiagnostics.length;
const mechanicalIntentIssueCount = mechanicalIntentDiagnostics.length;
const mechanicalTransmissionIssueCount = mechanicalTransmissionDiagnostics.length;
+ const jointTopologyIssueCount = jointTopologyDiagnostics.length;
+ const physicalUseCaseIssueCount = physicalUseCaseDiagnostics.length;
const repairMode = chooseRepairMode(blockingReasons);
return {
@@ -227,6 +263,9 @@ export function summarizeMechanismFitness(
...(mechanicalPlausibilityIssueCount === 0 ? {} : { mechanicalPlausibilityIssueCount }),
...(mechanicalIntentIssueCount === 0 ? {} : { mechanicalIntentIssueCount }),
...(mechanicalTransmissionIssueCount === 0 ? {} : { mechanicalTransmissionIssueCount }),
+ ...(jointTopologyIssueCount === 0 ? {} : { jointTopologyIssueCount }),
+ ...(physicalUseCaseCount === 0 ? {} : { physicalUseCaseCount }),
+ ...(physicalUseCaseIssueCount === 0 ? {} : { physicalUseCaseIssueCount }),
},
};
}
@@ -240,6 +279,13 @@ function chooseRepairMode(
return 'topology-redesign';
}
+ if (blockingReasons.some((reason) =>
+ reason.code.startsWith('assembly.connectivity.') ||
+ reason.code.startsWith('assembly.joint-topology.')
+ )) {
+ return 'topology-redesign';
+ }
+
if (blockingReasons.every((reason) => reason.code === 'assembly.pose.out-of-limits')) {
return 'parameter-tune';
}
diff --git a/src/modeling/mates/physicalUseCase.test.ts b/src/modeling/mates/physicalUseCase.test.ts
new file mode 100644
index 000000000..e91a36a22
--- /dev/null
+++ b/src/modeling/mates/physicalUseCase.test.ts
@@ -0,0 +1,433 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+import { describe, expect, it } from 'vitest';
+import { CaptureSession } from '../capture/captureSession';
+import { createApi } from '../api';
+import {
+ makePhysicalUseCaseRecord,
+ reviewPhysicalUseCasesWithReachability,
+} from './physicalUseCase';
+
+function makeStaticReviewRig(maxTorqueNmm: number) {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ const arm = kcad.assembly('static review rig');
+ arm
+ .part('base', kcad.box(50, 20, 8))
+ .connector('left-axis', { type: 'axis', origin: { kind: 'vec3', value: [-20, 0, 0] }, axis: [0, 1, 0] })
+ .connector('right-axis', { type: 'axis', origin: { kind: 'vec3', value: [20, 0, 0] }, axis: [0, 1, 0] });
+ arm
+ .part('left-finger', kcad.box(10, 4, 4))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [-20, 0, 0] }, axis: [0, 1, 0] })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [-10, 0, 0] } });
+ arm
+ .part('right-finger', kcad.box(10, 4, 4))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [20, 0, 0] }, axis: [0, 1, 0] })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } });
+ arm
+ .part('held', kcad.box(20, 10, 10), { role: 'contact-target' })
+ .connector('center', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } })
+ .connector('left-contact', { type: 'frame', origin: { kind: 'vec3', value: [-10, 0, 0] } })
+ .connector('right-contact', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } });
+ arm.mate('left-curl', 'base.left-axis', 'left-finger.axis', 'revolute', { limitsDeg: [0, 1] });
+ arm.mate('right-curl', 'base.right-axis', 'right-finger.axis', 'revolute', { limitsDeg: [0, 1] });
+ arm.physicalUseCase('hold-object', {
+ stableParts: ['base'],
+ loads: [{ part: 'held', at: 'held.center', force: [0, 0, -6] }],
+ contacts: [
+ { a: 'left-finger.tip', b: 'held.left-contact', normal: [-1, 0, 0], friction: 0.5, normalForceN: 8 },
+ { a: 'right-finger.tip', b: 'held.right-contact', normal: [1, 0, 0], friction: 0.5, normalForceN: 8 },
+ ],
+ actuatorLimits: [
+ { mate: 'left-curl', maxTorqueNmm },
+ { mate: 'right-curl', maxTorqueNmm },
+ ],
+ criteria: { maxSlipMm: 0.01, maxForceResidualN: 0.01, maxTorqueResidualNmm: 0.1 },
+ });
+ return arm;
+}
+
+function makeStructurallyRatedClevisRig(
+ opts: {
+ envelopeForceN?: number;
+ includeEnvelope?: boolean;
+ includeStructure?: boolean;
+ minJointSafetyFactor?: number;
+ } = {},
+) {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ const arm = kcad.assembly('rated clevis rig');
+ const steel = {
+ name: 'test steel',
+ model: 'isotropic-ductile' as const,
+ yieldStrengthMPa: 250,
+ bearingStrengthMPa: 400,
+ };
+ const clevis = kcad.joint.clevis({
+ parentBody: kcad.box(30, 30, 10, true),
+ childBody: kcad.box(50, 6, 6, true).translate(25, 0, 0),
+ axis: 'Z',
+ pivotParent: [0, 0, 0],
+ pivotChild: [0, 0, 0],
+ liftPivot: false,
+ style: {
+ knuckleR: 10,
+ forkGapY: 6,
+ tongueY: 5,
+ plateT: 4,
+ pinR: 3,
+ holeClearance: 0.2,
+ },
+ engineering: { pin: steel, fork: steel, tongue: steel },
+ });
+
+ arm
+ .part('base', clevis.parentGeometry)
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: clevis.parentConnector.origin },
+ axis: clevis.parentConnector.axis,
+ jointClearanceRadius: clevis.parentConnector.clearanceRadius,
+ });
+ arm
+ .part('finger', clevis.childGeometry)
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: clevis.childConnector.origin },
+ axis: clevis.childConnector.axis,
+ jointClearanceRadius: clevis.childConnector.clearanceRadius,
+ })
+ .connector('tip', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [50, 0, 0] },
+ });
+ arm
+ .part('held', kcad.box(6, 6, 6, true), { role: 'contact-target' })
+ .connector('contact', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [50, 0, 0] },
+ })
+ .connector('load-point', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [50, 0, 0] },
+ });
+
+ arm.mate('hinge', 'base.axis', 'finger.axis', 'revolute', {
+ pose: 0,
+ limitsDeg: [-1, 1],
+ capacity: {
+ ...(opts.includeEnvelope === false ? {} : {
+ envelope: {
+ maxResultantForceN: opts.envelopeForceN ?? 100,
+ maxResultantMomentNmm: 1000,
+ },
+ }),
+ ...(opts.includeStructure === false ? {} : { structure: clevis.structural }),
+ },
+ });
+ arm.mechanicalJoint('hinge-drive', {
+ mate: 'hinge',
+ actuator: 'base',
+ shaft: 'base',
+ supports: ['base'],
+ output: 'finger',
+ });
+ arm.physicalUseCase('hold-load', {
+ stableParts: ['base'],
+ loads: [{ part: 'held', at: 'held.load-point', force: [0, -10, 0] }],
+ contacts: [{
+ a: 'finger.tip',
+ b: 'held.contact',
+ normal: [0, -1, 0],
+ normalFrame: 'world',
+ friction: 0.5,
+ normalForceN: 20,
+ }],
+ actuatorLimits: [{ mate: 'hinge', maxTorqueNmm: 1000 }],
+ criteria: {
+ maxSlipMm: 0.001,
+ maxForceResidualN: 0.01,
+ maxTorqueResidualNmm: 0.1,
+ ...(opts.minJointSafetyFactor === undefined
+ ? {}
+ : { minJointSafetyFactor: opts.minJointSafetyFactor }),
+ },
+ });
+ return arm;
+}
+
+describe('physical use case records', () => {
+ it('deep-copies nested load, contact, actuator, criteria, and vector inputs', () => {
+ const force: [number, number, number] = [1, 2, 3];
+ const torque: [number, number, number] = [4, 5, 6];
+ const normal: [number, number, number] = [0, 0, 1];
+ const loads = [{ part: 'link', at: 'link.load-point', force, torque }];
+ const contacts = [{
+ a: 'link.tip',
+ b: 'base.target',
+ normal,
+ normalFrame: 'a' as 'a' | 'b',
+ friction: 0.5,
+ normalForceN: 10,
+ }];
+ const actuatorLimits = [{ mate: 'yaw', maxTorqueNmm: 100 }];
+ const criteria = {
+ maxSlipMm: 2,
+ settleTimeMs: 50,
+ maxForceResidualN: 0.01,
+ maxTorqueResidualNmm: 0.1,
+ };
+
+ const record = makePhysicalUseCaseRecord('touch-target', {
+ stableParts: ['base'],
+ loads,
+ contacts,
+ actuatorLimits,
+ criteria,
+ });
+
+ loads[0].part = 'mutated-link';
+ loads[0].at = 'mutated.load-point';
+ force[0] = 99;
+ torque[1] = 99;
+ contacts[0].a = 'mutated.tip';
+ contacts[0].normalFrame = 'b';
+ normal[2] = 99;
+ contacts[0].friction = 99;
+ actuatorLimits[0].mate = 'mutated-yaw';
+ actuatorLimits[0].maxTorqueNmm = 99;
+ criteria.maxSlipMm = 99;
+
+ expect(record.loads[0]).toEqual({
+ part: 'link',
+ at: 'link.load-point',
+ force: [1, 2, 3],
+ torque: [4, 5, 6],
+ });
+ expect(record.contacts[0]).toEqual({
+ a: 'link.tip',
+ b: 'base.target',
+ normal: [0, 0, 1],
+ normalFrame: 'a',
+ friction: 0.5,
+ normalForceN: 10,
+ });
+ expect(record.actuatorLimits[0]).toEqual({ mate: 'yaw', maxTorqueNmm: 100 });
+ expect(record.criteria).toEqual({
+ maxSlipMm: 2,
+ settleTimeMs: 50,
+ maxForceResidualN: 0.01,
+ maxTorqueResidualNmm: 0.1,
+ });
+ });
+
+ it('rejects invalid statics frames and residual tolerances at capture time', () => {
+ expect(() => makePhysicalUseCaseRecord('bad-frame', {
+ contacts: [{
+ a: 'finger.tip',
+ b: 'held.contact',
+ normal: [1, 0, 0],
+ normalFrame: 'part' as never,
+ friction: 0.5,
+ }],
+ })).toThrow(/normalFrame/);
+
+ for (const criteria of [
+ { maxForceResidualN: 0 },
+ { maxTorqueResidualNmm: -1 },
+ { maxForceResidualN: Number.NaN },
+ ]) {
+ expect(() => makePhysicalUseCaseRecord('bad-tolerance', { criteria })).toThrow(
+ /positive finite/,
+ );
+ }
+
+ expect(() => makePhysicalUseCaseRecord('loose-force-tolerance', {
+ criteria: { maxForceResidualN: 0.02 },
+ })).toThrow(/cannot exceed/);
+ expect(() => makePhysicalUseCaseRecord('loose-torque-tolerance', {
+ criteria: { maxTorqueResidualNmm: 0.2 },
+ })).toThrow(/cannot exceed/);
+
+ for (const minJointSafetyFactor of [0, 1.99, Number.NaN, Number.POSITIVE_INFINITY]) {
+ expect(() => makePhysicalUseCaseRecord('bad-safety-factor', {
+ criteria: { minJointSafetyFactor },
+ })).toThrow(/safety factor|at least 2/i);
+ }
+ });
+
+ it('reports a blocking diagnostic when contacts require different actuator poses', async () => {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ const arm = kcad.assembly('split-pose-review');
+ arm
+ .part('base', kcad.box(10, 10, 10))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('target-a', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } })
+ .connector('target-b', { type: 'frame', origin: { kind: 'vec3', value: [0, 10, 0] } });
+ arm
+ .part('finger', kcad.box(10, 2, 2))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('a', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } })
+ .connector('b', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } });
+ arm.mate('yaw', 'base.axis', 'finger.axis', 'revolute', { limitsDeg: [0, 90] });
+ arm.mechanicalJoint('yaw-drive', {
+ mate: 'yaw',
+ actuator: 'base',
+ shaft: 'base',
+ supports: ['base'],
+ output: 'finger',
+ });
+ arm.physicalUseCase('split-pose-grasp', {
+ stableParts: ['base'],
+ loads: [{ part: 'finger', force: [0, 0, -1] }],
+ contacts: [
+ { a: 'finger.a', b: 'base.target-a', normal: [1, 0, 0], friction: 0.5 },
+ { a: 'finger.b', b: 'base.target-b', normal: [0, 1, 0], friction: 0.5 },
+ ],
+ actuatorLimits: [{ mate: 'yaw', maxTorqueNmm: 10 }],
+ criteria: { maxSlipMm: 0.1 },
+ });
+
+ const result = await reviewPhysicalUseCasesWithReachability(arm, {
+ includeReachability: true,
+ includeStatics: true,
+ reachabilitySamplesPerMate: 2,
+ });
+ const diagnostic = result.diagnostics.find((candidate) =>
+ String(candidate.code) === 'assembly.physical-use-case.simultaneous-contacts-unreachable');
+
+ expect(diagnostic).toMatchObject({
+ code: 'assembly.physical-use-case.simultaneous-contacts-unreachable',
+ severity: 'error',
+ useCaseName: 'split-pose-grasp',
+ toleranceMm: 0.1,
+ bestMaxDistanceMm: expect.any(Number),
+ contactDistances: expect.arrayContaining([
+ expect.objectContaining({ contactA: 'finger.a', contactB: 'base.target-a' }),
+ expect.objectContaining({ contactA: 'finger.b', contactB: 'base.target-b' }),
+ ]),
+ });
+ expect(result.diagnostics.some((candidate) =>
+ candidate.code.startsWith('assembly.physical-use-case.static-'))).toBe(false);
+ expect(result.staticCertificates).toEqual([]);
+ });
+
+ it('maps insufficient pose-bound actuator torque into a blocking review diagnostic', async () => {
+ const result = await reviewPhysicalUseCasesWithReachability(makeStaticReviewRig(25), {
+ includeReachability: true,
+ includeStatics: true,
+ reachabilitySamplesPerMate: 1,
+ });
+
+ expect(result.staticCertificates).toEqual([]);
+ expect(result.diagnostics).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ code: 'assembly.physical-use-case.static-actuator-torque-insufficient',
+ useCaseName: 'hold-object',
+ actuatorTorques: expect.arrayContaining([
+ expect.objectContaining({ mateName: 'left-curl', maxTorqueNmm: 25 }),
+ expect.objectContaining({ mateName: 'right-curl', maxTorqueNmm: 25 }),
+ ]),
+ }),
+ ]));
+ });
+
+ it('returns a pose-bound static certificate when wrench and actuator limits pass', async () => {
+ const result = await reviewPhysicalUseCasesWithReachability(makeStaticReviewRig(35), {
+ includeReachability: true,
+ includeStatics: true,
+ reachabilitySamplesPerMate: 1,
+ });
+
+ expect(result.diagnostics.some((diagnostic) =>
+ diagnostic.code.startsWith('assembly.physical-use-case.static-'))).toBe(false);
+ expect(result.staticCertificates).toEqual([
+ expect.objectContaining({
+ useCaseName: 'hold-object',
+ heldPart: 'held',
+ actuatorTorques: expect.arrayContaining([
+ expect.objectContaining({ mateName: 'left-curl', requiredTorqueNmm: expect.any(Number) }),
+ expect.objectContaining({ mateName: 'right-curl', requiredTorqueNmm: expect.any(Number) }),
+ ]),
+ }),
+ ]);
+ });
+
+ it('returns reaction and structural certificates for a rated joint.clevis load path', async () => {
+ const result = await reviewPhysicalUseCasesWithReachability(
+ makeStructurallyRatedClevisRig(),
+ { includeJointStructure: true, reachabilitySamplesPerMate: 3 },
+ );
+
+ expect(result.staticCertificates).toHaveLength(1);
+ expect(result.jointReactionCertificates).toEqual([
+ expect.objectContaining({
+ useCaseName: 'hold-load',
+ reactions: [expect.objectContaining({
+ mateName: 'hinge',
+ resultantForceN: expect.closeTo(10, 6),
+ resultantMomentNmm: expect.closeTo(500, 4),
+ })],
+ }),
+ ]);
+ expect(result.jointStructuralCertificates).toEqual([
+ expect.objectContaining({
+ useCaseName: 'hold-load',
+ joints: [expect.objectContaining({
+ mateName: 'hinge',
+ envelope: expect.objectContaining({ status: 'pass' }),
+ structure: expect.objectContaining({ status: 'pass', minSafetyFactor: 2 }),
+ })],
+ }),
+ ]);
+ expect(result.diagnostics.filter((diagnostic) =>
+ diagnostic.code.includes('joint-reaction') ||
+ diagnostic.code.includes('joint-capacity') ||
+ diagnostic.code.includes('joint-structure'))).toEqual([]);
+ });
+
+ it('blocks undeclared/exceeded envelopes and missing structural evidence', async () => {
+ const undeclared = await reviewPhysicalUseCasesWithReachability(
+ makeStructurallyRatedClevisRig({ includeEnvelope: false }),
+ { includeJointStructure: true, reachabilitySamplesPerMate: 3 },
+ );
+ const exceeded = await reviewPhysicalUseCasesWithReachability(
+ makeStructurallyRatedClevisRig({ envelopeForceN: 5 }),
+ { includeJointStructure: true, reachabilitySamplesPerMate: 3 },
+ );
+ const missingStructure = await reviewPhysicalUseCasesWithReachability(
+ makeStructurallyRatedClevisRig({ includeStructure: false }),
+ { includeJointStructure: true, reachabilitySamplesPerMate: 3 },
+ );
+
+ expect(undeclared.diagnostics).toEqual(expect.arrayContaining([
+ expect.objectContaining({ code: 'assembly.physical-use-case.joint-capacity-undeclared', mateName: 'hinge' }),
+ ]));
+ expect(exceeded.diagnostics).toEqual(expect.arrayContaining([
+ expect.objectContaining({ code: 'assembly.physical-use-case.joint-capacity-exceeded', mateName: 'hinge' }),
+ ]));
+ expect(missingStructure.diagnostics).toEqual(expect.arrayContaining([
+ expect.objectContaining({ code: 'assembly.physical-use-case.joint-structure-input-incomplete', mateName: 'hinge' }),
+ ]));
+ });
+
+ it('maps a geometry-derived safety-factor failure into a blocker with evidence', async () => {
+ const result = await reviewPhysicalUseCasesWithReachability(
+ makeStructurallyRatedClevisRig({ minJointSafetyFactor: 1000 }),
+ { includeJointStructure: true, reachabilitySamplesPerMate: 3 },
+ );
+
+ expect(result.diagnostics).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ code: 'assembly.physical-use-case.joint-structure-insufficient',
+ mateName: 'hinge',
+ }),
+ ]));
+ expect(result.jointStructuralCertificates[0].joints[0].structure).toMatchObject({
+ status: 'failed',
+ minSafetyFactor: 1000,
+ });
+ });
+});
diff --git a/src/modeling/mates/physicalUseCase.ts b/src/modeling/mates/physicalUseCase.ts
new file mode 100644
index 000000000..ff8b39d10
--- /dev/null
+++ b/src/modeling/mates/physicalUseCase.ts
@@ -0,0 +1,1073 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+import type { Assembly } from '../capture/assembly';
+import type { Vec3 } from '../../shared/intent/types';
+import type { PoseEnvelopeReviewResult, TrackedConnectorPose } from './poseEnvelope';
+import { parseConnectorRef } from './mate';
+import { assessPhysicalUseCaseReachability } from './physicalUseCaseReachability';
+import {
+ DEFAULT_FORCE_RESIDUAL_N,
+ DEFAULT_TORQUE_RESIDUAL_NMM,
+ reviewPhysicalUseCaseStatics,
+ type PhysicalUseCaseStaticActuatorTorqueEvidence,
+ type PhysicalUseCaseStaticCertificate,
+} from './physicalUseCaseStatics';
+import {
+ reviewPhysicalUseCaseJointReactions,
+ type PhysicalUseCaseJointReactionCertificate,
+} from './physicalUseCaseJointReactions';
+import {
+ reviewJointReactionCapacity,
+ type JointReactionCapacityEvidence,
+} from './physicalUseCaseJointCapacity';
+import {
+ DEFAULT_MIN_JOINT_SAFETY_FACTOR,
+ reviewClevisJointStructure,
+ type ClevisJointStructureReview,
+} from './clevisJointStructure';
+
+export interface PhysicalUseCaseLoad {
+ readonly part: string;
+ /** Connector on part where force is applied. Required for non-zero force. */
+ readonly at?: string;
+ /** World-frame force in Newtons. */
+ readonly force?: readonly [number, number, number];
+ /** World-frame pure couple in Newton-millimetres. */
+ readonly torque?: readonly [number, number, number];
+}
+
+export type PhysicalUseCaseContactNormalFrame = 'world' | 'a' | 'b';
+
+export interface PhysicalUseCaseContact {
+ readonly a: string;
+ readonly b: string;
+ readonly normal: readonly [number, number, number];
+ readonly normalFrame?: PhysicalUseCaseContactNormalFrame;
+ readonly friction: number;
+ readonly normalForceN?: number;
+}
+
+export interface PhysicalUseCaseActuatorLimit {
+ readonly mate: string;
+ readonly maxTorqueNmm: number;
+}
+
+export interface PhysicalUseCaseCriteria {
+ readonly maxSlipMm?: number;
+ readonly settleTimeMs?: number;
+ readonly maxForceResidualN?: number;
+ readonly maxTorqueResidualNmm?: number;
+ readonly minJointSafetyFactor?: number;
+}
+
+export interface PhysicalUseCaseOptions {
+ readonly stableParts?: readonly string[];
+ readonly loads?: readonly PhysicalUseCaseLoad[];
+ readonly contacts?: readonly PhysicalUseCaseContact[];
+ readonly actuatorLimits?: readonly PhysicalUseCaseActuatorLimit[];
+ readonly criteria?: PhysicalUseCaseCriteria;
+}
+
+export interface PhysicalUseCaseRecord {
+ readonly name: string;
+ readonly stableParts: readonly string[];
+ readonly loads: readonly PhysicalUseCaseLoad[];
+ readonly contacts: readonly PhysicalUseCaseContact[];
+ readonly actuatorLimits: readonly PhysicalUseCaseActuatorLimit[];
+ readonly criteria?: PhysicalUseCaseCriteria;
+}
+
+export type PhysicalUseCaseDiagnostic =
+ | PhysicalUseCaseMissingDiagnostic
+ | PhysicalUseCasePartMissingDiagnostic
+ | PhysicalUseCaseZeroLoadDiagnostic
+ | PhysicalUseCaseLoadPathMissingDiagnostic
+ | PhysicalUseCaseContactForceInsufficientDiagnostic
+ | PhysicalUseCaseTorqueInsufficientDiagnostic
+ | PhysicalUseCaseContactInvalidDiagnostic
+ | PhysicalUseCaseContactUnreachableDiagnostic
+ | PhysicalUseCaseSimultaneousContactsUnreachableDiagnostic
+ | PhysicalUseCaseStaticInputIncompleteDiagnostic
+ | PhysicalUseCaseStaticEquilibriumUnmetDiagnostic
+ | PhysicalUseCaseStaticActuatorTorqueInsufficientDiagnostic
+ | PhysicalUseCaseJointReactionInputIncompleteDiagnostic
+ | PhysicalUseCaseJointReactionIndeterminateDiagnostic
+ | PhysicalUseCaseJointCapacityUndeclaredDiagnostic
+ | PhysicalUseCaseJointCapacityExceededDiagnostic
+ | PhysicalUseCaseJointStructureInputIncompleteDiagnostic
+ | PhysicalUseCaseJointStructureUnsupportedLoadCaseDiagnostic
+ | PhysicalUseCaseJointStructureInsufficientDiagnostic
+ | PhysicalUseCaseActuatorSupportMissingDiagnostic
+ | PhysicalUseCaseActuatorLimitInvalidDiagnostic;
+
+interface PhysicalUseCaseDiagnosticBase {
+ readonly severity: 'error';
+ readonly useCaseName?: string;
+ readonly message: string;
+ readonly hint: string;
+}
+
+export interface PhysicalUseCaseMissingDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.missing';
+}
+
+export interface PhysicalUseCasePartMissingDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.part-missing';
+ readonly partName: string;
+ readonly role: 'stablePart' | 'load';
+}
+
+export interface PhysicalUseCaseZeroLoadDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.zero-load';
+ readonly partName: string;
+}
+
+export interface PhysicalUseCaseLoadPathMissingDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.load-path-missing';
+ readonly loadPart: string;
+ readonly stableParts: readonly string[];
+}
+
+export interface PhysicalUseCaseContactForceInsufficientDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.contact-force-insufficient';
+ readonly loadPart: string;
+ readonly requiredForceN: number;
+ readonly availableForceN: number;
+}
+
+export interface PhysicalUseCaseTorqueInsufficientDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.torque-insufficient';
+ readonly mateName: string;
+ readonly loadPart: string;
+ readonly requiredTorqueNmm: number;
+ readonly maxTorqueNmm: number;
+}
+
+export interface PhysicalUseCaseContactInvalidDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.contact-invalid';
+ readonly contactRef?: string;
+}
+
+export interface PhysicalUseCaseContactUnreachableDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.contact-unreachable';
+ readonly contactA: string;
+ readonly contactB: string;
+ readonly minDistanceMm?: number;
+ readonly toleranceMm: number;
+}
+
+export interface PhysicalUseCaseSimultaneousContactsUnreachableDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.simultaneous-contacts-unreachable';
+ readonly toleranceMm: number;
+ readonly bestMaxDistanceMm?: number;
+ readonly contactDistances: readonly {
+ readonly contactA: string;
+ readonly contactB: string;
+ readonly distanceMm?: number;
+ }[];
+}
+
+export interface PhysicalUseCaseActuatorLimitInvalidDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.actuator-limit-invalid';
+ readonly mateName: string;
+}
+
+export interface PhysicalUseCaseActuatorSupportMissingDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.actuator-support-missing';
+ readonly mateName: string;
+}
+
+export interface PhysicalUseCaseStaticInputIncompleteDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.static-input-incomplete';
+}
+
+export interface PhysicalUseCaseStaticEquilibriumUnmetDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.static-equilibrium-unmet';
+ readonly bestPoses?: import('../capture/forwardKinematics').NumericPoses;
+ readonly bestForceResidualN?: number;
+ readonly bestTorqueResidualNmm?: number;
+}
+
+export interface PhysicalUseCaseStaticActuatorTorqueInsufficientDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.static-actuator-torque-insufficient';
+ readonly bestPoses?: import('../capture/forwardKinematics').NumericPoses;
+ readonly actuatorTorques: readonly PhysicalUseCaseStaticActuatorTorqueEvidence[];
+}
+
+export interface PhysicalUseCaseJointReactionInputIncompleteDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.joint-reaction-input-incomplete';
+}
+
+export interface PhysicalUseCaseJointReactionIndeterminateDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.joint-reaction-indeterminate';
+}
+
+export interface PhysicalUseCaseJointCapacityUndeclaredDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.joint-capacity-undeclared';
+ readonly mateName: string;
+ readonly evidence: JointReactionCapacityEvidence;
+}
+
+export interface PhysicalUseCaseJointCapacityExceededDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.joint-capacity-exceeded';
+ readonly mateName: string;
+ readonly evidence: JointReactionCapacityEvidence;
+}
+
+export interface PhysicalUseCaseJointStructureInputIncompleteDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.joint-structure-input-incomplete';
+ readonly mateName: string;
+ readonly review?: ClevisJointStructureReview;
+}
+
+export interface PhysicalUseCaseJointStructureUnsupportedLoadCaseDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.joint-structure-unsupported-load-case';
+ readonly mateName: string;
+ readonly review: ClevisJointStructureReview;
+}
+
+export interface PhysicalUseCaseJointStructureInsufficientDiagnostic extends PhysicalUseCaseDiagnosticBase {
+ readonly code: 'assembly.physical-use-case.joint-structure-insufficient';
+ readonly mateName: string;
+ readonly review: ClevisJointStructureReview;
+}
+
+export interface PhysicalUseCaseJointStructuralCertificate {
+ readonly useCaseName: string;
+ readonly poses: import('../capture/forwardKinematics').NumericPoses;
+ readonly joints: readonly {
+ readonly mateName: string;
+ readonly envelope: JointReactionCapacityEvidence;
+ readonly structure?: ClevisJointStructureReview;
+ }[];
+}
+
+export interface PhysicalUseCaseReviewResult {
+ readonly diagnostics: readonly PhysicalUseCaseDiagnostic[];
+ readonly checkedUseCaseCount: number;
+ readonly staticCertificates: readonly PhysicalUseCaseStaticCertificate[];
+ readonly jointReactionCertificates: readonly PhysicalUseCaseJointReactionCertificate[];
+ readonly jointStructuralCertificates: readonly PhysicalUseCaseJointStructuralCertificate[];
+}
+
+export interface PhysicalUseCaseReviewOptions {
+ readonly requirePhysicalUseCase?: boolean;
+ readonly poseEnvelope?: PoseEnvelopeReviewResult;
+ readonly includeReachability?: boolean;
+ readonly includeStatics?: boolean;
+ readonly includeJointReactions?: boolean;
+ readonly includeJointStructure?: boolean;
+ readonly reachabilitySamplesPerMate?: number;
+}
+
+export function makePhysicalUseCaseRecord(
+ name: string,
+ opts: PhysicalUseCaseOptions,
+): PhysicalUseCaseRecord {
+ if (typeof name !== 'string' || name.trim() === '') {
+ throw new Error('assembly.physicalUseCase: name must be a non-empty string.');
+ }
+ for (const contact of opts.contacts ?? []) {
+ if (
+ contact.normalFrame !== undefined &&
+ contact.normalFrame !== 'world' &&
+ contact.normalFrame !== 'a' &&
+ contact.normalFrame !== 'b'
+ ) {
+ throw new Error("assembly.physicalUseCase: contact.normalFrame must be 'world', 'a', or 'b'.");
+ }
+ }
+ for (const [field, value, maximum] of [
+ ['maxForceResidualN', opts.criteria?.maxForceResidualN, DEFAULT_FORCE_RESIDUAL_N],
+ ['maxTorqueResidualNmm', opts.criteria?.maxTorqueResidualNmm, DEFAULT_TORQUE_RESIDUAL_NMM],
+ ] as const) {
+ if (value !== undefined && (!Number.isFinite(value) || value <= 0)) {
+ throw new Error(`assembly.physicalUseCase: criteria.${field} must be a positive finite number.`);
+ }
+ if (value !== undefined && value > maximum) {
+ throw new Error(`assembly.physicalUseCase: criteria.${field} cannot exceed ${maximum}.`);
+ }
+ }
+ const minJointSafetyFactor = opts.criteria?.minJointSafetyFactor;
+ if (
+ minJointSafetyFactor !== undefined &&
+ (!Number.isFinite(minJointSafetyFactor) || minJointSafetyFactor < DEFAULT_MIN_JOINT_SAFETY_FACTOR)
+ ) {
+ throw new Error(
+ `assembly.physicalUseCase: criteria.minJointSafetyFactor must be finite and at least ${DEFAULT_MIN_JOINT_SAFETY_FACTOR}.`,
+ );
+ }
+ return {
+ name,
+ stableParts: [...(opts.stableParts ?? [])],
+ loads: (opts.loads ?? []).map((load) => ({
+ part: load.part,
+ ...(load.at === undefined ? {} : { at: load.at }),
+ ...(load.force === undefined ? {} : { force: copyVec3(load.force) }),
+ ...(load.torque === undefined ? {} : { torque: copyVec3(load.torque) }),
+ })),
+ contacts: (opts.contacts ?? []).map((contact) => ({
+ a: contact.a,
+ b: contact.b,
+ normal: copyVec3(contact.normal),
+ ...(contact.normalFrame === undefined ? {} : { normalFrame: contact.normalFrame }),
+ friction: contact.friction,
+ ...(contact.normalForceN === undefined ? {} : { normalForceN: contact.normalForceN }),
+ })),
+ actuatorLimits: (opts.actuatorLimits ?? []).map((limit) => ({
+ mate: limit.mate,
+ maxTorqueNmm: limit.maxTorqueNmm,
+ })),
+ ...(opts.criteria === undefined ? {} : { criteria: { ...opts.criteria } }),
+ };
+}
+
+export function reviewPhysicalUseCases(
+ arm: Assembly,
+ opts: { requirePhysicalUseCase?: boolean; poseEnvelope?: PoseEnvelopeReviewResult } = {},
+): PhysicalUseCaseReviewResult {
+ const useCases = arm.__physicalUseCases();
+ const diagnostics: PhysicalUseCaseDiagnostic[] = [];
+ const partsByName = new Map(arm.__parts().map((part) => [part.name, part]));
+ const matesByName = new Map(arm.__mates().map((mate) => [mate.name, mate]));
+ const mechanicallySupportedMates = new Set(arm.__mechanicalJointIntents().map((intent) => intent.mate));
+ const hasArticulatedMate = arm.__mates().some((mate) => mate.type !== 'fastened');
+
+ if (opts.requirePhysicalUseCase === true && useCases.length === 0 && hasArticulatedMate) {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.missing',
+ severity: 'error',
+ message: 'Assembly has articulated mates but no declared physical use case.',
+ hint: 'physical-use-case.missing — add arm.physicalUseCase(name, { loads, contacts, actuatorLimits, stableParts }) so review can check physical task evidence, not just geometry.',
+ });
+ }
+
+ for (const useCase of useCases) {
+ for (const partName of useCase.stableParts) {
+ if (!partsByName.has(partName)) {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.part-missing',
+ severity: 'error',
+ useCaseName: useCase.name,
+ role: 'stablePart',
+ partName,
+ message: `Physical use case '${useCase.name}' references missing stable part '${partName}'.`,
+ hint: `physical-use-case.part-missing — declare arm.part('${partName}', ...) or remove it from stableParts.`,
+ });
+ }
+ }
+
+ if (useCase.loads.length === 0) {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.zero-load',
+ severity: 'error',
+ useCaseName: useCase.name,
+ partName: '',
+ message: `Physical use case '${useCase.name}' declares no load.`,
+ hint: 'physical-use-case.zero-load — add at least one load with a non-zero force or torque vector.',
+ });
+ }
+
+ for (const load of useCase.loads) {
+ if (!partsByName.has(load.part)) {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.part-missing',
+ severity: 'error',
+ useCaseName: useCase.name,
+ role: 'load',
+ partName: load.part,
+ message: `Physical use case '${useCase.name}' load references missing part '${load.part}'.`,
+ hint: `physical-use-case.part-missing — declare arm.part('${load.part}', ...) or move the load to a real part.`,
+ });
+ }
+ if (!hasNonZeroVec(load.force) && !hasNonZeroVec(load.torque)) {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.zero-load',
+ severity: 'error',
+ useCaseName: useCase.name,
+ partName: load.part,
+ message: `Physical use case '${useCase.name}' load on '${load.part}' has zero force and zero torque.`,
+ hint: 'physical-use-case.zero-load — specify force or torque as a finite non-zero Vec3.',
+ });
+ }
+ }
+
+ if (useCase.contacts.length === 0) {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.contact-invalid',
+ severity: 'error',
+ useCaseName: useCase.name,
+ message: `Physical use case '${useCase.name}' declares no contacts.`,
+ hint: 'physical-use-case.contact-invalid — add at least one contact with two connector refs, a normal, and positive friction.',
+ });
+ }
+ for (const contact of useCase.contacts) {
+ const badRef = !connectorExists(contact.a, partsByName) ? contact.a : !connectorExists(contact.b, partsByName) ? contact.b : undefined;
+ if (
+ badRef !== undefined ||
+ !hasNonZeroVec(contact.normal) ||
+ !Number.isFinite(contact.friction) ||
+ contact.friction <= 0 ||
+ (contact.normalForceN !== undefined && (!Number.isFinite(contact.normalForceN) || contact.normalForceN <= 0))
+ ) {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.contact-invalid',
+ severity: 'error',
+ useCaseName: useCase.name,
+ contactRef: badRef,
+ message: `Physical use case '${useCase.name}' has an invalid contact declaration.`,
+ hint: 'physical-use-case.contact-invalid — contact refs must name existing connectors, normal must be a finite non-zero Vec3, friction must be > 0, and normalForceN must be > 0 when declared.',
+ });
+ continue;
+ }
+
+ if (opts.poseEnvelope !== undefined) {
+ const toleranceMm = useCase.criteria?.maxSlipMm ?? 0;
+ const minDistanceMm = minContactDistanceMm(opts.poseEnvelope.connectorPoses, contact.a, contact.b);
+ if (minDistanceMm === undefined || minDistanceMm > toleranceMm) {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.contact-unreachable',
+ severity: 'error',
+ useCaseName: useCase.name,
+ contactA: contact.a,
+ contactB: contact.b,
+ ...(minDistanceMm === undefined ? {} : { minDistanceMm }),
+ toleranceMm,
+ message: minDistanceMm === undefined
+ ? `Physical use case '${useCase.name}' contact '${contact.a}' to '${contact.b}' could not be checked in the sampled pose envelope.`
+ : `Physical use case '${useCase.name}' contact '${contact.a}' to '${contact.b}' never gets within ${toleranceMm.toFixed(2)} mm; closest sampled distance is ${minDistanceMm.toFixed(2)} mm.`,
+ hint: minDistanceMm === undefined
+ ? `physical-use-case.contact-unreachable — ensure '${contact.a}' and '${contact.b}' use numeric vec3 connector origins and are included in pose-envelope tracking.`
+ : `physical-use-case.contact-unreachable — move the contact connectors, widen mate travel, or revise the use case so '${contact.a}' can reach '${contact.b}' within maxSlipMm ${toleranceMm.toFixed(2)}.`,
+ });
+ }
+ }
+ }
+
+ if (hasArticulatedMate && useCase.actuatorLimits.length === 0) {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.actuator-limit-invalid',
+ severity: 'error',
+ useCaseName: useCase.name,
+ mateName: '',
+ message: `Physical use case '${useCase.name}' has no actuator torque limits for an articulated assembly.`,
+ hint: 'physical-use-case.actuator-limit-invalid — add actuatorLimits naming driven mates and positive maxTorqueNmm values.',
+ });
+ }
+ for (const limit of useCase.actuatorLimits) {
+ const mate = matesByName.get(limit.mate);
+ if (mate === undefined || !Number.isFinite(limit.maxTorqueNmm) || limit.maxTorqueNmm <= 0) {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.actuator-limit-invalid',
+ severity: 'error',
+ useCaseName: useCase.name,
+ mateName: limit.mate,
+ message: `Physical use case '${useCase.name}' has an invalid actuator limit for mate '${limit.mate}'.`,
+ hint: 'physical-use-case.actuator-limit-invalid — actuatorLimits must reference an existing mate and maxTorqueNmm must be > 0.',
+ });
+ continue;
+ }
+ if (mate.type !== 'fastened' && !mechanicallySupportedMates.has(limit.mate)) {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.actuator-support-missing',
+ severity: 'error',
+ useCaseName: useCase.name,
+ mateName: limit.mate,
+ message: `Physical use case '${useCase.name}' declares actuator torque for mate '${limit.mate}' but no mechanicalJoint(...) support contract backs that driven joint.`,
+ hint: `physical-use-case.actuator-support-missing — add arm.mechanicalJoint(name, { mate: '${limit.mate}', actuator, shaft, supports, output }) with real support geometry, or remove '${limit.mate}' from actuatorLimits until the joint is physically grounded.`,
+ });
+ }
+ }
+
+ diagnostics.push(...reviewLoadPaths(arm, useCase, partsByName));
+ diagnostics.push(...reviewContactForceCapacity(useCase, partsByName));
+ diagnostics.push(...reviewTorqueLimits(useCase, partsByName, matesByName));
+ }
+
+ return {
+ diagnostics,
+ checkedUseCaseCount: useCases.length,
+ staticCertificates: [],
+ jointReactionCertificates: [],
+ jointStructuralCertificates: [],
+ };
+}
+
+export async function reviewPhysicalUseCasesWithReachability(
+ arm: Assembly,
+ opts: PhysicalUseCaseReviewOptions = {},
+): Promise {
+ const base = reviewPhysicalUseCases(arm, opts);
+ const includeJointReactions = opts.includeJointReactions === true || opts.includeJointStructure === true;
+ const includeStatics = opts.includeStatics === true || includeJointReactions;
+ const includeReachability = opts.includeReachability === true || includeStatics;
+ if (!includeReachability) return base;
+
+ const diagnostics: PhysicalUseCaseDiagnostic[] = [...base.diagnostics];
+ const staticCertificates: PhysicalUseCaseStaticCertificate[] = [];
+ const jointReactionCertificates: PhysicalUseCaseJointReactionCertificate[] = [];
+ const jointStructuralCertificates: PhysicalUseCaseJointStructuralCertificate[] = [];
+ const existingUnreachableContacts = new Set(
+ base.diagnostics
+ .filter((diagnostic): diagnostic is PhysicalUseCaseContactUnreachableDiagnostic =>
+ diagnostic.code === 'assembly.physical-use-case.contact-unreachable')
+ .map((diagnostic) => unreachableContactKey(diagnostic.useCaseName, diagnostic.contactA, diagnostic.contactB)),
+ );
+ for (const useCase of arm.__physicalUseCases()) {
+ const assessment = await assessPhysicalUseCaseReachability(arm, useCase, {
+ samplesPerMate: opts.reachabilitySamplesPerMate,
+ });
+ for (const issue of assessment.findings) {
+ if (!('contactA' in issue)) {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.simultaneous-contacts-unreachable',
+ severity: 'error',
+ useCaseName: issue.useCaseName,
+ toleranceMm: issue.toleranceMm,
+ ...(issue.bestMaxDistanceMm === undefined ? {} : { bestMaxDistanceMm: issue.bestMaxDistanceMm }),
+ contactDistances: issue.contactDistances,
+ message: issue.bestMaxDistanceMm === undefined
+ ? `Physical use case '${issue.useCaseName}' has no solved targeted actuator sample where all ${issue.contactDistances.length} contacts can be checked together.`
+ : `Physical use case '${issue.useCaseName}' has no single targeted actuator sample that satisfies all ${issue.contactDistances.length} contacts within ${issue.toleranceMm.toFixed(2)} mm; the best sample's worst contact is ${issue.bestMaxDistanceMm.toFixed(2)} mm away.`,
+ hint: 'physical-use-case.simultaneous-contacts-unreachable — revise mate couplings, contact geometry, or actuator ranges until one sampled mechanism state satisfies every declared contact; independent per-contact poses do not form a grasp.',
+ });
+ continue;
+ }
+ if (existingUnreachableContacts.has(unreachableContactKey(issue.useCaseName, issue.contactA, issue.contactB))) continue;
+ diagnostics.push({
+ code: 'assembly.physical-use-case.contact-unreachable',
+ severity: 'error',
+ useCaseName: issue.useCaseName,
+ contactA: issue.contactA,
+ contactB: issue.contactB,
+ ...(issue.minDistanceMm === undefined ? {} : { minDistanceMm: issue.minDistanceMm }),
+ toleranceMm: issue.toleranceMm,
+ message: issue.minDistanceMm === undefined
+ ? `Physical use case '${issue.useCaseName}' contact '${issue.contactA}' to '${issue.contactB}' could not be checked by targeted actuator sampling.`
+ : `Physical use case '${issue.useCaseName}' contact '${issue.contactA}' to '${issue.contactB}' cannot be reached by the declared actuator limits; closest targeted sample is ${issue.minDistanceMm.toFixed(2)} mm away with tolerance ${issue.toleranceMm.toFixed(2)} mm.`,
+ hint: `physical-use-case.contact-unreachable — repair the target connector, move '${issue.contactA}' or '${issue.contactB}', or widen the declared actuatorLimits so the contact can get within maxSlipMm ${issue.toleranceMm.toFixed(2)}.`,
+ });
+ }
+
+ if (!includeStatics || assessment.findings.length > 0) continue;
+ const statics = await reviewPhysicalUseCaseStatics(arm, useCase, assessment.commonPoseSamples);
+ staticCertificates.push(...statics.certificates);
+ for (const issue of statics.issues) {
+ if (issue.kind === 'static-input-incomplete') {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.static-input-incomplete',
+ severity: 'error',
+ useCaseName: issue.useCaseName,
+ message: `Physical use case '${issue.useCaseName}' cannot run pose-bound static review: ${issue.message}`,
+ hint: 'physical-use-case.static-input-incomplete - add explicit load application connectors, contact capacities and frames, finite revolute limits, and transmission evidence for every coupled joint.',
+ });
+ continue;
+ }
+ if (issue.kind === 'static-equilibrium-unmet') {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.static-equilibrium-unmet',
+ severity: 'error',
+ useCaseName: issue.useCaseName,
+ ...(issue.bestPoses === undefined ? {} : { bestPoses: issue.bestPoses }),
+ ...(issue.bestForceResidualN === undefined ? {} : { bestForceResidualN: issue.bestForceResidualN }),
+ ...(issue.bestTorqueResidualNmm === undefined ? {} : { bestTorqueResidualNmm: issue.bestTorqueResidualNmm }),
+ message: `Physical use case '${issue.useCaseName}' has no verified contact-force allocation that balances force and moment at a sampled common-contact pose.`,
+ hint: 'physical-use-case.static-equilibrium-unmet - revise contact locations/normals, friction, force capacity, or the applied load. This sampled linearized failure is not a proof of analytical impossibility.',
+ });
+ continue;
+ }
+ diagnostics.push({
+ code: 'assembly.physical-use-case.static-actuator-torque-insufficient',
+ severity: 'error',
+ useCaseName: issue.useCaseName,
+ ...(issue.bestPoses === undefined ? {} : { bestPoses: issue.bestPoses }),
+ actuatorTorques: issue.actuatorTorques,
+ message: `Physical use case '${issue.useCaseName}' can balance its held-object wrench, but no verified sampled allocation stays within every actuator torque limit.`,
+ hint: 'physical-use-case.static-actuator-torque-insufficient - increase real actuator/transmission capacity, shorten moment arms, reduce the load, or redesign contact placement without weakening the gate.',
+ });
+ }
+
+ if (!includeJointReactions) continue;
+ for (const certificate of statics.certificates) {
+ const jointReview = await reviewCertifiedJointLoads(
+ arm,
+ useCase,
+ certificate,
+ opts.includeJointStructure === true,
+ );
+ diagnostics.push(...jointReview.diagnostics);
+ jointReactionCertificates.push(...jointReview.reactionCertificates);
+ jointStructuralCertificates.push(...jointReview.structuralCertificates);
+ }
+ }
+
+ return {
+ diagnostics,
+ checkedUseCaseCount: base.checkedUseCaseCount,
+ staticCertificates,
+ jointReactionCertificates,
+ jointStructuralCertificates,
+ };
+}
+
+async function reviewCertifiedJointLoads(
+ arm: Assembly,
+ useCase: PhysicalUseCaseRecord,
+ staticCertificate: PhysicalUseCaseStaticCertificate,
+ includeStructure: boolean,
+): Promise<{
+ diagnostics: PhysicalUseCaseDiagnostic[];
+ reactionCertificates: PhysicalUseCaseJointReactionCertificate[];
+ structuralCertificates: PhysicalUseCaseJointStructuralCertificate[];
+}> {
+ const diagnostics: PhysicalUseCaseDiagnostic[] = [];
+ const reactions = await reviewPhysicalUseCaseJointReactions(arm, useCase, staticCertificate);
+ for (const issue of reactions.issues) {
+ diagnostics.push({
+ code: issue.kind === 'joint-reaction-input-incomplete'
+ ? 'assembly.physical-use-case.joint-reaction-input-incomplete'
+ : 'assembly.physical-use-case.joint-reaction-indeterminate',
+ severity: 'error',
+ useCaseName: issue.useCaseName,
+ message: `Physical use case '${issue.useCaseName}' cannot derive determinate pose-bound joint reactions: ${issue.message}`,
+ hint: issue.kind === 'joint-reaction-input-incomplete'
+ ? 'physical-use-case.joint-reaction-input-incomplete - preserve the exact passing contact certificate, solved pose, contact points, loads, and connector frames.'
+ : 'physical-use-case.joint-reaction-indeterminate - use one stable root and a tree load path, or provide a future stiffness model for loops and multiple supports.',
+ });
+ }
+
+ const matesByName = new Map(arm.__mates().map((mate) => [mate.name, mate]));
+ const structuralCertificates: PhysicalUseCaseJointStructuralCertificate[] = [];
+ for (const certificate of reactions.certificates) {
+ const joints: PhysicalUseCaseJointStructuralCertificate['joints'][number][] = [];
+ for (const reaction of certificate.reactions) {
+ const mate = matesByName.get(reaction.mateName);
+ if (mate === undefined) {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.joint-reaction-input-incomplete',
+ severity: 'error',
+ useCaseName: useCase.name,
+ message: `Physical use case '${useCase.name}' reaction references missing mate '${reaction.mateName}'.`,
+ hint: 'physical-use-case.joint-reaction-input-incomplete - regenerate reaction evidence from the current assembly mate graph.',
+ });
+ continue;
+ }
+
+ const envelope = reviewJointReactionCapacity(mate, reaction);
+ if (envelope.status === 'undeclared') {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.joint-capacity-undeclared',
+ severity: 'error',
+ useCaseName: useCase.name,
+ mateName: mate.name,
+ evidence: envelope,
+ message: `Physical use case '${useCase.name}' derives a reaction at mate '${mate.name}', but the mate has no complete resultant force and moment envelope.`,
+ hint: `physical-use-case.joint-capacity-undeclared - add capacity.envelope with positive maxResultantForceN and maxResultantMomentNmm to mate '${mate.name}'. A declaration is a rating check, not structural proof.`,
+ });
+ } else if (envelope.status === 'exceeded') {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.joint-capacity-exceeded',
+ severity: 'error',
+ useCaseName: useCase.name,
+ mateName: mate.name,
+ evidence: envelope,
+ message: `Physical use case '${useCase.name}' reaction at mate '${mate.name}' exceeds its declared resultant capacity envelope.`,
+ hint: `physical-use-case.joint-capacity-exceeded - increase real rated joint capacity or redesign the load path; do not raise the declaration without physical evidence.`,
+ });
+ }
+
+ let structure: ClevisJointStructureReview | undefined;
+ if (includeStructure) {
+ if (mate.capacity?.structure === undefined) {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.joint-structure-input-incomplete',
+ severity: 'error',
+ useCaseName: useCase.name,
+ mateName: mate.name,
+ message: `Physical use case '${useCase.name}' has no geometry/material structural descriptor for mate '${mate.name}'.`,
+ hint: `physical-use-case.joint-structure-input-incomplete - build '${mate.name}' with joint.clevis(...), declare pin/fork/tongue engineering materials, and attach clevis.structural as capacity.structure.`,
+ });
+ } else {
+ structure = reviewClevisJointStructure({
+ reaction,
+ model: mate.capacity.structure,
+ minSafetyFactor: useCase.criteria?.minJointSafetyFactor,
+ });
+ if (structure.status === 'input-incomplete') {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.joint-structure-input-incomplete',
+ severity: 'error',
+ useCaseName: useCase.name,
+ mateName: mate.name,
+ review: structure,
+ message: `Physical use case '${useCase.name}' cannot derive clevis strength for mate '${mate.name}': ${structure.message ?? 'structural input is incomplete'}`,
+ hint: `physical-use-case.joint-structure-input-incomplete - use the unmodified joint.clevis structural descriptor with explicit valid materials and geometry.`,
+ });
+ } else if (structure.status === 'unsupported-load-case') {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.joint-structure-unsupported-load-case',
+ severity: 'error',
+ useCaseName: useCase.name,
+ mateName: mate.name,
+ review: structure,
+ message: `Physical use case '${useCase.name}' reaction at mate '${mate.name}' is outside the clevis v1 load model: ${structure.message ?? 'unsupported load component'}`,
+ hint: `physical-use-case.joint-structure-unsupported-load-case - add explicit thrust/moment load-path geometry or use a later structural model; the current gate will not silently omit this component.`,
+ });
+ } else if (structure.status === 'failed') {
+ diagnostics.push({
+ code: 'assembly.physical-use-case.joint-structure-insufficient',
+ severity: 'error',
+ useCaseName: useCase.name,
+ mateName: mate.name,
+ review: structure,
+ message: `Physical use case '${useCase.name}' clevis at mate '${mate.name}' is below minimum factor of safety ${structure.minSafetyFactor}.`,
+ hint: `physical-use-case.joint-structure-insufficient - increase real pin/ligament/bearing dimensions, select stronger declared materials, reduce load, or redesign the load path.`,
+ });
+ }
+ }
+ }
+
+ joints.push({
+ mateName: mate.name,
+ envelope,
+ ...(structure === undefined ? {} : { structure }),
+ });
+ }
+ if (includeStructure) {
+ structuralCertificates.push({
+ useCaseName: useCase.name,
+ poses: certificate.poses,
+ joints,
+ });
+ }
+ }
+ return {
+ diagnostics,
+ reactionCertificates: [...reactions.certificates],
+ structuralCertificates,
+ };
+}
+
+function copyVec3(v: readonly [number, number, number]): [number, number, number] {
+ return [v[0], v[1], v[2]];
+}
+
+function unreachableContactKey(useCaseName: string | undefined, contactA: string, contactB: string): string {
+ return `${useCaseName ?? ''}\n${contactA}\n${contactB}`;
+}
+
+function hasNonZeroVec(v: readonly number[] | undefined): v is Vec3 {
+ return Array.isArray(v) && v.length === 3 && v.every((n) => Number.isFinite(n)) && Math.hypot(v[0], v[1], v[2]) > 0;
+}
+
+function connectorExists(ref: string, partsByName: ReadonlyMap): boolean {
+ try {
+ const parsed = parseConnectorRef(ref);
+ const part = partsByName.get(parsed.partName);
+ return part?.mateConnectors.some((connector) => connector.name === parsed.connectorName) ?? false;
+ } catch {
+ return false;
+ }
+}
+
+function reviewLoadPaths(
+ arm: Assembly,
+ useCase: PhysicalUseCaseRecord,
+ partsByName: ReadonlyMap,
+): PhysicalUseCaseLoadPathMissingDiagnostic[] {
+ const stableParts = useCase.stableParts.filter((partName) => partsByName.has(partName));
+ const graph = buildLoadPathGraph(arm, useCase, partsByName);
+ const diagnostics: PhysicalUseCaseLoadPathMissingDiagnostic[] = [];
+
+ for (const load of useCase.loads) {
+ if (!partsByName.has(load.part)) continue;
+ if (stableParts.length > 0 && reachesAnyStablePart(graph, load.part, stableParts)) continue;
+ diagnostics.push({
+ code: 'assembly.physical-use-case.load-path-missing',
+ severity: 'error',
+ useCaseName: useCase.name,
+ loadPart: load.part,
+ stableParts,
+ message: stableParts.length === 0
+ ? `Physical use case '${useCase.name}' load on '${load.part}' has no valid stable part to react it.`
+ : `Physical use case '${useCase.name}' load on '${load.part}' has no mate/contact path to stable part(s): ${stableParts.join(', ')}.`,
+ hint: stableParts.length === 0
+ ? 'physical-use-case.load-path-missing — add at least one existing stableParts entry, or move the load onto a part already grounded by the task.'
+ : `physical-use-case.load-path-missing — connect '${load.part}' to ${stableParts.join(', ')} through mates or declared physical contacts so the applied load has a structural reaction path.`,
+ });
+ }
+
+ return diagnostics;
+}
+
+function reviewTorqueLimits(
+ useCase: PhysicalUseCaseRecord,
+ partsByName: ReadonlyMap,
+ matesByName: ReadonlyMap,
+): PhysicalUseCaseTorqueInsufficientDiagnostic[] {
+ const diagnostics: PhysicalUseCaseTorqueInsufficientDiagnostic[] = [];
+
+ for (const limit of useCase.actuatorLimits) {
+ const mate = matesByName.get(limit.mate);
+ if (mate === undefined || (mate.type !== 'revolute' && mate.type !== 'cylindrical')) continue;
+ if (!Number.isFinite(limit.maxTorqueNmm) || limit.maxTorqueNmm <= 0) continue;
+
+ for (const load of useCase.loads) {
+ const requiredTorqueNmm = estimateDirectMateTorqueNmm(mate, load, useCase.contacts, partsByName);
+ if (requiredTorqueNmm === undefined || requiredTorqueNmm <= limit.maxTorqueNmm) continue;
+ diagnostics.push({
+ code: 'assembly.physical-use-case.torque-insufficient',
+ severity: 'error',
+ useCaseName: useCase.name,
+ mateName: limit.mate,
+ loadPart: load.part,
+ requiredTorqueNmm,
+ maxTorqueNmm: limit.maxTorqueNmm,
+ message: `Physical use case '${useCase.name}' needs at least ${requiredTorqueNmm.toFixed(1)} Nmm at mate '${limit.mate}' for load on '${load.part}', but actuator limit is ${limit.maxTorqueNmm.toFixed(1)} Nmm.`,
+ hint: `physical-use-case.torque-insufficient — increase actuatorLimits for '${limit.mate}', reduce the declared load, shorten the moment arm, or add a transmission/support path that reduces the torque demand below ${requiredTorqueNmm.toFixed(1)} Nmm.`,
+ });
+ }
+ }
+
+ return diagnostics;
+}
+
+function reviewContactForceCapacity(
+ useCase: PhysicalUseCaseRecord,
+ partsByName: ReadonlyMap,
+): PhysicalUseCaseContactForceInsufficientDiagnostic[] {
+ const diagnostics: PhysicalUseCaseContactForceInsufficientDiagnostic[] = [];
+
+ for (const load of useCase.loads) {
+ if (!partsByName.has(load.part) || !hasNonZeroVec(load.force)) continue;
+ const relevantContacts = useCase.contacts.filter((contact) => {
+ if (contact.normalForceN === undefined || !isValidContactDeclaration(contact, partsByName)) return false;
+ return safePartNameFromConnectorRef(contact.a) === load.part || safePartNameFromConnectorRef(contact.b) === load.part;
+ });
+ if (relevantContacts.length === 0) continue;
+
+ const requiredForceN = Math.hypot(load.force[0], load.force[1], load.force[2]);
+ const loadDirection = unit([-load.force[0], -load.force[1], -load.force[2]]);
+ const availableForceN = relevantContacts.reduce(
+ (sum, contact) => sum + projectedContactCapacityN(contact, loadDirection),
+ 0,
+ );
+
+ if (availableForceN + 1e-6 >= requiredForceN) continue;
+ diagnostics.push({
+ code: 'assembly.physical-use-case.contact-force-insufficient',
+ severity: 'error',
+ useCaseName: useCase.name,
+ loadPart: load.part,
+ requiredForceN,
+ availableForceN,
+ message: `Physical use case '${useCase.name}' declares ${availableForceN.toFixed(1)} N contact capacity for load on '${load.part}', but the applied force is ${requiredForceN.toFixed(1)} N.`,
+ hint: `physical-use-case.contact-force-insufficient — increase declared normalForceN/friction, add more declared contacts on '${load.part}', or reduce the applied load below ${availableForceN.toFixed(1)} N.`,
+ });
+ }
+
+ return diagnostics;
+}
+
+function projectedContactCapacityN(contact: PhysicalUseCaseContact, loadDirection: Vec3): number {
+ const normalForceN = contact.normalForceN;
+ if (normalForceN === undefined || !Number.isFinite(normalForceN) || normalForceN <= 0) return 0;
+ const normal = unit(contact.normal);
+ const normalAlignment = Math.max(0, dot(normal, loadDirection));
+ const tangentialAlignment = Math.sqrt(Math.max(0, 1 - normalAlignment * normalAlignment));
+ return normalForceN * normalAlignment + contact.friction * normalForceN * tangentialAlignment;
+}
+
+interface ConnectorLike {
+ readonly name: string;
+ readonly origin: { readonly kind: string; readonly value?: readonly number[] };
+ readonly axis?: readonly [number, number, number];
+}
+
+function estimateDirectMateTorqueNmm(
+ mate: { a: string; b: string },
+ load: PhysicalUseCaseLoad,
+ contacts: readonly PhysicalUseCaseContact[],
+ partsByName: ReadonlyMap,
+): number | undefined {
+ const axisRef = directMateConnectorForPart(mate, load.part);
+ if (axisRef === undefined) return undefined;
+ const axisConnector = connectorForRef(axisRef, partsByName);
+ if (axisConnector?.origin.kind !== 'vec3' || axisConnector.axis === undefined) return undefined;
+
+ const forceMoment = hasNonZeroVec(load.force)
+ ? maxForceMomentFromContacts(load.part, load.force, contacts, axisConnector.origin.value as Vec3, axisConnector.axis, partsByName)
+ : undefined;
+ const directTorque = hasNonZeroVec(load.torque)
+ ? Math.abs(dot(load.torque, unit(axisConnector.axis)))
+ : undefined;
+
+ const candidates = [forceMoment, directTorque].filter((value): value is number => value !== undefined);
+ return candidates.length === 0 ? undefined : Math.max(...candidates);
+}
+
+function directMateConnectorForPart(
+ mate: { a: string; b: string },
+ partName: string,
+): string | undefined {
+ const a = safePartNameFromConnectorRef(mate.a);
+ if (a === partName) return mate.a;
+ const b = safePartNameFromConnectorRef(mate.b);
+ if (b === partName) return mate.b;
+ return undefined;
+}
+
+function maxForceMomentFromContacts(
+ loadPart: string,
+ force: readonly [number, number, number],
+ contacts: readonly PhysicalUseCaseContact[],
+ axisOrigin: Vec3,
+ axis: readonly [number, number, number],
+ partsByName: ReadonlyMap,
+): number | undefined {
+ const axisUnit = unit(axis);
+ let maxMoment: number | undefined;
+ for (const contact of contacts) {
+ const loadRef = safePartNameFromConnectorRef(contact.a) === loadPart
+ ? contact.a
+ : safePartNameFromConnectorRef(contact.b) === loadPart
+ ? contact.b
+ : undefined;
+ if (loadRef === undefined) continue;
+ const connector = connectorForRef(loadRef, partsByName);
+ if (connector?.origin.kind !== 'vec3') continue;
+ const point = connector.origin.value as Vec3;
+ const r: Vec3 = [point[0] - axisOrigin[0], point[1] - axisOrigin[1], point[2] - axisOrigin[2]];
+ const moment = Math.abs(dot(cross(r, force), axisUnit));
+ maxMoment = maxMoment === undefined ? moment : Math.max(maxMoment, moment);
+ }
+ return maxMoment;
+}
+
+function connectorForRef(
+ ref: string,
+ partsByName: ReadonlyMap,
+): ConnectorLike | undefined {
+ try {
+ const parsed = parseConnectorRef(ref);
+ return partsByName.get(parsed.partName)?.mateConnectors.find((connector) => connector.name === parsed.connectorName);
+ } catch {
+ return undefined;
+ }
+}
+
+function buildLoadPathGraph(
+ arm: Assembly,
+ useCase: PhysicalUseCaseRecord,
+ partsByName: ReadonlyMap,
+): Map> {
+ const graph = new Map>();
+ for (const partName of partsByName.keys()) graph.set(partName, new Set());
+
+ const addEdge = (a: string, b: string): void => {
+ graph.get(a)?.add(b);
+ graph.get(b)?.add(a);
+ };
+
+ for (const mate of arm.__mates()) {
+ const a = safePartNameFromConnectorRef(mate.a);
+ const b = safePartNameFromConnectorRef(mate.b);
+ if (a !== undefined && b !== undefined && partsByName.has(a) && partsByName.has(b)) addEdge(a, b);
+ }
+
+ for (const contact of useCase.contacts) {
+ if (!isValidContactDeclaration(contact, partsByName)) {
+ continue;
+ }
+ const a = safePartNameFromConnectorRef(contact.a);
+ const b = safePartNameFromConnectorRef(contact.b);
+ if (a !== undefined && b !== undefined && partsByName.has(a) && partsByName.has(b)) addEdge(a, b);
+ }
+
+ return graph;
+}
+
+function isValidContactDeclaration(
+ contact: PhysicalUseCaseContact,
+ partsByName: ReadonlyMap,
+): boolean {
+ return (
+ connectorExists(contact.a, partsByName) &&
+ connectorExists(contact.b, partsByName) &&
+ hasNonZeroVec(contact.normal) &&
+ Number.isFinite(contact.friction) &&
+ contact.friction > 0 &&
+ (contact.normalForceN === undefined || (Number.isFinite(contact.normalForceN) && contact.normalForceN > 0))
+ );
+}
+
+function reachesAnyStablePart(
+ graph: ReadonlyMap>,
+ start: string,
+ stableParts: readonly string[],
+): boolean {
+ const stable = new Set(stableParts);
+ const seen = new Set();
+ const queue = [start];
+ while (queue.length > 0) {
+ const part = queue.shift() as string;
+ if (stable.has(part)) return true;
+ if (seen.has(part)) continue;
+ seen.add(part);
+ for (const next of graph.get(part) ?? []) {
+ if (!seen.has(next)) queue.push(next);
+ }
+ }
+ return false;
+}
+
+function safePartNameFromConnectorRef(ref: string): string | undefined {
+ try {
+ return parseConnectorRef(ref).partName;
+ } catch {
+ return undefined;
+ }
+}
+
+function cross(a: readonly [number, number, number], b: readonly [number, number, number]): Vec3 {
+ return [
+ a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0],
+ ];
+}
+
+function dot(a: readonly [number, number, number], b: readonly [number, number, number]): number {
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
+}
+
+function unit(v: readonly [number, number, number]): Vec3 {
+ const length = Math.hypot(v[0], v[1], v[2]);
+ return length === 0 ? [0, 0, 0] : [v[0] / length, v[1] / length, v[2] / length];
+}
+
+function minContactDistanceMm(
+ poses: readonly TrackedConnectorPose[],
+ aRef: string,
+ bRef: string,
+): number | undefined {
+ const bySample = new Map>();
+ for (const pose of poses) {
+ let sample = bySample.get(pose.sampleName);
+ if (!sample) {
+ sample = new Map();
+ bySample.set(pose.sampleName, sample);
+ }
+ sample.set(pose.ref, pose.world);
+ }
+
+ let min: number | undefined;
+ for (const sample of bySample.values()) {
+ const a = sample.get(aRef);
+ const b = sample.get(bRef);
+ if (!a || !b) continue;
+ const distance = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
+ min = min === undefined ? distance : Math.min(min, distance);
+ }
+ return min;
+}
diff --git a/src/modeling/mates/physicalUseCaseJointCapacity.test.ts b/src/modeling/mates/physicalUseCaseJointCapacity.test.ts
new file mode 100644
index 000000000..5c4b3700e
--- /dev/null
+++ b/src/modeling/mates/physicalUseCaseJointCapacity.test.ts
@@ -0,0 +1,608 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+import { describe, expect, it } from 'vitest';
+import { KernelError } from '../../shared/intent/kernelError';
+import { createApi } from '../api';
+import { CaptureSession } from '../capture/captureSession';
+import type { ClevisStructuralModel, StructuralMaterial } from '../joints/types';
+import type {
+ MateCapacity,
+ MateCapacityEnvelope,
+ MateLoadLimit,
+ MateRecord,
+} from './mate';
+import {
+ reviewJointReactionCapacity,
+ type JointReactionCapacityEvidence,
+} from './physicalUseCaseJointCapacity';
+import type { PhysicalUseCaseJointReactionEvidence } from './physicalUseCaseJointReactions';
+
+function makeArm() {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ const arm = kcad.assembly('capacity-rig');
+ arm
+ .part('base', kcad.box(10, 10, 10))
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+ });
+ arm
+ .part('link', kcad.box(10, 10, 10))
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+ });
+ return arm;
+}
+
+function expectInvalidArgs(action: () => void, messagePattern: RegExp): void {
+ let caught: unknown;
+ try {
+ action();
+ } catch (error) {
+ caught = error;
+ }
+ expect(caught).toBeInstanceOf(KernelError);
+ expect((caught as KernelError).code).toBe('feature.invalid-args');
+ expect((caught as Error).message).toMatch(messagePattern);
+}
+
+function makeMate(
+ opts: {
+ name?: string;
+ capacity?: MateCapacity;
+ maxLoad?: MateLoadLimit;
+ } = {},
+): MateRecord {
+ return {
+ name: opts.name ?? 'hinge',
+ a: 'base.axis',
+ b: 'link.axis',
+ type: 'revolute',
+ ...(opts.capacity !== undefined ? { capacity: opts.capacity } : {}),
+ ...(opts.maxLoad !== undefined ? { maxLoad: opts.maxLoad } : {}),
+ };
+}
+
+function makeReaction(
+ overrides: Partial = {},
+): PhysicalUseCaseJointReactionEvidence {
+ return {
+ mateName: 'hinge',
+ parentPart: 'base',
+ childPart: 'link',
+ pointWorldMm: [0, 0, 0],
+ axisWorld: [0, 0, 1],
+ forceWorldN: [120, 0, 0],
+ momentWorldNmm: [0, 800, 0],
+ resultantForceN: 120,
+ resultantMomentNmm: 800,
+ axialForceN: 0,
+ radialForceN: 120,
+ axisMomentNmm: 0,
+ bendingMomentNmm: 800,
+ ...overrides,
+ };
+}
+
+function capacityEnvelope(
+ maxResultantForceN = 120,
+ maxResultantMomentNmm = 800,
+): MateCapacity {
+ return {
+ envelope: { maxResultantForceN, maxResultantMomentNmm },
+ };
+}
+
+function structuralModel(): ClevisStructuralModel {
+ const steel: StructuralMaterial = {
+ name: 'test steel',
+ model: 'isotropic-ductile',
+ yieldStrengthMPa: 250,
+ bearingStrengthMPa: 400,
+ };
+ return {
+ kind: 'clevis-double-shear-v1',
+ source: 'joint.clevis',
+ pinDiameterMm: 6,
+ boreDiameterMm: 6.4,
+ forkPlateThicknessMm: 4,
+ forkPlateCount: 2,
+ tongueThicknessMm: 5,
+ forkGapMm: 6,
+ supportSpanMm: 10,
+ edgeDistanceMm: 10,
+ materials: { pin: steel, fork: steel, tongue: steel },
+ };
+}
+
+describe('mate capacity capture', () => {
+ it('preserves capacity and maxLoad as nested copies through subAssembly import', () => {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ const ratedSource = kcad.assembly('rated-source');
+ ratedSource
+ .part('base', kcad.box(10, 10, 10))
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+ });
+ ratedSource
+ .part('link', kcad.box(10, 10, 10))
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+ });
+ ratedSource.mate('hinge', 'base.axis', 'link.axis', 'revolute', {
+ capacity: {
+ envelope: {
+ maxResultantForceN: 120,
+ maxResultantMomentNmm: 800,
+ },
+ structure: structuralModel(),
+ },
+ });
+
+ const legacySource = kcad.assembly('legacy-source');
+ legacySource
+ .part('base', kcad.box(10, 10, 10))
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+ });
+ legacySource
+ .part('link', kcad.box(10, 10, 10))
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+ });
+ legacySource.mate('hinge', 'base.axis', 'link.axis', 'revolute', {
+ maxLoad: { force: 120, torque: 0.8 },
+ });
+
+ const parent = kcad.assembly('parent');
+ parent.subAssembly('rated', ratedSource);
+ parent.subAssembly('legacy', legacySource);
+
+ const importedRated = parent.__mates().find((mate) => mate.name === 'rated_hinge');
+ const importedLegacy = parent.__mates().find((mate) => mate.name === 'legacy_hinge');
+ expect(importedRated?.capacity).toEqual({
+ envelope: {
+ maxResultantForceN: 120,
+ maxResultantMomentNmm: 800,
+ },
+ structure: structuralModel(),
+ });
+ expect(importedRated?.capacity).not.toBe(ratedSource.__mates()[0].capacity);
+ expect(importedRated?.capacity?.envelope).not.toBe(
+ ratedSource.__mates()[0].capacity?.envelope,
+ );
+ expect(importedRated?.capacity?.structure).not.toBe(
+ ratedSource.__mates()[0].capacity?.structure,
+ );
+ expect(importedRated?.capacity?.structure?.materials?.pin).not.toBe(
+ ratedSource.__mates()[0].capacity?.structure?.materials?.pin,
+ );
+ expect(importedLegacy?.maxLoad).toEqual({ force: 120, torque: 0.8 });
+ expect(importedLegacy?.maxLoad).not.toBe(legacySource.__mates()[0].maxLoad);
+ });
+
+ it('captures a unit-bearing capacity envelope on the public mate record', () => {
+ const arm = makeArm();
+
+ arm.mate('hinge', 'base.axis', 'link.axis', 'revolute', {
+ capacity: {
+ envelope: {
+ maxResultantForceN: 120,
+ maxResultantMomentNmm: 800,
+ },
+ },
+ });
+
+ expect(arm.__mates()[0]).toEqual({
+ name: 'hinge',
+ a: 'base.axis',
+ b: 'link.axis',
+ type: 'revolute',
+ capacity: {
+ envelope: {
+ maxResultantForceN: 120,
+ maxResultantMomentNmm: 800,
+ },
+ },
+ });
+ });
+
+ it('defensively copies the capacity and nested envelope', () => {
+ const arm = makeArm();
+ const capacity = {
+ envelope: {
+ maxResultantForceN: 120,
+ maxResultantMomentNmm: 800,
+ },
+ };
+
+ arm.mate('hinge', 'base.axis', 'link.axis', 'revolute', { capacity });
+ const captured = arm.__mates()[0].capacity;
+
+ expect(captured).not.toBe(capacity);
+ expect(captured?.envelope).not.toBe(capacity.envelope);
+ capacity.envelope.maxResultantForceN = 999;
+ capacity.envelope.maxResultantMomentNmm = 999;
+ expect(captured?.envelope).toEqual({
+ maxResultantForceN: 120,
+ maxResultantMomentNmm: 800,
+ });
+ });
+
+ it('captures a clevis structural model only on revolute mates and copies it deeply', () => {
+ const arm = makeArm();
+ const structure = structuralModel();
+ const capacity: MateCapacity = {
+ envelope: { maxResultantForceN: 120, maxResultantMomentNmm: 800 },
+ structure,
+ };
+
+ arm.mate('hinge', 'base.axis', 'link.axis', 'revolute', { capacity });
+ const captured = arm.__mates()[0].capacity?.structure;
+
+ expect(captured).toEqual(structure);
+ expect(captured).not.toBe(structure);
+ expect(captured?.materials).not.toBe(structure.materials);
+ expect(captured?.materials?.pin).not.toBe(structure.materials?.pin);
+
+ const prismatic = makeArm();
+ expectInvalidArgs(
+ () => prismatic.mate('slide', 'base.axis', 'link.axis', 'prismatic', {
+ capacity: { structure },
+ }),
+ /structure.*revolute/i,
+ );
+ });
+
+ it.each([
+ ['null', null],
+ ['string', 'invalid'],
+ ['number', 42],
+ ['array', []],
+ ])('rejects malformed capacity shape: %s', (_label, capacity) => {
+ const arm = makeArm();
+
+ expectInvalidArgs(
+ () => arm.mate('hinge', 'base.axis', 'link.axis', 'revolute', {
+ capacity: capacity as unknown as MateCapacity,
+ }),
+ /capacity.*object/i,
+ );
+ });
+
+ it.each([
+ ['null', null],
+ ['string', 'invalid'],
+ ['array', []],
+ ])('rejects malformed capacity.envelope shape: %s', (_label, envelope) => {
+ const arm = makeArm();
+
+ expectInvalidArgs(
+ () => arm.mate('hinge', 'base.axis', 'link.axis', 'revolute', {
+ capacity: { envelope } as unknown as MateCapacity,
+ }),
+ /capacity\.envelope.*object/i,
+ );
+ });
+
+ it.each([
+ ['null', null],
+ ['string', 'invalid'],
+ ['number', 42],
+ ['array', []],
+ ])('rejects malformed maxLoad shape: %s', (_label, maxLoad) => {
+ const arm = makeArm();
+
+ expectInvalidArgs(
+ () => arm.mate('hinge', 'base.axis', 'link.axis', 'revolute', {
+ maxLoad: maxLoad as unknown as MateLoadLimit,
+ }),
+ /maxLoad.*object/i,
+ );
+ });
+
+ it('continues to capture an empty capacity object', () => {
+ const arm = makeArm();
+
+ arm.mate('hinge', 'base.axis', 'link.axis', 'revolute', { capacity: {} });
+
+ expect(arm.__mates()[0].capacity).toEqual({});
+ });
+
+ it('continues to capture an empty maxLoad object', () => {
+ const arm = makeArm();
+
+ arm.mate('hinge', 'base.axis', 'link.axis', 'revolute', { maxLoad: {} });
+
+ expect(arm.__mates()[0].maxLoad).toEqual({});
+ });
+
+ const invalidEnvelopeCases: readonly [
+ label: string,
+ field: 'maxResultantForceN' | 'maxResultantMomentNmm',
+ value: number | undefined,
+ unit: 'N' | 'Nmm',
+ ][] = [
+ ['missing force', 'maxResultantForceN', undefined, 'N'],
+ ['zero force', 'maxResultantForceN', 0, 'N'],
+ ['negative force', 'maxResultantForceN', -1, 'N'],
+ ['NaN force', 'maxResultantForceN', Number.NaN, 'N'],
+ ['infinite force', 'maxResultantForceN', Number.POSITIVE_INFINITY, 'N'],
+ ['missing moment', 'maxResultantMomentNmm', undefined, 'Nmm'],
+ ['zero moment', 'maxResultantMomentNmm', 0, 'Nmm'],
+ ['negative moment', 'maxResultantMomentNmm', -1, 'Nmm'],
+ ['NaN moment', 'maxResultantMomentNmm', Number.NaN, 'Nmm'],
+ ['infinite moment', 'maxResultantMomentNmm', Number.POSITIVE_INFINITY, 'Nmm'],
+ ];
+
+ it.each(invalidEnvelopeCases)(
+ 'rejects an envelope with %s',
+ (_label, field, value, unit) => {
+ const arm = makeArm();
+ const envelope: {
+ maxResultantForceN?: number;
+ maxResultantMomentNmm?: number;
+ } = {
+ maxResultantForceN: 120,
+ maxResultantMomentNmm: 800,
+ };
+ if (value === undefined) delete envelope[field];
+ else envelope[field] = value;
+
+ expectInvalidArgs(
+ () => arm.mate('hinge', 'base.axis', 'link.axis', 'revolute', {
+ capacity: { envelope: envelope as MateCapacityEnvelope },
+ }),
+ new RegExp(`${field}.*positive finite.*${unit}`),
+ );
+ },
+ );
+
+ it('rejects capacity and legacy maxLoad together with unit-bearing guidance', () => {
+ const arm = makeArm();
+
+ expectInvalidArgs(
+ () => arm.mate('hinge', 'base.axis', 'link.axis', 'revolute', {
+ capacity: {
+ envelope: {
+ maxResultantForceN: 120,
+ maxResultantMomentNmm: 800,
+ },
+ },
+ maxLoad: { force: 120, torque: 0.8 },
+ }),
+ /capacity\.envelope.*N.*Nmm.*maxLoad.*N.*Nm/i,
+ );
+ });
+
+ const invalidLegacyCases: readonly [
+ field: 'force' | 'torque',
+ value: number,
+ unit: 'N' | 'Nm',
+ ][] = [
+ ['force', 0, 'N'],
+ ['force', -1, 'N'],
+ ['force', Number.NaN, 'N'],
+ ['force', Number.POSITIVE_INFINITY, 'N'],
+ ['torque', 0, 'Nm'],
+ ['torque', -1, 'Nm'],
+ ['torque', Number.NaN, 'Nm'],
+ ['torque', Number.POSITIVE_INFINITY, 'Nm'],
+ ];
+
+ it.each(invalidLegacyCases)(
+ 'rejects invalid legacy maxLoad.%s=%s',
+ (field, value, unit) => {
+ const arm = makeArm();
+
+ expectInvalidArgs(
+ () => arm.mate('hinge', 'base.axis', 'link.axis', 'revolute', {
+ maxLoad: { [field]: value },
+ }),
+ new RegExp(`maxLoad\\.${field}.*positive finite.*${unit}`),
+ );
+ },
+ );
+
+ it('rejects legacy torque whose conversion from Nm to Nmm overflows', () => {
+ const arm = makeArm();
+
+ expectInvalidArgs(
+ () => arm.mate('hinge', 'base.axis', 'link.axis', 'revolute', {
+ maxLoad: { force: 120, torque: Number.MAX_VALUE },
+ }),
+ /maxLoad\.torque.*Nm.*Nmm.*finite/i,
+ );
+ });
+
+ it('captures and defensively copies the legacy manual-load declaration', () => {
+ const arm = makeArm();
+ const maxLoad = { force: 120, torque: 0.8 };
+
+ arm.mate('hinge', 'base.axis', 'link.axis', 'revolute', { maxLoad });
+ const captured = arm.__mates()[0];
+
+ expect(captured).toEqual({
+ name: 'hinge',
+ a: 'base.axis',
+ b: 'link.axis',
+ type: 'revolute',
+ maxLoad: { force: 120, torque: 0.8 },
+ });
+ expect(captured.maxLoad).not.toBe(maxLoad);
+ maxLoad.force = 999;
+ maxLoad.torque = 999;
+ expect(captured.maxLoad).toEqual({ force: 120, torque: 0.8 });
+ });
+});
+
+describe('reviewJointReactionCapacity', () => {
+ it('passes at exact capacity-envelope force and moment thresholds', () => {
+ const evidence = reviewJointReactionCapacity(
+ makeMate({ capacity: capacityEnvelope() }),
+ makeReaction(),
+ );
+
+ expect(evidence).toEqual({
+ mateName: 'hinge',
+ status: 'pass',
+ resultantForceN: 120,
+ resultantMomentNmm: 800,
+ maxResultantForceN: 120,
+ maxResultantMomentNmm: 800,
+ forceExceeded: false,
+ momentExceeded: false,
+ source: 'capacity',
+ });
+ });
+
+ it('marks only force exceeded when moment remains at its threshold', () => {
+ const evidence = reviewJointReactionCapacity(
+ makeMate({ capacity: capacityEnvelope() }),
+ makeReaction({ resultantForceN: 121 }),
+ );
+
+ expect(evidence).toMatchObject({
+ status: 'exceeded',
+ forceExceeded: true,
+ momentExceeded: false,
+ });
+ });
+
+ it('marks only moment exceeded when force remains at its threshold', () => {
+ const evidence = reviewJointReactionCapacity(
+ makeMate({ capacity: capacityEnvelope() }),
+ makeReaction({ resultantMomentNmm: 801 }),
+ );
+
+ expect(evidence).toMatchObject({
+ status: 'exceeded',
+ forceExceeded: false,
+ momentExceeded: true,
+ });
+ });
+
+ it('marks both force and moment exceeded', () => {
+ const evidence = reviewJointReactionCapacity(
+ makeMate({ capacity: capacityEnvelope() }),
+ makeReaction({ resultantForceN: 121, resultantMomentNmm: 801 }),
+ );
+
+ expect(evidence).toMatchObject({
+ status: 'exceeded',
+ forceExceeded: true,
+ momentExceeded: true,
+ });
+ });
+
+ it.each([
+ ['no capacity declaration', makeMate()],
+ ['capacity without an envelope', makeMate({ capacity: {} })],
+ ])('reports undeclared for %s', (_label, mate) => {
+ expect(reviewJointReactionCapacity(mate, makeReaction())).toEqual({
+ mateName: 'hinge',
+ status: 'undeclared',
+ resultantForceN: 120,
+ resultantMomentNmm: 800,
+ forceExceeded: false,
+ momentExceeded: false,
+ });
+ });
+
+ it('captures legacy maxLoad and converts 0.8 Nm to 800 Nmm exactly once', () => {
+ const arm = makeArm();
+ arm.mate('hinge', 'base.axis', 'link.axis', 'revolute', {
+ maxLoad: { force: 120, torque: 0.8 },
+ });
+
+ expect(reviewJointReactionCapacity(arm.__mates()[0], makeReaction())).toEqual({
+ mateName: 'hinge',
+ status: 'pass',
+ resultantForceN: 120,
+ resultantMomentNmm: 800,
+ maxResultantForceN: 120,
+ maxResultantMomentNmm: 800,
+ forceExceeded: false,
+ momentExceeded: false,
+ source: 'legacy-max-load',
+ });
+ });
+
+ it('keeps an overflowing pre-existing legacy torque undeclared without Infinity', () => {
+ const evidence = reviewJointReactionCapacity(
+ makeMate({ maxLoad: { force: 120, torque: Number.MAX_VALUE } }),
+ makeReaction(),
+ );
+
+ expect(evidence).toEqual({
+ mateName: 'hinge',
+ status: 'undeclared',
+ resultantForceN: 120,
+ resultantMomentNmm: 800,
+ maxResultantForceN: 120,
+ forceExceeded: false,
+ momentExceeded: false,
+ source: 'legacy-max-load',
+ });
+ expect(evidence).not.toHaveProperty('maxResultantMomentNmm');
+ });
+
+ it.each([
+ [
+ 'force only',
+ { force: 120 },
+ makeReaction({ resultantForceN: 121 }),
+ {
+ maxResultantForceN: 120,
+ forceExceeded: true,
+ momentExceeded: false,
+ },
+ ],
+ [
+ 'torque only',
+ { torque: 0.8 },
+ makeReaction({ resultantMomentNmm: 801 }),
+ {
+ maxResultantMomentNmm: 800,
+ forceExceeded: false,
+ momentExceeded: true,
+ },
+ ],
+ ] as const)(
+ 'keeps partial legacy maxLoad (%s) undeclared without a synthetic counterpart',
+ (_label, maxLoad, reaction, expected) => {
+ const evidence = reviewJointReactionCapacity(makeMate({ maxLoad }), reaction);
+
+ expect(evidence).toMatchObject({
+ status: 'undeclared',
+ source: 'legacy-max-load',
+ ...expected,
+ });
+ if ('maxResultantForceN' in expected) {
+ expect(evidence).not.toHaveProperty('maxResultantMomentNmm');
+ } else {
+ expect(evidence).not.toHaveProperty('maxResultantForceN');
+ }
+ },
+ );
+
+ it('rejects a reaction captured for a different mate', () => {
+ expect(() => reviewJointReactionCapacity(
+ makeMate({ capacity: capacityEnvelope() }),
+ makeReaction({ mateName: 'other-hinge' }),
+ )).toThrow(/reaction mate 'other-hinge'.*mate 'hinge'/i);
+ });
+});
diff --git a/src/modeling/mates/physicalUseCaseJointCapacity.ts b/src/modeling/mates/physicalUseCaseJointCapacity.ts
new file mode 100644
index 000000000..175ec90b4
--- /dev/null
+++ b/src/modeling/mates/physicalUseCaseJointCapacity.ts
@@ -0,0 +1,93 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+import type { MateRecord } from './mate';
+import type { PhysicalUseCaseJointReactionEvidence } from './physicalUseCaseJointReactions';
+
+const NMM_PER_NM = 1000;
+
+export interface JointReactionCapacityEvidence {
+ readonly mateName: string;
+ readonly status: 'pass' | 'exceeded' | 'undeclared';
+ readonly resultantForceN: number;
+ readonly resultantMomentNmm: number;
+ readonly maxResultantForceN?: number;
+ readonly maxResultantMomentNmm?: number;
+ readonly forceExceeded: boolean;
+ readonly momentExceeded: boolean;
+ readonly source?: 'capacity' | 'legacy-max-load';
+}
+
+interface NormalizedCapacityEnvelope {
+ readonly maxResultantForceN?: number;
+ readonly maxResultantMomentNmm?: number;
+ readonly source?: JointReactionCapacityEvidence['source'];
+}
+
+/**
+ * Compare a pose-bound joint reaction with a declared resultant rating.
+ * A passing rating comparison is not structural proof.
+ */
+export function reviewJointReactionCapacity(
+ mate: MateRecord,
+ reaction: PhysicalUseCaseJointReactionEvidence,
+): JointReactionCapacityEvidence {
+ if (reaction.mateName !== mate.name) {
+ throw new Error(
+ `Joint reaction mate '${reaction.mateName}' does not match mate '${mate.name}'; compare evidence for the same joint.`,
+ );
+ }
+
+ const normalized = normalizeCapacityEnvelope(mate);
+ const forceExceeded = normalized.maxResultantForceN !== undefined
+ && reaction.resultantForceN > normalized.maxResultantForceN;
+ const momentExceeded = normalized.maxResultantMomentNmm !== undefined
+ && reaction.resultantMomentNmm > normalized.maxResultantMomentNmm;
+ const complete = normalized.maxResultantForceN !== undefined
+ && normalized.maxResultantMomentNmm !== undefined;
+
+ return {
+ mateName: mate.name,
+ status: !complete
+ ? 'undeclared'
+ : forceExceeded || momentExceeded
+ ? 'exceeded'
+ : 'pass',
+ resultantForceN: reaction.resultantForceN,
+ resultantMomentNmm: reaction.resultantMomentNmm,
+ ...(normalized.maxResultantForceN !== undefined
+ ? { maxResultantForceN: normalized.maxResultantForceN }
+ : {}),
+ ...(normalized.maxResultantMomentNmm !== undefined
+ ? { maxResultantMomentNmm: normalized.maxResultantMomentNmm }
+ : {}),
+ forceExceeded,
+ momentExceeded,
+ ...(normalized.source !== undefined ? { source: normalized.source } : {}),
+ };
+}
+
+function normalizeCapacityEnvelope(mate: MateRecord): NormalizedCapacityEnvelope {
+ const envelope = mate.capacity?.envelope;
+ if (envelope !== undefined) {
+ return {
+ maxResultantForceN: envelope.maxResultantForceN,
+ maxResultantMomentNmm: envelope.maxResultantMomentNmm,
+ source: 'capacity',
+ };
+ }
+
+ if (mate.maxLoad === undefined) return {};
+ const legacyForceN = mate.maxLoad.force;
+ const legacyMomentNmm = typeof mate.maxLoad.torque === 'number'
+ ? mate.maxLoad.torque * NMM_PER_NM
+ : undefined;
+ return {
+ ...(typeof legacyForceN === 'number' && Number.isFinite(legacyForceN) && legacyForceN > 0
+ ? { maxResultantForceN: legacyForceN }
+ : {}),
+ ...(legacyMomentNmm !== undefined && Number.isFinite(legacyMomentNmm) && legacyMomentNmm > 0
+ ? { maxResultantMomentNmm: legacyMomentNmm }
+ : {}),
+ source: 'legacy-max-load',
+ };
+}
diff --git a/src/modeling/mates/physicalUseCaseJointReactions.test.ts b/src/modeling/mates/physicalUseCaseJointReactions.test.ts
new file mode 100644
index 000000000..0269ec111
--- /dev/null
+++ b/src/modeling/mates/physicalUseCaseJointReactions.test.ts
@@ -0,0 +1,718 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+import { describe, expect, it } from 'vitest';
+import { createApi } from '../api';
+import type { Assembly } from '../capture/assembly';
+import { CaptureSession } from '../capture/captureSession';
+import type { NumericPoses } from '../capture/forwardKinematics';
+import type { Shape } from '../capture/proxy';
+import type { Vec3 } from '../../shared/intent/types';
+import {
+ makePhysicalUseCaseRecord,
+ type PhysicalUseCaseRecord,
+} from './physicalUseCase';
+import {
+ reviewPhysicalUseCaseJointReactions,
+ type PhysicalUseCaseJointReactionEvidence,
+} from './physicalUseCaseJointReactions';
+import type {
+ PhysicalUseCaseStaticCertificate,
+ PhysicalUseCaseStaticContactForce,
+} from './physicalUseCaseStatics';
+import { solveMates } from './solver';
+
+interface ContactSpec {
+ readonly mechanismRef: string;
+ readonly mechanismPart: string;
+ readonly pointWorldMm: Vec3;
+ readonly forceOnHeldWorldN: Vec3;
+}
+
+interface ReactionFixture {
+ readonly arm: Assembly;
+ readonly useCase: PhysicalUseCaseRecord;
+ readonly certificate: PhysicalUseCaseStaticCertificate;
+}
+
+function makeHarness(name: string) {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ return { arm: kcad.assembly(name), kcad };
+}
+
+function addHeldPart(arm: Assembly, shape: Shape, points: readonly Vec3[]): void {
+ const held = arm
+ .part('held', shape, { role: 'contact-target' })
+ .connector('load', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ });
+ points.forEach((point, index) => {
+ held.connector(`contact-${index}`, {
+ type: 'frame',
+ origin: { kind: 'vec3', value: copy(point) },
+ });
+ });
+}
+
+function certifyFixture(
+ arm: Assembly,
+ name: string,
+ stableParts: readonly string[],
+ poses: NumericPoses,
+ contacts: readonly ContactSpec[],
+): Omit {
+ const contactForce = contacts.reduce((sum, contact) => add(sum, contact.forceOnHeldWorldN), zero());
+ const contactMoment = contacts.reduce(
+ (sum, contact) => add(sum, cross(contact.pointWorldMm, contact.forceOnHeldWorldN)),
+ zero(),
+ );
+ const actuatorLimits = arm.__mates()
+ .filter((mate) => mate.type !== 'fastened')
+ .map((mate) => ({ mate: mate.name, maxTorqueNmm: 1_000_000 }));
+ const contactForces: PhysicalUseCaseStaticContactForce[] = contacts.map((contact, index) => {
+ const magnitude = norm(contact.forceOnHeldWorldN);
+ return {
+ contactA: contact.mechanismRef,
+ contactB: `held.contact-${index}`,
+ pointWorldMm: copy(contact.pointWorldMm),
+ mechanismPart: contact.mechanismPart,
+ forceOnHeldWorldN: copy(contact.forceOnHeldWorldN),
+ normalForceN: magnitude,
+ tangentialForceN: 0,
+ normalCapacityN: magnitude * 2,
+ friction: 0.5,
+ };
+ });
+ const useCase = makePhysicalUseCaseRecord(name, {
+ stableParts,
+ loads: [{
+ part: 'held',
+ at: 'held.load',
+ force: scale(contactForce, -1),
+ torque: scale(contactMoment, -1),
+ }],
+ contacts: contacts.map((contact, index) => ({
+ a: contact.mechanismRef,
+ b: `held.contact-${index}`,
+ normal: scale(unit(contact.forceOnHeldWorldN), -1),
+ friction: 0.5,
+ normalForceN: norm(contact.forceOnHeldWorldN) * 2,
+ })),
+ actuatorLimits,
+ criteria: {
+ maxForceResidualN: 0.01,
+ maxTorqueResidualNmm: 0.1,
+ },
+ });
+ return {
+ useCase,
+ certificate: {
+ useCaseName: name,
+ heldPart: 'held',
+ poses: { ...poses },
+ forceResidualN: 0,
+ torqueResidualNmm: 0,
+ contactForces,
+ actuatorTorques: actuatorLimits.map((limit) => ({
+ mateName: limit.mate,
+ requiredTorqueNmm: 0,
+ maxTorqueNmm: limit.maxTorqueNmm,
+ })),
+ },
+ };
+}
+
+async function makeSerialFixture(opts: {
+ readonly poses?: NumericPoses;
+ readonly coupled?: boolean;
+ readonly heldFirst?: boolean;
+ readonly contactSeparationMm?: number;
+} = {}): Promise {
+ const { arm, kcad } = makeHarness('serial reaction rig');
+ if (opts.heldFirst === true) {
+ addHeldPart(arm, kcad.box(5, 5, 5), [[150, 0, 0]]);
+ }
+ arm
+ .part('base', kcad.box(10, 10, 10))
+ .connector('proximal', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+ });
+ arm
+ .part('link-1', kcad.box(100, 5, 5))
+ .connector('in', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+ })
+ .connector('distal', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [100, 0, 0] },
+ axis: [0, 0, 1],
+ });
+ arm
+ .part('link-2', kcad.box(50, 5, 5))
+ .connector('in', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+ })
+ .connector('tip', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [50, 0, 0] },
+ });
+ arm.mate('proximal', 'base.proximal', 'link-1.in', 'revolute', { limitsDeg: [-180, 180] });
+ arm.mate('distal', 'link-1.distal', 'link-2.in', 'revolute', { limitsDeg: [-180, 180] });
+ if (opts.coupled === true) {
+ arm.coupleMates('distal', { source: 'proximal', ratio: -1 });
+ }
+
+ const poses = opts.poses ?? {};
+ let pointWorldMm: Vec3;
+ if (opts.heldFirst === true) {
+ pointWorldMm = [150, 0, 0];
+ } else {
+ const solved = await solveMates(arm, poses);
+ if (solved.status !== 'solved') throw new Error(`serial fixture solve returned ${solved.status}`);
+ const mechanismPoint = [...solved.poses.get('link-2')!.point([50, 0, 0])] as Vec3;
+ const separationMm = opts.contactSeparationMm ?? 0;
+ pointWorldMm = [
+ mechanismPoint[0] + separationMm / 2,
+ mechanismPoint[1],
+ mechanismPoint[2],
+ ];
+ addHeldPart(arm, kcad.box(5, 5, 5), [[
+ mechanismPoint[0] + separationMm,
+ mechanismPoint[1],
+ mechanismPoint[2],
+ ]]);
+ }
+ return {
+ arm,
+ ...certifyFixture(arm, 'serial-load', ['base'], poses, [{
+ mechanismRef: 'link-2.tip',
+ mechanismPart: 'link-2',
+ pointWorldMm,
+ forceOnHeldWorldN: [0, 10, 0],
+ }]),
+ };
+}
+
+function makeBranchFixture(): ReactionFixture {
+ const { arm, kcad } = makeHarness('branch reaction rig');
+ arm
+ .part('base', kcad.box(10, 10, 10))
+ .connector('root', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+ })
+ .connector('direct-contact', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ });
+ arm
+ .part('hub', kcad.box(50, 5, 5))
+ .connector('in', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+ })
+ .connector('branch-a', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [50, 0, 0] },
+ axis: [0, 0, 1],
+ })
+ .connector('branch-b', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [50, 0, 0] },
+ axis: [0, 0, 1],
+ })
+ .connector('idle-branch', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 50, 0] },
+ axis: [0, 0, 1],
+ });
+ for (const name of ['branch-a', 'branch-b']) {
+ arm
+ .part(name, kcad.box(50, 5, 5))
+ .connector('in', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+ })
+ .connector('tip', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [50, 0, 0] },
+ });
+ }
+ arm
+ .part('idle-branch', kcad.box(20, 5, 5))
+ .connector('in', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+ });
+ arm.mate('proximal', 'base.root', 'hub.in', 'revolute');
+ arm.mate('branch-a-joint', 'hub.branch-a', 'branch-a.in', 'revolute');
+ arm.mate('branch-b-joint', 'hub.branch-b', 'branch-b.in', 'revolute');
+ arm.mate('idle-joint', 'hub.idle-branch', 'idle-branch.in', 'revolute');
+ const contacts: ContactSpec[] = [
+ {
+ mechanismRef: 'branch-a.tip',
+ mechanismPart: 'branch-a',
+ pointWorldMm: [100, 0, 0],
+ forceOnHeldWorldN: [0, 10, 0],
+ },
+ {
+ mechanismRef: 'branch-b.tip',
+ mechanismPart: 'branch-b',
+ pointWorldMm: [100, 0, 0],
+ forceOnHeldWorldN: [0, -10, 0],
+ },
+ {
+ mechanismRef: 'base.direct-contact',
+ mechanismPart: 'base',
+ pointWorldMm: [0, 0, 0],
+ forceOnHeldWorldN: [10, 0, 0],
+ },
+ ];
+ addHeldPart(arm, kcad.box(5, 5, 5), contacts.map((contact) => contact.pointWorldMm));
+ return { arm, ...certifyFixture(arm, 'branch-load', ['base'], {}, contacts) };
+}
+
+function makeFastenedFixture(): ReactionFixture {
+ const { arm, kcad } = makeHarness('fastened reaction rig');
+ arm
+ .part('base', kcad.box(10, 10, 10))
+ .connector('hinge', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+ });
+ arm
+ .part('carrier', kcad.box(50, 5, 5))
+ .connector('hinge', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+ })
+ .connector('mount', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [50, 0, 0] },
+ });
+ arm
+ .part('bracket', kcad.box(50, 5, 5))
+ .connector('mount', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ })
+ .connector('tip', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [50, 0, 0] },
+ });
+ arm.mate('hinge', 'base.hinge', 'carrier.hinge', 'revolute');
+ arm.mate('mount', 'carrier.mount', 'bracket.mount', 'fastened');
+ const contacts: ContactSpec[] = [{
+ mechanismRef: 'bracket.tip',
+ mechanismPart: 'bracket',
+ pointWorldMm: [100, 0, 0],
+ forceOnHeldWorldN: [0, 10, 0],
+ }];
+ addHeldPart(arm, kcad.box(5, 5, 5), [contacts[0].pointWorldMm]);
+ return { arm, ...certifyFixture(arm, 'fastened-load', ['base'], {}, contacts) };
+}
+
+function makeLoopFixture(): ReactionFixture {
+ const { arm, kcad } = makeHarness('loop reaction rig');
+ arm
+ .part('base', kcad.box(10, 10, 10))
+ .connector('to-a', axisAtOrigin())
+ .connector('to-b', axisAtOrigin());
+ arm
+ .part('link-a', kcad.box(10, 5, 5))
+ .connector('to-base', axisAtOrigin())
+ .connector('to-b', axisAtOrigin());
+ arm
+ .part('link-b', kcad.box(50, 5, 5))
+ .connector('to-a', axisAtOrigin())
+ .connector('to-base', axisAtOrigin())
+ .connector('tip', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [50, 0, 0] },
+ });
+ arm.mate('base-a', 'base.to-a', 'link-a.to-base', 'revolute');
+ arm.mate('a-b', 'link-a.to-b', 'link-b.to-a', 'revolute');
+ arm.mate('b-base', 'link-b.to-base', 'base.to-b', 'revolute');
+ const contacts: ContactSpec[] = [{
+ mechanismRef: 'link-b.tip',
+ mechanismPart: 'link-b',
+ pointWorldMm: [50, 0, 0],
+ forceOnHeldWorldN: [0, 10, 0],
+ }];
+ addHeldPart(arm, kcad.box(5, 5, 5), [contacts[0].pointWorldMm]);
+ return { arm, ...certifyFixture(arm, 'loop-load', ['base'], {}, contacts) };
+}
+
+function makeMultipleRootFixture(): ReactionFixture {
+ const { arm, kcad } = makeHarness('multiple root reaction rig');
+ arm.part('base-a', kcad.box(10, 10, 10)).connector('axis', axisAtOrigin());
+ arm.part('base-b', kcad.box(10, 10, 10)).connector('axis', axisAtOrigin());
+ arm
+ .part('link', kcad.box(50, 5, 5))
+ .connector('to-a', axisAtOrigin())
+ .connector('to-b', axisAtOrigin())
+ .connector('tip', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [50, 0, 0] },
+ });
+ arm.mate('from-a', 'base-a.axis', 'link.to-a', 'revolute');
+ arm.mate('from-b', 'base-b.axis', 'link.to-b', 'revolute');
+ const contacts: ContactSpec[] = [{
+ mechanismRef: 'link.tip',
+ mechanismPart: 'link',
+ pointWorldMm: [50, 0, 0],
+ forceOnHeldWorldN: [0, 10, 0],
+ }];
+ addHeldPart(arm, kcad.box(5, 5, 5), [contacts[0].pointWorldMm]);
+ return { arm, ...certifyFixture(arm, 'two-root-load', ['base-a', 'base-b'], {}, contacts) };
+}
+
+function makeMissingRootFixture(): ReactionFixture {
+ const { arm, kcad } = makeHarness('missing root reaction rig');
+ arm.part('support', kcad.box(10, 10, 10)).connector('axis', axisAtOrigin());
+ arm
+ .part('link', kcad.box(50, 5, 5))
+ .connector('axis', axisAtOrigin())
+ .connector('tip', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [50, 0, 0] },
+ });
+ arm.mate('hinge', 'support.axis', 'link.axis', 'revolute');
+ const contacts: ContactSpec[] = [{
+ mechanismRef: 'link.tip',
+ mechanismPart: 'link',
+ pointWorldMm: [50, 0, 0],
+ forceOnHeldWorldN: [0, 10, 0],
+ }];
+ addHeldPart(arm, kcad.box(5, 5, 5), [contacts[0].pointWorldMm]);
+ return { arm, ...certifyFixture(arm, 'missing-root-load', [], {}, contacts) };
+}
+
+function axisAtOrigin() {
+ return {
+ type: 'axis' as const,
+ origin: { kind: 'vec3' as const, value: [0, 0, 0] as Vec3 },
+ axis: [0, 0, 1] as Vec3,
+ };
+}
+
+function reactionByMate(
+ reactions: readonly PhysicalUseCaseJointReactionEvidence[],
+ mateName: string,
+): PhysicalUseCaseJointReactionEvidence {
+ const reaction = reactions.find((candidate) => candidate.mateName === mateName);
+ if (reaction === undefined) throw new Error(`missing reaction for ${mateName}`);
+ return reaction;
+}
+
+function expectVecClose(actual: Vec3, expected: Vec3): void {
+ expected.forEach((value, index) => expect(actual[index]).toBeCloseTo(value, 8));
+}
+
+function zero(): Vec3 {
+ return [0, 0, 0];
+}
+
+function copy(value: Vec3): Vec3 {
+ return [value[0], value[1], value[2]];
+}
+
+function add(a: Vec3, b: Vec3): Vec3 {
+ return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
+}
+
+function scale(value: Vec3, scalar: number): Vec3 {
+ return [value[0] * scalar, value[1] * scalar, value[2] * scalar];
+}
+
+function cross(a: Vec3, b: Vec3): Vec3 {
+ return [
+ a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0],
+ ];
+}
+
+function norm(value: Vec3): number {
+ return Math.hypot(value[0], value[1], value[2]);
+}
+
+function unit(value: Vec3): Vec3 {
+ return scale(value, 1 / norm(value));
+}
+
+describe('physical use case joint reactions', () => {
+ it('reports 500 Nmm and 1500 Nmm reactions for a serial 10 N chain', async () => {
+ const { arm, useCase, certificate } = await makeSerialFixture();
+
+ const result = await reviewPhysicalUseCaseJointReactions(arm, useCase, certificate);
+
+ expect(result.issues).toEqual([]);
+ expect(result.certificates).toHaveLength(1);
+ const distal = reactionByMate(result.certificates[0].reactions, 'distal');
+ const proximal = reactionByMate(result.certificates[0].reactions, 'proximal');
+ expect(distal).toMatchObject({
+ parentPart: 'link-1',
+ childPart: 'link-2',
+ resultantForceN: 10,
+ resultantMomentNmm: 500,
+ radialForceN: 10,
+ axisMomentNmm: 500,
+ bendingMomentNmm: 0,
+ });
+ expect(proximal.resultantMomentNmm).toBeCloseTo(1500, 8);
+ expectVecClose(distal.pointWorldMm, [100, 0, 0]);
+ expectVecClose(distal.axisWorld, [0, 0, 1]);
+ expectVecClose(distal.forceWorldN, certificate.contactForces[0].forceOnHeldWorldN);
+ expectVecClose(distal.momentWorldNmm, [0, 0, 500]);
+ });
+
+ it('solves the loaded mechanism when the disconnected held part was declared first', async () => {
+ const { arm, useCase, certificate } = await makeSerialFixture({ heldFirst: true });
+ expect(arm.__parts()[0].name).toBe('held');
+
+ const result = await reviewPhysicalUseCaseJointReactions(arm, useCase, certificate);
+
+ expect(result.issues).toEqual([]);
+ const distal = reactionByMate(result.certificates[0].reactions, 'distal');
+ const proximal = reactionByMate(result.certificates[0].reactions, 'proximal');
+ expectVecClose(distal.pointWorldMm, [100, 0, 0]);
+ expect(distal.resultantMomentNmm).toBeCloseTo(500, 8);
+ expect(proximal.resultantMomentNmm).toBeCloseTo(1500, 8);
+ });
+
+ it('solves the exact coupled certificate pose before measuring moment arms', async () => {
+ const rest = await makeSerialFixture();
+ const posed = await makeSerialFixture({ poses: { proximal: 90 }, coupled: true });
+
+ const restResult = await reviewPhysicalUseCaseJointReactions(rest.arm, rest.useCase, rest.certificate);
+ const posedResult = await reviewPhysicalUseCaseJointReactions(posed.arm, posed.useCase, posed.certificate);
+
+ expect(restResult.issues).toEqual([]);
+ expect(posedResult.issues).toEqual([]);
+ expect(posed.certificate.poses).toEqual({ proximal: 90 });
+ expect(posedResult.certificates[0].poses).toEqual({ proximal: 90, distal: -90 });
+ expectVecClose(posed.certificate.contactForces[0].pointWorldMm, [50, 100, 0]);
+ expect(reactionByMate(restResult.certificates[0].reactions, 'proximal').resultantMomentNmm)
+ .toBeCloseTo(1500, 8);
+ expect(reactionByMate(posedResult.certificates[0].reactions, 'proximal').resultantMomentNmm)
+ .toBeCloseTo(500, 8);
+ const posedDistal = reactionByMate(posedResult.certificates[0].reactions, 'distal');
+ expect(posedDistal.resultantMomentNmm).toBeCloseTo(500, 8);
+ expectVecClose(posedDistal.pointWorldMm, [0, 100, 0]);
+ });
+
+ it('rejects an explicit driven pose that contradicts its coupling equation', async () => {
+ const { arm, useCase, certificate } = await makeSerialFixture({
+ poses: { proximal: 90, distal: 0 },
+ coupled: true,
+ });
+
+ const result = await reviewPhysicalUseCaseJointReactions(arm, useCase, certificate);
+
+ expect(result.certificates).toEqual([]);
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ kind: 'joint-reaction-input-incomplete',
+ message: expect.stringMatching(/coupl|driven|contradict/i),
+ }),
+ ]);
+ });
+
+ it('combines branch wrenches vectorially so opposing loads cancel upstream', async () => {
+ const { arm, useCase, certificate } = makeBranchFixture();
+
+ const result = await reviewPhysicalUseCaseJointReactions(arm, useCase, certificate);
+
+ expect(result.issues).toEqual([]);
+ const reactions = result.certificates[0].reactions;
+ expect(reactionByMate(reactions, 'branch-a-joint').resultantForceN).toBeCloseTo(10, 8);
+ expect(reactionByMate(reactions, 'branch-b-joint').resultantForceN).toBeCloseTo(10, 8);
+ expect(reactionByMate(reactions, 'proximal').resultantForceN).toBeCloseTo(0, 8);
+ expect(reactionByMate(reactions, 'proximal').resultantMomentNmm).toBeCloseTo(0, 8);
+ expect(reactions.some((reaction) => reaction.mateName === 'idle-joint')).toBe(false);
+ });
+
+ it('collapses fastened parts into one rigid loaded group', async () => {
+ const { arm, useCase, certificate } = makeFastenedFixture();
+
+ const result = await reviewPhysicalUseCaseJointReactions(arm, useCase, certificate);
+
+ expect(result.issues).toEqual([]);
+ expect(result.certificates[0].reactions).toHaveLength(1);
+ expect(result.certificates[0].reactions[0]).toMatchObject({
+ mateName: 'hinge',
+ parentPart: 'base',
+ childPart: 'carrier',
+ resultantForceN: 10,
+ resultantMomentNmm: 1000,
+ });
+ });
+
+ it('rejects an articulated loop instead of selecting a spanning tree', async () => {
+ const { arm, useCase, certificate } = makeLoopFixture();
+
+ const result = await reviewPhysicalUseCaseJointReactions(arm, useCase, certificate);
+
+ expect(result.certificates).toEqual([]);
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ kind: 'joint-reaction-indeterminate',
+ useCaseName: 'loop-load',
+ message: expect.stringMatching(/tree|loop/i),
+ }),
+ ]);
+ });
+
+ it('rejects two stable rigid groups in one loaded component', async () => {
+ const { arm, useCase, certificate } = makeMultipleRootFixture();
+
+ const result = await reviewPhysicalUseCaseJointReactions(arm, useCase, certificate);
+
+ expect(result.certificates).toEqual([]);
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ kind: 'joint-reaction-indeterminate',
+ useCaseName: 'two-root-load',
+ message: expect.stringMatching(/two|2|multiple/i),
+ }),
+ ]);
+ });
+
+ it('rejects a loaded component without a stable root', async () => {
+ const { arm, useCase, certificate } = makeMissingRootFixture();
+
+ const result = await reviewPhysicalUseCaseJointReactions(arm, useCase, certificate);
+
+ expect(result.certificates).toEqual([]);
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ kind: 'joint-reaction-indeterminate',
+ useCaseName: 'missing-root-load',
+ message: expect.stringMatching(/stable root/i),
+ }),
+ ]);
+ });
+
+ it('rejects malformed and mismatched static certificate data', async () => {
+ const { arm, useCase, certificate } = await makeSerialFixture();
+ const mismatched = { ...certificate, useCaseName: 'other-use-case' };
+ const malformed: PhysicalUseCaseStaticCertificate = {
+ ...certificate,
+ contactForces: [{
+ ...certificate.contactForces[0],
+ pointWorldMm: [Number.NaN, 0, 0],
+ }],
+ };
+
+ const mismatchResult = await reviewPhysicalUseCaseJointReactions(arm, useCase, mismatched);
+ const malformedResult = await reviewPhysicalUseCaseJointReactions(arm, useCase, malformed);
+
+ for (const result of [mismatchResult, malformedResult]) {
+ expect(result.certificates).toEqual([]);
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ kind: 'joint-reaction-input-incomplete',
+ useCaseName: 'serial-load',
+ }),
+ ]);
+ }
+ });
+
+ it('rejects a finite certified contact point that is not the solved endpoint midpoint', async () => {
+ const { arm, useCase, certificate } = await makeSerialFixture();
+ const wrongPoint: PhysicalUseCaseStaticCertificate = {
+ ...certificate,
+ contactForces: [{
+ ...certificate.contactForces[0],
+ pointWorldMm: [149, 0, 0],
+ }],
+ };
+
+ const result = await reviewPhysicalUseCaseJointReactions(arm, useCase, wrongPoint);
+
+ expect(result.certificates).toEqual([]);
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ kind: 'joint-reaction-input-incomplete',
+ message: expect.stringMatching(/point|midpoint/i),
+ }),
+ ]);
+ });
+
+ it('rejects solved contact endpoints farther apart than maxSlipMm despite a valid midpoint', async () => {
+ const fixture = await makeSerialFixture({ contactSeparationMm: 2 });
+ const useCase: PhysicalUseCaseRecord = {
+ ...fixture.useCase,
+ criteria: { ...fixture.useCase.criteria, maxSlipMm: 0.1 },
+ };
+
+ const result = await reviewPhysicalUseCaseJointReactions(
+ fixture.arm,
+ useCase,
+ fixture.certificate,
+ );
+
+ expect(result.certificates).toEqual([]);
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ kind: 'joint-reaction-input-incomplete',
+ message: expect.stringMatching(/distance|slip/i),
+ }),
+ ]);
+ });
+
+ it('rejects a finite certified force that no longer balances the declared held load', async () => {
+ const { arm, useCase, certificate } = await makeSerialFixture();
+ const wrongForce: PhysicalUseCaseStaticCertificate = {
+ ...certificate,
+ contactForces: [{
+ ...certificate.contactForces[0],
+ forceOnHeldWorldN: [0, 9, 0],
+ normalForceN: 9,
+ }],
+ };
+
+ const result = await reviewPhysicalUseCaseJointReactions(arm, useCase, wrongForce);
+
+ expect(result.certificates).toEqual([]);
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ kind: 'joint-reaction-input-incomplete',
+ message: expect.stringMatching(/equilibrium|residual/i),
+ }),
+ ]);
+ });
+
+ it('rejects stale residual fields even when they remain below the use-case limits', async () => {
+ const { arm, useCase, certificate } = await makeSerialFixture();
+ const staleResiduals: PhysicalUseCaseStaticCertificate = {
+ ...certificate,
+ forceResidualN: 0.005,
+ torqueResidualNmm: 0.05,
+ };
+
+ const result = await reviewPhysicalUseCaseJointReactions(arm, useCase, staleResiduals);
+
+ expect(result.certificates).toEqual([]);
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ kind: 'joint-reaction-input-incomplete',
+ message: expect.stringMatching(/residual.*match|match.*residual/i),
+ }),
+ ]);
+ });
+});
diff --git a/src/modeling/mates/physicalUseCaseJointReactions.ts b/src/modeling/mates/physicalUseCaseJointReactions.ts
new file mode 100644
index 000000000..deab29215
--- /dev/null
+++ b/src/modeling/mates/physicalUseCaseJointReactions.ts
@@ -0,0 +1,1013 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+import type { Assembly, AssemblyPartStored } from '../capture/assembly';
+import type { NumericPoses } from '../capture/forwardKinematics';
+import type { Vec3 } from '../../shared/intent/types';
+import { currentValue } from '../../shared/runtime/editableHelpers';
+import type { Editable } from '../../shared/runtime/paramRef';
+import type { Transform } from '../../shared/runtime/se3';
+import { resolveConnectorOrigin, type Connector } from './connector';
+import { parseConnectorRef } from './mate';
+import type { PhysicalUseCaseRecord } from './physicalUseCase';
+import {
+ DEFAULT_FORCE_RESIDUAL_N,
+ DEFAULT_TORQUE_RESIDUAL_NMM,
+ type PhysicalUseCaseStaticCertificate,
+ type PhysicalUseCaseStaticContactForce,
+} from './physicalUseCaseStatics';
+import { solveMates } from './solver';
+
+const CONNECTOR_COINCIDENCE_TOLERANCE_MM = 1e-6;
+const AXIS_ALIGNMENT_TOLERANCE = 1e-6;
+const CERTIFICATE_POINT_TOLERANCE_MM = 1e-6;
+const CONTACT_DISTANCE_TOLERANCE_MM = 1e-6;
+const CERTIFICATE_NUMERIC_TOLERANCE = 1e-8;
+
+export interface PhysicalUseCaseJointReactionEvidence {
+ readonly mateName: string;
+ readonly parentPart: string;
+ readonly childPart: string;
+ readonly pointWorldMm: Vec3;
+ readonly axisWorld: Vec3;
+ readonly forceWorldN: Vec3;
+ readonly momentWorldNmm: Vec3;
+ readonly resultantForceN: number;
+ readonly resultantMomentNmm: number;
+ readonly axialForceN: number;
+ readonly radialForceN: number;
+ readonly axisMomentNmm: number;
+ readonly bendingMomentNmm: number;
+}
+
+export type PhysicalUseCaseJointReactionIssue =
+ | { readonly kind: 'joint-reaction-input-incomplete'; readonly useCaseName: string; readonly message: string }
+ | { readonly kind: 'joint-reaction-indeterminate'; readonly useCaseName: string; readonly message: string };
+
+export interface PhysicalUseCaseJointReactionCertificate {
+ readonly useCaseName: string;
+ readonly poses: NumericPoses;
+ readonly reactions: readonly PhysicalUseCaseJointReactionEvidence[];
+}
+
+export interface PhysicalUseCaseJointReactionsResult {
+ readonly issues: readonly PhysicalUseCaseJointReactionIssue[];
+ readonly certificates: readonly PhysicalUseCaseJointReactionCertificate[];
+}
+
+type Mate = ReturnType[number];
+
+interface CertifiedMechanismContact {
+ readonly evidence: PhysicalUseCaseStaticContactForce;
+ readonly mechanismPart: string;
+ readonly pointWorldMm: Vec3;
+ readonly forceOnMechanismWorldN: Vec3;
+}
+
+interface ArticulatedEdge {
+ readonly mate: Mate;
+ readonly aPart: string;
+ readonly bPart: string;
+ readonly aGroup: string;
+ readonly bGroup: string;
+}
+
+interface RigidGroupTopology {
+ readonly groupByPart: ReadonlyMap;
+ readonly edges: readonly ArticulatedEdge[];
+ readonly adjacency: ReadonlyMap;
+}
+
+interface SupportedComponent {
+ readonly groups: ReadonlySet;
+ readonly edges: readonly ArticulatedEdge[];
+ readonly rootGroup: string;
+}
+
+interface OrientedEdge {
+ readonly edge: ArticulatedEdge;
+ readonly parentGroup: string;
+ readonly childGroup: string;
+ readonly parentPart: string;
+ readonly childPart: string;
+}
+
+interface ResolvedJointFrame {
+ readonly pointWorldMm: Vec3;
+ readonly axisWorld: Vec3;
+}
+
+interface ResolvedConnectorPoint {
+ readonly connector: Connector;
+ readonly pointWorldMm: Vec3;
+ readonly transform: Transform;
+}
+
+interface WrenchAtWorldOrigin {
+ readonly forceWorldN: Vec3;
+ readonly momentWorldNmm: Vec3;
+}
+
+interface AccumulatedSubtree {
+ readonly wrench: WrenchAtWorldOrigin;
+ readonly hasAppliedContactLoad: boolean;
+}
+
+interface PreparationFailure {
+ readonly kind: 'input' | 'indeterminate';
+ readonly message: string;
+}
+
+export async function reviewPhysicalUseCaseJointReactions(
+ arm: Assembly,
+ useCase: PhysicalUseCaseRecord,
+ certificate: PhysicalUseCaseStaticCertificate,
+): Promise {
+ const contacts = validateCertificateInput(arm, useCase, certificate);
+ if (typeof contacts === 'string') return inputFailure(useCase.name, contacts);
+ const expandedPoses = expandCertificatePoses(arm, certificate.poses);
+ if (typeof expandedPoses === 'string') return inputFailure(useCase.name, expandedPoses);
+
+ const topology = buildRigidGroupTopology(arm);
+ if ('kind' in topology) return failure(useCase.name, topology);
+ const components = findSupportedLoadedComponents(arm, useCase, topology, contacts);
+ if ('kind' in components) return failure(useCase.name, components);
+
+ let solved: Awaited>;
+ try {
+ solved = await solveMates(arm, expandedPoses, {
+ acceptConsistentArticulatedLoops: true,
+ solveDisconnectedComponents: true,
+ });
+ } catch (error) {
+ return inputFailure(
+ useCase.name,
+ `Static certificate poses could not be solved: ${errorMessage(error)}.`,
+ );
+ }
+ if (solved.status !== 'solved' && solved.status !== 'redundant-ok') {
+ return inputFailure(
+ useCase.name,
+ `Static certificate poses returned mate solver status '${solved.status}'.`,
+ );
+ }
+ const exactPoseIssue = await validateCertificateAtSolvedPose(
+ arm,
+ useCase,
+ certificate,
+ contacts,
+ solved.poses,
+ );
+ if (exactPoseIssue !== undefined) return inputFailure(useCase.name, exactPoseIssue);
+
+ const externalByGroup = contactWrenchesByGroup(topology.groupByPart, contacts);
+ const reactions: PhysicalUseCaseJointReactionEvidence[] = [];
+ for (const component of components) {
+ const oriented = orientComponent(component, topology.adjacency);
+ if ('kind' in oriented) return failure(useCase.name, oriented);
+
+ const frameByEdge = new Map();
+ for (const entry of oriented) {
+ const frame = await resolveJointFrame(arm, solved.poses, entry);
+ if (typeof frame === 'string') return inputFailure(useCase.name, frame);
+ frameByEdge.set(entry.edge, frame);
+ }
+ accumulateComponentReactions(
+ component.rootGroup,
+ oriented,
+ frameByEdge,
+ externalByGroup,
+ reactions,
+ );
+ }
+
+ return {
+ issues: [],
+ certificates: [{
+ useCaseName: useCase.name,
+ poses: copyPoses(expandedPoses),
+ reactions,
+ }],
+ };
+}
+
+function expandCertificatePoses(
+ arm: Assembly,
+ certificatePoses: NumericPoses,
+): NumericPoses | string {
+ const matesByName = new Map(arm.__mates().map((mate) => [mate.name, mate]));
+ const couplings = arm.__mateCouplings();
+ const drivenMateNames = new Set(couplings.map((coupling) => coupling.driven));
+ const expanded = copyPoses(certificatePoses);
+
+ for (let pass = 0; pass <= couplings.length; pass++) {
+ let changed = false;
+ for (const coupling of couplings) {
+ const sourceMate = matesByName.get(coupling.source);
+ if (sourceMate === undefined) {
+ return `Coupling for driven mate '${coupling.driven}' names unknown source '${coupling.source}'.`;
+ }
+ let sourcePose: number | [number, number, number] | undefined =
+ expanded[coupling.source];
+ if (sourcePose === undefined && !drivenMateNames.has(coupling.source)) {
+ try {
+ sourcePose = sourceMate.pose === undefined
+ ? 0
+ : Array.isArray(sourceMate.pose)
+ ? undefined
+ : currentValue(sourceMate.pose as Editable, arm.__session().paramTable);
+ } catch (error) {
+ return `Coupling source pose '${coupling.source}' could not be resolved: ${errorMessage(error)}.`;
+ }
+ }
+ if (sourcePose === undefined) continue;
+ if (Array.isArray(sourcePose) || !Number.isFinite(sourcePose)) {
+ return `Coupling source pose '${coupling.source}' is not a finite scalar.`;
+ }
+ const expectedDriven = sourcePose * coupling.ratio + (coupling.offset ?? 0);
+ const explicitDriven = certificatePoses[coupling.driven];
+ if (explicitDriven !== undefined) {
+ if (Array.isArray(explicitDriven) || !numbersMatch(explicitDriven, expectedDriven)) {
+ return `Explicit driven pose '${coupling.driven}' contradicts coupling '${coupling.source} * ${coupling.ratio} + ${coupling.offset ?? 0}'.`;
+ }
+ continue;
+ }
+ if (expanded[coupling.driven] === undefined) {
+ expanded[coupling.driven] = expectedDriven;
+ changed = true;
+ }
+ }
+ if (!changed) break;
+ }
+
+ for (const coupling of couplings) {
+ if (expanded[coupling.driven] === undefined) {
+ return `Coupled pose '${coupling.driven}' could not be derived from source '${coupling.source}'.`;
+ }
+ }
+ return expanded;
+}
+
+async function validateCertificateAtSolvedPose(
+ arm: Assembly,
+ useCase: PhysicalUseCaseRecord,
+ certificate: PhysicalUseCaseStaticCertificate,
+ contacts: readonly CertifiedMechanismContact[],
+ transforms: ReadonlyMap,
+): Promise {
+ for (const contact of contacts) {
+ const declared = useCase.contacts.find((candidate) =>
+ contactKey(candidate.a, candidate.b) ===
+ contactKey(contact.evidence.contactA, contact.evidence.contactB));
+ if (declared === undefined) {
+ return `Certified contact '${contact.evidence.contactA}' to '${contact.evidence.contactB}' is not declared.`;
+ }
+ const aPoint = await resolveConnectorPoint(arm, transforms, declared.a);
+ if (typeof aPoint === 'string') return aPoint;
+ const bPoint = await resolveConnectorPoint(arm, transforms, declared.b);
+ if (typeof bPoint === 'string') return bPoint;
+ const maxSlipMm = useCase.criteria?.maxSlipMm ?? 0;
+ if (!Number.isFinite(maxSlipMm) || maxSlipMm < 0) {
+ return `Use-case maxSlipMm must be a finite non-negative value.`;
+ }
+ const endpointDistanceMm = distance(aPoint.pointWorldMm, bPoint.pointWorldMm);
+ if (endpointDistanceMm > maxSlipMm + CONTACT_DISTANCE_TOLERANCE_MM) {
+ return `Solved contact endpoint distance ${endpointDistanceMm} mm for '${declared.a}' to '${declared.b}' exceeds maxSlipMm ${maxSlipMm}.`;
+ }
+ const expectedPoint = midpoint(aPoint.pointWorldMm, bPoint.pointWorldMm);
+ if (distance(contact.evidence.pointWorldMm, expectedPoint) > CERTIFICATE_POINT_TOLERANCE_MM) {
+ return `Certified contact point for '${declared.a}' to '${declared.b}' does not match the solved endpoint midpoint.`;
+ }
+
+ const heldNormal = contactHeldWorldNormal(
+ declared,
+ certificate.heldPart,
+ transforms,
+ );
+ if (typeof heldNormal === 'string') return heldNormal;
+ const normalForceN = dot(contact.evidence.forceOnHeldWorldN, heldNormal);
+ const tangentialForceN = norm(sub(
+ contact.evidence.forceOnHeldWorldN,
+ scale(heldNormal, normalForceN),
+ ));
+ if (
+ !numbersMatch(normalForceN, contact.evidence.normalForceN) ||
+ !numbersMatch(tangentialForceN, contact.evidence.tangentialForceN)
+ ) {
+ return `Certified contact force metadata for '${declared.a}' to '${declared.b}' does not match forceOnHeldWorldN at the solved pose.`;
+ }
+ if (
+ normalForceN < -CERTIFICATE_NUMERIC_TOLERANCE ||
+ normalForceN > contact.evidence.normalCapacityN + CERTIFICATE_NUMERIC_TOLERANCE ||
+ tangentialForceN >
+ contact.evidence.friction * Math.max(0, normalForceN) + CERTIFICATE_NUMERIC_TOLERANCE
+ ) {
+ return `Certified contact force for '${declared.a}' to '${declared.b}' is outside its solved-pose contact limits.`;
+ }
+ }
+
+ const loads: { force: Vec3; torque: Vec3; pointWorldMm?: Vec3 }[] = [];
+ for (const load of useCase.loads) {
+ if (load.force !== undefined && !isFiniteVec3(load.force)) {
+ return `Declared force load on '${load.part}' is not a finite Vec3.`;
+ }
+ if (load.torque !== undefined && !isFiniteVec3(load.torque)) {
+ return `Declared torque load on '${load.part}' is not a finite Vec3.`;
+ }
+ let pointWorldMm: Vec3 | undefined;
+ if (load.at !== undefined) {
+ const parsed = safeParseConnectorRef(load.at);
+ if (parsed?.partName !== certificate.heldPart) {
+ return `Load application connector '${load.at}' does not belong to held part '${certificate.heldPart}'.`;
+ }
+ const resolved = await resolveConnectorPoint(arm, transforms, load.at);
+ if (typeof resolved === 'string') return resolved;
+ pointWorldMm = resolved.pointWorldMm;
+ } else if (hasNonZeroVec(load.force)) {
+ return `Force load on '${load.part}' has no application connector at the certified pose.`;
+ }
+ loads.push({
+ force: load.force === undefined ? [0, 0, 0] : copyVec(load.force),
+ torque: load.torque === undefined ? [0, 0, 0] : copyVec(load.torque),
+ ...(pointWorldMm === undefined ? {} : { pointWorldMm }),
+ });
+ }
+ const referencePoint = loads.find((load) => load.pointWorldMm !== undefined)?.pointWorldMm;
+ if (referencePoint === undefined) {
+ return `Held part '${certificate.heldPart}' has no resolved load application connector.`;
+ }
+
+ let netForce: Vec3 = [0, 0, 0];
+ let netMoment: Vec3 = [0, 0, 0];
+ for (const load of loads) {
+ netForce = add(netForce, load.force);
+ netMoment = add(netMoment, load.torque);
+ if (load.pointWorldMm !== undefined) {
+ netMoment = add(
+ netMoment,
+ cross(sub(load.pointWorldMm, referencePoint), load.force),
+ );
+ }
+ }
+ for (const contact of contacts) {
+ netForce = add(netForce, contact.evidence.forceOnHeldWorldN);
+ netMoment = add(
+ netMoment,
+ cross(
+ sub(contact.evidence.pointWorldMm, referencePoint),
+ contact.evidence.forceOnHeldWorldN,
+ ),
+ );
+ }
+
+ const forceResidualN = norm(netForce);
+ const torqueResidualNmm = norm(netMoment);
+ const forceToleranceN = Math.min(
+ useCase.criteria?.maxForceResidualN ?? DEFAULT_FORCE_RESIDUAL_N,
+ DEFAULT_FORCE_RESIDUAL_N,
+ );
+ const torqueToleranceNmm = Math.min(
+ useCase.criteria?.maxTorqueResidualNmm ?? DEFAULT_TORQUE_RESIDUAL_NMM,
+ DEFAULT_TORQUE_RESIDUAL_NMM,
+ );
+ if (
+ !isPositiveFinite(forceToleranceN) ||
+ !isPositiveFinite(torqueToleranceNmm) ||
+ forceResidualN > forceToleranceN + CERTIFICATE_NUMERIC_TOLERANCE ||
+ torqueResidualNmm > torqueToleranceNmm + CERTIFICATE_NUMERIC_TOLERANCE
+ ) {
+ return `Supplied contact forces do not satisfy held-body equilibrium at the certified pose (force residual ${forceResidualN} N, moment residual ${torqueResidualNmm} Nmm).`;
+ }
+ if (
+ !numbersMatch(forceResidualN, certificate.forceResidualN) ||
+ !numbersMatch(torqueResidualNmm, certificate.torqueResidualNmm)
+ ) {
+ return `Recomputed solved-pose residuals do not match the static certificate residual fields.`;
+ }
+ return undefined;
+}
+
+function contactHeldWorldNormal(
+ contact: PhysicalUseCaseRecord['contacts'][number],
+ heldPart: string,
+ transforms: ReadonlyMap,
+): Vec3 | string {
+ const frame = contact.normalFrame ?? 'world';
+ let worldNormal: Vec3;
+ if (frame === 'world') {
+ worldNormal = copyVec(contact.normal);
+ } else {
+ const ref = frame === 'a' ? contact.a : contact.b;
+ const partName = safePartName(ref);
+ const transform = partName === undefined ? undefined : transforms.get(partName);
+ if (transform === undefined) {
+ return `Contact normal frame '${frame}' for '${contact.a}' to '${contact.b}' could not be resolved.`;
+ }
+ worldNormal = [...transform.axisDir(contact.normal)] as Vec3;
+ }
+ if (!isFiniteVec3(worldNormal) || norm(worldNormal) <= 0) {
+ return `Contact normal for '${contact.a}' to '${contact.b}' is not finite and non-zero.`;
+ }
+ worldNormal = unit(worldNormal);
+ return safePartName(contact.a) === heldPart ? worldNormal : scale(worldNormal, -1);
+}
+
+function validateCertificateInput(
+ arm: Assembly,
+ useCase: PhysicalUseCaseRecord,
+ certificate: PhysicalUseCaseStaticCertificate,
+): CertifiedMechanismContact[] | string {
+ if (certificate.useCaseName !== useCase.name) {
+ return `Static certificate use case '${certificate.useCaseName}' does not match '${useCase.name}'.`;
+ }
+ const partsByName = new Map(arm.__parts().map((part) => [part.name, part]));
+ if (!partsByName.has(certificate.heldPart)) {
+ return `Static certificate held part '${certificate.heldPart}' does not exist in the assembly.`;
+ }
+ const loadedParts = [...new Set(useCase.loads.map((load) => load.part))];
+ if (loadedParts.length !== 1 || loadedParts[0] !== certificate.heldPart) {
+ return `Static certificate held part '${certificate.heldPart}' does not match the use-case load owner.`;
+ }
+ for (const stablePart of useCase.stableParts) {
+ if (!partsByName.has(stablePart)) {
+ return `Stable part '${stablePart}' does not exist in the assembly.`;
+ }
+ }
+ for (const mate of arm.__mates()) {
+ const aPart = safePartName(mate.a);
+ const bPart = safePartName(mate.b);
+ if (aPart === certificate.heldPart || bPart === certificate.heldPart) {
+ return `Static certificate held part '${certificate.heldPart}' is connected by structural mate '${mate.name}'.`;
+ }
+ }
+
+ const forceLimit = useCase.criteria?.maxForceResidualN ?? DEFAULT_FORCE_RESIDUAL_N;
+ const torqueLimit = useCase.criteria?.maxTorqueResidualNmm ?? DEFAULT_TORQUE_RESIDUAL_NMM;
+ if (
+ !isNonNegativeFinite(certificate.forceResidualN) ||
+ certificate.forceResidualN > forceLimit + 1e-12 ||
+ !isNonNegativeFinite(certificate.torqueResidualNmm) ||
+ certificate.torqueResidualNmm > torqueLimit + 1e-12
+ ) {
+ return 'Static certificate residuals are not finite passing values for this use case.';
+ }
+
+ const matesByName = new Map(arm.__mates().map((mate) => [mate.name, mate]));
+ for (const [mateName, pose] of Object.entries(certificate.poses)) {
+ const mate = matesByName.get(mateName);
+ if (mate === undefined) return `Static certificate pose names unknown mate '${mateName}'.`;
+ if (!isFinitePose(pose)) return `Static certificate pose for mate '${mateName}' is not finite.`;
+ if (mate.type === 'ball' ? !Array.isArray(pose) : Array.isArray(pose)) {
+ return `Static certificate pose for mate '${mateName}' has the wrong shape for '${mate.type}'.`;
+ }
+ if (mate.type === 'fastened' || mate.type === 'planar') {
+ return `Static certificate must not provide a pose for zero-DOF mate '${mateName}'.`;
+ }
+ }
+
+ if (certificate.contactForces.length !== useCase.contacts.length) {
+ return `Static certificate has ${certificate.contactForces.length} contact forces for ${useCase.contacts.length} declared contacts.`;
+ }
+ const declaredContacts = new Map();
+ for (const contact of useCase.contacts) {
+ const key = contactKey(contact.a, contact.b);
+ if (declaredContacts.has(key)) {
+ return `Use case declares duplicate contact '${contact.a}' to '${contact.b}'.`;
+ }
+ declaredContacts.set(key, contact);
+ }
+
+ const seen = new Set();
+ const resolved: CertifiedMechanismContact[] = [];
+ for (const evidence of certificate.contactForces) {
+ const key = contactKey(evidence.contactA, evidence.contactB);
+ const declared = declaredContacts.get(key);
+ if (declared === undefined || seen.has(key)) {
+ return `Static certificate contact '${evidence.contactA}' to '${evidence.contactB}' does not match the declared contacts.`;
+ }
+ seen.add(key);
+ const aPart = safePartName(evidence.contactA);
+ const bPart = safePartName(evidence.contactB);
+ const heldIsA = aPart === certificate.heldPart;
+ const heldIsB = bPart === certificate.heldPart;
+ if (aPart === undefined || bPart === undefined || heldIsA === heldIsB) {
+ return `Static certificate contact '${evidence.contactA}' to '${evidence.contactB}' has invalid held/mechanism ownership.`;
+ }
+ const mechanismPart = heldIsA ? bPart : aPart;
+ if (evidence.mechanismPart !== mechanismPart || !partsByName.has(mechanismPart)) {
+ return `Static certificate mechanism part '${evidence.mechanismPart}' does not own the non-held contact endpoint.`;
+ }
+ if (
+ !connectorExists(partsByName, evidence.contactA) ||
+ !connectorExists(partsByName, evidence.contactB)
+ ) {
+ return `Static certificate contact '${evidence.contactA}' to '${evidence.contactB}' names an unknown connector.`;
+ }
+ if (!isFiniteVec3(evidence.pointWorldMm) || !isFiniteVec3(evidence.forceOnHeldWorldN)) {
+ return `Static certificate contact '${evidence.contactA}' to '${evidence.contactB}' has a non-finite point or force.`;
+ }
+ if (
+ !isNonNegativeFinite(evidence.normalForceN) ||
+ !isNonNegativeFinite(evidence.tangentialForceN) ||
+ !isPositiveFinite(evidence.normalCapacityN) ||
+ !isPositiveFinite(evidence.friction) ||
+ evidence.normalForceN > evidence.normalCapacityN + 1e-8 ||
+ evidence.tangentialForceN > evidence.friction * evidence.normalForceN + 1e-8
+ ) {
+ return `Static certificate contact '${evidence.contactA}' to '${evidence.contactB}' is outside its certified contact limits.`;
+ }
+ if (
+ declared.normalForceN === undefined ||
+ !nearlyEqual(evidence.normalCapacityN, declared.normalForceN) ||
+ !nearlyEqual(evidence.friction, declared.friction)
+ ) {
+ return `Static certificate contact '${evidence.contactA}' to '${evidence.contactB}' does not match declared capacity or friction.`;
+ }
+ resolved.push({
+ evidence,
+ mechanismPart,
+ pointWorldMm: copyVec(evidence.pointWorldMm),
+ forceOnMechanismWorldN: scale(evidence.forceOnHeldWorldN, -1),
+ });
+ }
+ return resolved;
+}
+
+function buildRigidGroupTopology(arm: Assembly): RigidGroupTopology | PreparationFailure {
+ const parts = arm.__parts();
+ const partNames = new Set(parts.map((part) => part.name));
+ const groups = new DisjointSet(partNames);
+ for (const mate of arm.__mates()) {
+ const endpoints = mateEndpointParts(mate);
+ if (typeof endpoints === 'string') return { kind: 'input', message: endpoints };
+ if (!partNames.has(endpoints.aPart) || !partNames.has(endpoints.bPart)) {
+ return { kind: 'input', message: `Mate '${mate.name}' references an unknown part.` };
+ }
+ if (mate.type === 'fastened') groups.union(endpoints.aPart, endpoints.bPart);
+ }
+
+ const groupByPart = new Map(parts.map((part) => [part.name, groups.find(part.name)]));
+ const adjacency = new Map();
+ for (const group of groupByPart.values()) adjacency.set(group, []);
+ const edges: ArticulatedEdge[] = [];
+ for (const mate of arm.__mates()) {
+ if (mate.type === 'fastened') continue;
+ const endpoints = mateEndpointParts(mate);
+ if (typeof endpoints === 'string') return { kind: 'input', message: endpoints };
+ const edge: ArticulatedEdge = {
+ mate,
+ ...endpoints,
+ aGroup: groupByPart.get(endpoints.aPart)!,
+ bGroup: groupByPart.get(endpoints.bPart)!,
+ };
+ edges.push(edge);
+ adjacency.get(edge.aGroup)!.push(edge);
+ if (edge.bGroup !== edge.aGroup) adjacency.get(edge.bGroup)!.push(edge);
+ }
+ return { groupByPart, edges, adjacency };
+}
+
+function findSupportedLoadedComponents(
+ arm: Assembly,
+ useCase: PhysicalUseCaseRecord,
+ topology: RigidGroupTopology,
+ contacts: readonly CertifiedMechanismContact[],
+): SupportedComponent[] | PreparationFailure {
+ const stableGroups = new Set(useCase.stableParts.map((part) => topology.groupByPart.get(part)!));
+ const loadedGroups = contacts.map((contact) => topology.groupByPart.get(contact.mechanismPart)!);
+ const covered = new Set();
+ const components: SupportedComponent[] = [];
+
+ for (const loadedGroup of loadedGroups) {
+ if (loadedGroup === undefined) {
+ return { kind: 'input', message: 'A certified mechanism contact has no rigid-group owner.' };
+ }
+ if (covered.has(loadedGroup)) continue;
+ const componentGroups = collectConnectedGroups(loadedGroup, topology.adjacency);
+ for (const group of componentGroups) covered.add(group);
+ const componentEdges = topology.edges.filter((edge) =>
+ componentGroups.has(edge.aGroup) && componentGroups.has(edge.bGroup));
+ const roots = [...componentGroups].filter((group) => stableGroups.has(group));
+ if (roots.length === 0) {
+ return {
+ kind: 'indeterminate',
+ message: `Loaded articulated component containing rigid group '${loadedGroup}' has no stable root rigid group.`,
+ };
+ }
+ if (roots.length !== 1) {
+ return {
+ kind: 'indeterminate',
+ message: `Loaded articulated component containing rigid group '${loadedGroup}' has ${roots.length} stable root rigid groups; exactly one is required.`,
+ };
+ }
+ if (componentEdges.length !== componentGroups.size - 1) {
+ return {
+ kind: 'indeterminate',
+ message: `Loaded articulated component containing rigid group '${loadedGroup}' is not a tree (${componentGroups.size} groups, ${componentEdges.length} articulated mates); loops and parallel paths require load-sharing evidence.`,
+ };
+ }
+ components.push({ groups: componentGroups, edges: componentEdges, rootGroup: roots[0] });
+ }
+
+ if (contacts.length > 0 && components.length === 0) {
+ return { kind: 'input', message: `Use case '${useCase.name}' has no resolvable loaded component.` };
+ }
+ void arm;
+ return components;
+}
+
+function orientComponent(
+ component: SupportedComponent,
+ adjacency: RigidGroupTopology['adjacency'],
+): OrientedEdge[] | PreparationFailure {
+ const oriented: OrientedEdge[] = [];
+ const visited = new Set([component.rootGroup]);
+ const queue = [component.rootGroup];
+ while (queue.length > 0) {
+ const parentGroup = queue.shift()!;
+ for (const edge of adjacency.get(parentGroup) ?? []) {
+ if (!component.edges.includes(edge)) continue;
+ const childGroup = edge.aGroup === parentGroup ? edge.bGroup : edge.aGroup;
+ if (visited.has(childGroup)) continue;
+ visited.add(childGroup);
+ queue.push(childGroup);
+ const parentIsA = edge.aGroup === parentGroup;
+ oriented.push({
+ edge,
+ parentGroup,
+ childGroup,
+ parentPart: parentIsA ? edge.aPart : edge.bPart,
+ childPart: parentIsA ? edge.bPart : edge.aPart,
+ });
+ }
+ }
+ if (visited.size !== component.groups.size || oriented.length !== component.edges.length) {
+ return { kind: 'indeterminate', message: 'Loaded articulated component could not be oriented as a rooted tree.' };
+ }
+ return oriented;
+}
+
+async function resolveJointFrame(
+ arm: Assembly,
+ transforms: ReadonlyMap,
+ oriented: OrientedEdge,
+): Promise {
+ const parentRef = oriented.parentPart === oriented.edge.aPart
+ ? oriented.edge.mate.a
+ : oriented.edge.mate.b;
+ const childRef = oriented.childPart === oriented.edge.aPart
+ ? oriented.edge.mate.a
+ : oriented.edge.mate.b;
+ const parent = await resolveConnectorSide(arm, transforms, parentRef);
+ if (typeof parent === 'string') return `Mate '${oriented.edge.mate.name}' parent side: ${parent}`;
+ const child = await resolveConnectorSide(arm, transforms, childRef);
+ if (typeof child === 'string') return `Mate '${oriented.edge.mate.name}' child side: ${child}`;
+ if (distance(parent.pointWorldMm, child.pointWorldMm) > CONNECTOR_COINCIDENCE_TOLERANCE_MM) {
+ return `Mate '${oriented.edge.mate.name}' connector origins are not coincident at the certified pose.`;
+ }
+ if (Math.abs(dot(parent.axisWorld, child.axisWorld)) < 1 - AXIS_ALIGNMENT_TOLERANCE) {
+ return `Mate '${oriented.edge.mate.name}' connector axes are not aligned at the certified pose.`;
+ }
+ return {
+ pointWorldMm: midpoint(parent.pointWorldMm, child.pointWorldMm),
+ axisWorld: parent.axisWorld,
+ };
+}
+
+async function resolveConnectorSide(
+ arm: Assembly,
+ transforms: ReadonlyMap,
+ ref: string,
+): Promise {
+ const resolved = await resolveConnectorPoint(arm, transforms, ref);
+ if (typeof resolved === 'string') return resolved;
+ const { connector, pointWorldMm, transform } = resolved;
+ if (connector.type !== 'axis') return `connector '${ref}' is not an axis connector.`;
+ const localAxis = connector.axis ?? [0, 0, 1];
+ if (!isFiniteVec3(localAxis) || norm(localAxis) <= 0) {
+ return `connector '${ref}' has no finite non-zero axis.`;
+ }
+ const axisWorld = unit([...transform.axisDir(localAxis)] as Vec3);
+ if (!isFiniteVec3(axisWorld) || norm(axisWorld) <= 0) {
+ return `connector '${ref}' has a non-finite solved axis.`;
+ }
+ return { pointWorldMm, axisWorld };
+}
+
+async function resolveConnectorPoint(
+ arm: Assembly,
+ transforms: ReadonlyMap,
+ ref: string,
+): Promise {
+ const parsed = safeParseConnectorRef(ref);
+ if (parsed === undefined) return `connector reference '${ref}' is malformed.`;
+ const part = arm.__parts().find((candidate) => candidate.name === parsed.partName);
+ const connector = part?.mateConnectors.find((candidate) => candidate.name === parsed.connectorName);
+ const transform = transforms.get(parsed.partName);
+ if (part === undefined || connector === undefined || transform === undefined) {
+ return `connector '${ref}' could not be resolved in the solved assembly.`;
+ }
+ let localPoint: Vec3;
+ try {
+ localPoint = (await resolveConnectorOrigin(
+ part.originalShape,
+ connector.origin,
+ arm.__session().getRecords(),
+ )).value;
+ } catch (error) {
+ return `connector '${ref}' origin could not be resolved: ${errorMessage(error)}.`;
+ }
+ const pointWorldMm = [...transform.point(localPoint)] as Vec3;
+ if (!isFiniteVec3(pointWorldMm)) {
+ return `connector '${ref}' has a non-finite solved transform.`;
+ }
+ return { connector, pointWorldMm, transform };
+}
+
+function contactWrenchesByGroup(
+ groupByPart: ReadonlyMap,
+ contacts: readonly CertifiedMechanismContact[],
+): Map {
+ const result = new Map();
+ for (const contact of contacts) {
+ const group = groupByPart.get(contact.mechanismPart)!;
+ const current = result.get(group) ?? zeroWrench();
+ result.set(group, {
+ forceWorldN: add(current.forceWorldN, contact.forceOnMechanismWorldN),
+ momentWorldNmm: add(
+ current.momentWorldNmm,
+ cross(contact.pointWorldMm, contact.forceOnMechanismWorldN),
+ ),
+ });
+ }
+ return result;
+}
+
+function accumulateComponentReactions(
+ rootGroup: string,
+ oriented: readonly OrientedEdge[],
+ frameByEdge: ReadonlyMap,
+ externalByGroup: ReadonlyMap,
+ reactions: PhysicalUseCaseJointReactionEvidence[],
+): AccumulatedSubtree {
+ const children = new Map();
+ for (const entry of oriented) {
+ const list = children.get(entry.parentGroup) ?? [];
+ list.push(entry);
+ children.set(entry.parentGroup, list);
+ }
+
+ const accumulate = (group: string): AccumulatedSubtree => {
+ const ownWrench = externalByGroup.get(group);
+ let subtree = ownWrench ?? zeroWrench();
+ let hasAppliedContactLoad = ownWrench !== undefined && norm(ownWrench.forceWorldN) > 1e-9;
+ for (const child of children.get(group) ?? []) {
+ const childSubtree = accumulate(child.childGroup);
+ const frame = frameByEdge.get(child.edge)!;
+ const externalMomentAtJoint = sub(
+ childSubtree.wrench.momentWorldNmm,
+ cross(frame.pointWorldMm, childSubtree.wrench.forceWorldN),
+ );
+ const forceWorldN = scale(childSubtree.wrench.forceWorldN, -1);
+ const momentWorldNmm = scale(externalMomentAtJoint, -1);
+ if (childSubtree.hasAppliedContactLoad) {
+ reactions.push(makeReactionEvidence(child, frame, forceWorldN, momentWorldNmm));
+ }
+ subtree = addWrenches(subtree, childSubtree.wrench);
+ hasAppliedContactLoad ||= childSubtree.hasAppliedContactLoad;
+ }
+ return { wrench: subtree, hasAppliedContactLoad };
+ };
+ return accumulate(rootGroup);
+}
+
+function makeReactionEvidence(
+ oriented: OrientedEdge,
+ frame: ResolvedJointFrame,
+ forceWorldN: Vec3,
+ momentWorldNmm: Vec3,
+): PhysicalUseCaseJointReactionEvidence {
+ const axialForce = dot(forceWorldN, frame.axisWorld);
+ const axisMoment = dot(momentWorldNmm, frame.axisWorld);
+ const radialForce = sub(forceWorldN, scale(frame.axisWorld, axialForce));
+ const bendingMoment = sub(momentWorldNmm, scale(frame.axisWorld, axisMoment));
+ return {
+ mateName: oriented.edge.mate.name,
+ parentPart: oriented.parentPart,
+ childPart: oriented.childPart,
+ pointWorldMm: copyVec(frame.pointWorldMm),
+ axisWorld: copyVec(frame.axisWorld),
+ forceWorldN: copyVec(forceWorldN),
+ momentWorldNmm: copyVec(momentWorldNmm),
+ resultantForceN: norm(forceWorldN),
+ resultantMomentNmm: norm(momentWorldNmm),
+ axialForceN: Math.abs(axialForce),
+ radialForceN: norm(radialForce),
+ axisMomentNmm: Math.abs(axisMoment),
+ bendingMomentNmm: norm(bendingMoment),
+ };
+}
+
+function mateEndpointParts(mate: Mate): { aPart: string; bPart: string } | string {
+ const aPart = safePartName(mate.a);
+ const bPart = safePartName(mate.b);
+ if (aPart === undefined || bPart === undefined) {
+ return `Mate '${mate.name}' has a malformed connector reference.`;
+ }
+ return { aPart, bPart };
+}
+
+function collectConnectedGroups(
+ start: string,
+ adjacency: RigidGroupTopology['adjacency'],
+): Set {
+ const result = new Set([start]);
+ const queue = [start];
+ while (queue.length > 0) {
+ const group = queue.shift()!;
+ for (const edge of adjacency.get(group) ?? []) {
+ const neighbor = edge.aGroup === group ? edge.bGroup : edge.aGroup;
+ if (result.has(neighbor)) continue;
+ result.add(neighbor);
+ queue.push(neighbor);
+ }
+ }
+ return result;
+}
+
+class DisjointSet {
+ private readonly parent = new Map();
+ private readonly rank = new Map();
+
+ constructor(values: Iterable) {
+ for (const value of values) {
+ this.parent.set(value, value);
+ this.rank.set(value, 0);
+ }
+ }
+
+ find(value: string): string {
+ const parent = this.parent.get(value);
+ if (parent === undefined) throw new Error(`Unknown disjoint-set value '${value}'.`);
+ if (parent === value) return value;
+ const root = this.find(parent);
+ this.parent.set(value, root);
+ return root;
+ }
+
+ union(a: string, b: string): void {
+ const rootA = this.find(a);
+ const rootB = this.find(b);
+ if (rootA === rootB) return;
+ const rankA = this.rank.get(rootA)!;
+ const rankB = this.rank.get(rootB)!;
+ if (rankA < rankB) {
+ this.parent.set(rootA, rootB);
+ } else {
+ this.parent.set(rootB, rootA);
+ if (rankA === rankB) this.rank.set(rootA, rankA + 1);
+ }
+ }
+}
+
+function connectorExists(
+ partsByName: ReadonlyMap,
+ ref: string,
+): boolean {
+ const parsed = safeParseConnectorRef(ref);
+ return parsed !== undefined &&
+ partsByName.get(parsed.partName)?.mateConnectors.some(
+ (connector: Connector) => connector.name === parsed.connectorName,
+ ) === true;
+}
+
+function failure(
+ useCaseName: string,
+ problem: PreparationFailure,
+): PhysicalUseCaseJointReactionsResult {
+ return problem.kind === 'input'
+ ? inputFailure(useCaseName, problem.message)
+ : {
+ certificates: [],
+ issues: [{ kind: 'joint-reaction-indeterminate', useCaseName, message: problem.message }],
+ };
+}
+
+function inputFailure(useCaseName: string, message: string): PhysicalUseCaseJointReactionsResult {
+ return {
+ certificates: [],
+ issues: [{ kind: 'joint-reaction-input-incomplete', useCaseName, message }],
+ };
+}
+
+function safeParseConnectorRef(ref: string): ReturnType | undefined {
+ try {
+ return parseConnectorRef(ref);
+ } catch {
+ return undefined;
+ }
+}
+
+function safePartName(ref: string): string | undefined {
+ return safeParseConnectorRef(ref)?.partName;
+}
+
+function contactKey(a: string, b: string): string {
+ return `${a}\n${b}`;
+}
+
+function copyPoses(poses: NumericPoses): NumericPoses {
+ return Object.fromEntries(Object.entries(poses).map(([name, pose]) => [
+ name,
+ Array.isArray(pose) ? [pose[0], pose[1], pose[2]] : pose,
+ ]));
+}
+
+function isFinitePose(value: number | [number, number, number]): boolean {
+ return Array.isArray(value)
+ ? value.length === 3 && value.every(Number.isFinite)
+ : Number.isFinite(value);
+}
+
+function isFiniteVec3(value: readonly number[]): value is Vec3 {
+ return value.length === 3 && value.every(Number.isFinite);
+}
+
+function hasNonZeroVec(value: readonly number[] | undefined): value is Vec3 {
+ return value !== undefined && isFiniteVec3(value) && norm(value) > 0;
+}
+
+function isNonNegativeFinite(value: number): boolean {
+ return Number.isFinite(value) && value >= 0;
+}
+
+function isPositiveFinite(value: number): boolean {
+ return Number.isFinite(value) && value > 0;
+}
+
+function nearlyEqual(a: number, b: number): boolean {
+ return Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
+}
+
+function numbersMatch(a: number, b: number): boolean {
+ return Math.abs(a - b) <=
+ CERTIFICATE_NUMERIC_TOLERANCE * Math.max(1, Math.abs(a), Math.abs(b));
+}
+
+function zeroWrench(): WrenchAtWorldOrigin {
+ return { forceWorldN: [0, 0, 0], momentWorldNmm: [0, 0, 0] };
+}
+
+function addWrenches(a: WrenchAtWorldOrigin, b: WrenchAtWorldOrigin): WrenchAtWorldOrigin {
+ return {
+ forceWorldN: add(a.forceWorldN, b.forceWorldN),
+ momentWorldNmm: add(a.momentWorldNmm, b.momentWorldNmm),
+ };
+}
+
+function copyVec(value: readonly [number, number, number]): Vec3 {
+ return [value[0], value[1], value[2]];
+}
+
+function add(a: Vec3, b: Vec3): Vec3 {
+ return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
+}
+
+function sub(a: Vec3, b: Vec3): Vec3 {
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
+}
+
+function scale(value: readonly [number, number, number], scalar: number): Vec3 {
+ return [value[0] * scalar, value[1] * scalar, value[2] * scalar];
+}
+
+function dot(a: Vec3, b: Vec3): number {
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
+}
+
+function cross(a: Vec3, b: Vec3): Vec3 {
+ return [
+ a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0],
+ ];
+}
+
+function norm(value: Vec3): number {
+ return Math.hypot(value[0], value[1], value[2]);
+}
+
+function unit(value: Vec3): Vec3 {
+ return scale(value, 1 / norm(value));
+}
+
+function midpoint(a: Vec3, b: Vec3): Vec3 {
+ return scale(add(a, b), 0.5);
+}
+
+function distance(a: Vec3, b: Vec3): number {
+ return norm(sub(a, b));
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/src/modeling/mates/physicalUseCaseReachability.test.ts b/src/modeling/mates/physicalUseCaseReachability.test.ts
new file mode 100644
index 000000000..90ff616be
--- /dev/null
+++ b/src/modeling/mates/physicalUseCaseReachability.test.ts
@@ -0,0 +1,278 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+import { describe, expect, it } from 'vitest';
+import { CaptureSession } from '../capture/captureSession';
+import { createApi } from '../api';
+import { makePhysicalUseCaseRecord } from './physicalUseCase';
+import {
+ assessPhysicalUseCaseReachability,
+ buildTargetedReachabilitySamples,
+ reviewPhysicalUseCaseReachability,
+} from './physicalUseCaseReachability';
+
+function makeArm() {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ return { arm: kcad.assembly('reachability'), kcad };
+}
+
+function makeRotatingFingerRig(targetB: readonly [number, number, number]) {
+ const { arm, kcad } = makeArm();
+ arm
+ .part('base', kcad.box(10, 10, 10))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('target-a', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } })
+ .connector('target-b', { type: 'frame', origin: { kind: 'vec3', value: targetB } });
+ arm
+ .part('finger', kcad.box(10, 2, 2))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('a', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } })
+ .connector('b', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } });
+ arm.mate('yaw', 'base.axis', 'finger.axis', 'revolute', { limitsDeg: [0, 90] });
+ return arm;
+}
+
+describe('physical use case reachability', () => {
+ it('distributes capped actuator samples across mixed actuator positions', () => {
+ const { arm, kcad } = makeArm();
+ arm
+ .part('base', kcad.box(10, 10, 10))
+ .connector('j0', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('j1', { type: 'axis', origin: { kind: 'vec3', value: [0, 20, 0] }, axis: [0, 0, 1] })
+ .connector('j2', { type: 'axis', origin: { kind: 'vec3', value: [0, 40, 0] }, axis: [0, 0, 1] })
+ .connector('j3', { type: 'axis', origin: { kind: 'vec3', value: [0, 60, 0] }, axis: [0, 0, 1] })
+ .connector('driven', { type: 'axis', origin: { kind: 'vec3', value: [0, 80, 0] }, axis: [0, 0, 1] });
+ for (let i = 0; i < 4; i++) {
+ arm
+ .part(`link${i}`, kcad.box(5, 5, 5))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, i * 20, 0] }, axis: [0, 0, 1] });
+ arm.mate(`j${i}`, `base.j${i}`, `link${i}.axis`, 'revolute', { limitsDeg: [0, 90] });
+ }
+ arm
+ .part('driven-link', kcad.box(5, 5, 5))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 80, 0] }, axis: [0, 0, 1] });
+ arm.mate('driven', 'base.driven', 'driven-link.axis', 'revolute', { limitsDeg: [-45, 2] });
+ arm.coupleMates('driven', { source: 'j0', ratio: -0.5, offset: 2 });
+ const useCase = makePhysicalUseCaseRecord('multi-axis', {
+ actuatorLimits: [
+ { mate: 'j0', maxTorqueNmm: 10 },
+ { mate: 'j1', maxTorqueNmm: 10 },
+ { mate: 'j2', maxTorqueNmm: 10 },
+ { mate: 'j3', maxTorqueNmm: 10 },
+ ],
+ });
+
+ const samples = buildTargetedReachabilitySamples(arm, useCase, {
+ samplesPerMate: 3,
+ maxCombinations: 64,
+ });
+
+ expect(samples).toHaveLength(64);
+ expect(samples.some((sample) =>
+ sample.j0 === 0 && sample.j1 === 90 && sample.j2 === 0 && sample.j3 === 0 && sample.driven === 2,
+ )).toBe(true);
+ expect(samples.some((sample) => sample.j0 !== sample.j1)).toBe(true);
+ expect(samples.every((sample) => sample.driven === 2 - 0.5 * sample.j0)).toBe(true);
+ });
+
+ it('expands declared mate couplings in targeted actuator samples', () => {
+ const { arm, kcad } = makeArm();
+ arm
+ .part('base', kcad.box(10, 10, 10))
+ .connector('drive', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('curl', { type: 'axis', origin: { kind: 'vec3', value: [0, 20, 0] }, axis: [0, 0, 1] });
+ arm
+ .part('driver', kcad.box(5, 5, 5))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm
+ .part('finger', kcad.box(5, 5, 5))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 20, 0] }, axis: [0, 0, 1] });
+ arm.mate('drive', 'base.drive', 'driver.axis', 'revolute', { limitsDeg: [0, 90] });
+ arm.mate('curl', 'base.curl', 'finger.axis', 'revolute', { limitsDeg: [-45, 0] });
+ arm.coupleMates('curl', { source: 'drive', ratio: -0.5, offset: 2 });
+ const useCase = makePhysicalUseCaseRecord('coupled-drive', {
+ actuatorLimits: [{ mate: 'drive', maxTorqueNmm: 10 }],
+ });
+
+ const samples = buildTargetedReachabilitySamples(arm, useCase, { samplesPerMate: 3 });
+
+ expect(samples).toEqual([
+ { drive: 0, curl: 2 },
+ { drive: 45, curl: -20.5 },
+ { drive: 90, curl: -43 },
+ ]);
+ });
+
+ it('rejects contacts that are reachable only at different actuator poses', async () => {
+ const arm = makeRotatingFingerRig([0, 10, 0]);
+
+ const useCase = makePhysicalUseCaseRecord('split-pose-grasp', {
+ contacts: [
+ { a: 'finger.a', b: 'base.target-a', normal: [1, 0, 0], friction: 0.5 },
+ { a: 'finger.b', b: 'base.target-b', normal: [0, 1, 0], friction: 0.5 },
+ ],
+ actuatorLimits: [{ mate: 'yaw', maxTorqueNmm: 10 }],
+ criteria: { maxSlipMm: 0.1 },
+ });
+
+ const issues = await reviewPhysicalUseCaseReachability(arm, useCase, { samplesPerMate: 2 });
+
+ expect(issues).toHaveLength(1);
+ const issue = issues[0];
+ if (!('kind' in issue)) throw new Error('expected simultaneous-contact reachability issue');
+ expect(issue).toMatchObject({
+ kind: 'simultaneous-contacts-unreachable',
+ useCaseName: 'split-pose-grasp',
+ toleranceMm: 0.1,
+ });
+ expect(issue.bestMaxDistanceMm).toBeCloseTo(Math.sqrt(200));
+ expect(issue.contactDistances).toHaveLength(2);
+ expect(issue.contactDistances[0]).toMatchObject({
+ contactA: 'finger.a',
+ contactB: 'base.target-a',
+ distanceMm: 0,
+ });
+ expect(issue.contactDistances[1]).toMatchObject({
+ contactA: 'finger.b',
+ contactB: 'base.target-b',
+ });
+ expect(issue.contactDistances[1].distanceMm).toBeCloseTo(Math.sqrt(200));
+ });
+
+ it('accepts multiple contacts satisfied by one actuator pose', async () => {
+ const arm = makeRotatingFingerRig([10, 0, 0]);
+ const useCase = makePhysicalUseCaseRecord('common-pose-grasp', {
+ contacts: [
+ { a: 'finger.a', b: 'base.target-a', normal: [1, 0, 0], friction: 0.5 },
+ { a: 'finger.b', b: 'base.target-b', normal: [1, 0, 0], friction: 0.5 },
+ ],
+ actuatorLimits: [{ mate: 'yaw', maxTorqueNmm: 10 }],
+ criteria: { maxSlipMm: 0.1 },
+ });
+
+ const assessment = await assessPhysicalUseCaseReachability(arm, useCase, { samplesPerMate: 2 });
+
+ expect(assessment.findings).toEqual([]);
+ expect(assessment.samples).toHaveLength(2);
+ expect(assessment.samples.map((sample) => sample.poses.yaw)).toEqual([0, 90]);
+ expect(assessment.samples.every((sample) => sample.complete)).toBe(true);
+ expect(assessment.commonPoseSamples).toHaveLength(1);
+ expect(assessment.commonPoseSamples[0].poses).toMatchObject({ yaw: 0 });
+ expect(assessment.commonPoseSamples[0].contacts).toEqual([
+ expect.objectContaining({
+ contactA: 'finger.a',
+ contactB: 'base.target-a',
+ pointA: expect.any(Array),
+ pointB: expect.any(Array),
+ distanceMm: 0,
+ }),
+ expect.objectContaining({
+ contactA: 'finger.b',
+ contactB: 'base.target-b',
+ pointA: expect.any(Array),
+ pointB: expect.any(Array),
+ distanceMm: 0,
+ }),
+ ]);
+ });
+
+ it('prefers a specific unreachable contact over an aggregate issue', async () => {
+ const arm = makeRotatingFingerRig([100, 100, 0]);
+ const useCase = makePhysicalUseCaseRecord('partly-unreachable-grasp', {
+ contacts: [
+ { a: 'finger.a', b: 'base.target-a', normal: [1, 0, 0], friction: 0.5 },
+ { a: 'finger.b', b: 'base.target-b', normal: [1, 0, 0], friction: 0.5 },
+ ],
+ actuatorLimits: [{ mate: 'yaw', maxTorqueNmm: 10 }],
+ criteria: { maxSlipMm: 0.1 },
+ });
+
+ const issues = await reviewPhysicalUseCaseReachability(arm, useCase, { samplesPerMate: 2 });
+
+ expect(issues).toHaveLength(1);
+ expect(issues[0]).toMatchObject({
+ useCaseName: 'partly-unreachable-grasp',
+ contactA: 'finger.b',
+ contactB: 'base.target-b',
+ toleranceMm: 0.1,
+ minDistanceMm: expect.any(Number),
+ });
+ expect('kind' in issues[0]).toBe(false);
+ });
+
+ it('does not emit a simultaneous-contact issue for one reachable contact', async () => {
+ const arm = makeRotatingFingerRig([0, 10, 0]);
+ const useCase = makePhysicalUseCaseRecord('single-contact-touch', {
+ contacts: [
+ { a: 'finger.a', b: 'base.target-a', normal: [1, 0, 0], friction: 0.5 },
+ ],
+ actuatorLimits: [{ mate: 'yaw', maxTorqueNmm: 10 }],
+ criteria: { maxSlipMm: 0.1 },
+ });
+
+ const issues = await reviewPhysicalUseCaseReachability(arm, useCase, { samplesPerMate: 2 });
+
+ expect(issues).toEqual([]);
+ });
+
+ it('keeps an unresolved connector as a per-contact reachability issue', async () => {
+ const arm = makeRotatingFingerRig([0, 10, 0]);
+ const useCase = makePhysicalUseCaseRecord('unresolved-contact', {
+ contacts: [
+ { a: 'finger.a', b: 'base.missing', normal: [1, 0, 0], friction: 0.5 },
+ ],
+ actuatorLimits: [{ mate: 'yaw', maxTorqueNmm: 10 }],
+ criteria: { maxSlipMm: 0.1 },
+ });
+
+ const issues = await reviewPhysicalUseCaseReachability(arm, useCase, { samplesPerMate: 2 });
+
+ expect(issues).toEqual([{
+ useCaseName: 'unresolved-contact',
+ contactA: 'finger.a',
+ contactB: 'base.missing',
+ toleranceMm: 0.1,
+ }]);
+ });
+
+ it('leaves contacts uncheckable when every targeted sample has unusable solve status', async () => {
+ const { arm, kcad } = makeArm();
+ arm
+ .part('a', kcad.box(1, 1, 1))
+ .connector('p', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } })
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm
+ .part('b', kcad.box(1, 1, 1))
+ .connector('q', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } })
+ .connector('r', { type: 'frame', origin: { kind: 'vec3', value: [1, 0, 0] } });
+ arm
+ .part('c', kcad.box(1, 1, 1))
+ .connector('s', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } })
+ .connector('t', { type: 'frame', origin: { kind: 'vec3', value: [1, 0, 0] } });
+ arm
+ .part('driver', kcad.box(1, 1, 1))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 10, 0] }, axis: [0, 0, 1] });
+
+ arm.mate('m1', 'a.p', 'b.q', 'fastened');
+ arm.mate('m2', 'b.r', 'c.s', 'fastened');
+ arm.mate('m3', 'c.t', 'a.p', 'fastened');
+ arm.mate('yaw', 'a.axis', 'driver.axis', 'revolute', { limitsDeg: [0, 90] });
+
+ const useCase = makePhysicalUseCaseRecord('bad-loop', {
+ contacts: [{ a: 'a.p', b: 'a.p', normal: [0, 0, 1], friction: 0.5 }],
+ actuatorLimits: [{ mate: 'yaw', maxTorqueNmm: 10 }],
+ criteria: { maxSlipMm: 0 },
+ });
+
+ const issues = await reviewPhysicalUseCaseReachability(arm, useCase);
+
+ expect(issues).toEqual([
+ {
+ useCaseName: 'bad-loop',
+ contactA: 'a.p',
+ contactB: 'a.p',
+ toleranceMm: 0,
+ },
+ ]);
+ });
+});
diff --git a/src/modeling/mates/physicalUseCaseReachability.ts b/src/modeling/mates/physicalUseCaseReachability.ts
new file mode 100644
index 000000000..e3c6aac58
--- /dev/null
+++ b/src/modeling/mates/physicalUseCaseReachability.ts
@@ -0,0 +1,316 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+import type { Assembly } from '../capture/assembly';
+import type { NumericPoses } from '../capture/forwardKinematics';
+import type { Vec3 } from '../../shared/intent/types';
+import type { Transform } from '../../shared/runtime/se3';
+import { expandCoupledPoses } from './coupledPoses';
+import { solveMates } from './solver';
+import { parseConnectorRef } from './mate';
+import type { PhysicalUseCaseRecord } from './physicalUseCase';
+
+export interface PhysicalUseCaseReachabilityOptions {
+ readonly samplesPerMate?: number;
+ readonly maxCombinations?: number;
+}
+
+export interface PhysicalUseCaseReachabilityIssue {
+ readonly useCaseName: string;
+ readonly contactA: string;
+ readonly contactB: string;
+ readonly minDistanceMm?: number;
+ readonly toleranceMm: number;
+}
+
+export interface PhysicalUseCaseReachabilityContactDistance {
+ readonly contactA: string;
+ readonly contactB: string;
+ readonly distanceMm?: number;
+}
+
+export interface PhysicalUseCaseSimultaneousContactsReachabilityIssue {
+ readonly kind: 'simultaneous-contacts-unreachable';
+ readonly useCaseName: string;
+ readonly toleranceMm: number;
+ readonly bestMaxDistanceMm?: number;
+ readonly contactDistances: readonly PhysicalUseCaseReachabilityContactDistance[];
+}
+
+export type PhysicalUseCaseReachabilityFinding =
+ | PhysicalUseCaseReachabilityIssue
+ | PhysicalUseCaseSimultaneousContactsReachabilityIssue;
+
+export interface PhysicalUseCaseSolvedContact {
+ readonly contactA: string;
+ readonly contactB: string;
+ readonly pointA: Vec3;
+ readonly pointB: Vec3;
+ readonly distanceMm: number;
+}
+
+export interface PhysicalUseCasePoseWitness {
+ readonly poses: NumericPoses;
+ readonly transforms: ReadonlyMap;
+ readonly contacts: readonly PhysicalUseCaseSolvedContact[];
+ readonly complete: boolean;
+ readonly maxDistanceMm?: number;
+}
+
+export interface PhysicalUseCaseReachabilityAssessment {
+ readonly findings: readonly PhysicalUseCaseReachabilityFinding[];
+ readonly samples: readonly PhysicalUseCasePoseWitness[];
+ readonly commonPoseSamples: readonly PhysicalUseCasePoseWitness[];
+}
+
+export async function reviewPhysicalUseCaseReachability(
+ arm: Assembly,
+ useCase: PhysicalUseCaseRecord,
+ opts: PhysicalUseCaseReachabilityOptions = {},
+): Promise {
+ return [...(await assessPhysicalUseCaseReachability(arm, useCase, opts)).findings];
+}
+
+export async function assessPhysicalUseCaseReachability(
+ arm: Assembly,
+ useCase: PhysicalUseCaseRecord,
+ opts: PhysicalUseCaseReachabilityOptions = {},
+): Promise {
+ const samples = buildTargetedReachabilitySamples(arm, useCase, opts);
+ const contactDistances = new Map();
+ const toleranceMm = useCase.criteria?.maxSlipMm ?? 0;
+ const solvedSamples: PhysicalUseCasePoseWitness[] = [];
+ const commonPoseSamples: PhysicalUseCasePoseWitness[] = [];
+ let bestCommonPose: {
+ maxDistanceMm: number;
+ contactDistances: PhysicalUseCaseReachabilityContactDistance[];
+ } | undefined;
+
+ for (const contact of useCase.contacts) {
+ contactDistances.set(contactKey(contact.a, contact.b), {
+ contactA: contact.a,
+ contactB: contact.b,
+ });
+ }
+
+ for (const poses of samples) {
+ let solved: Awaited>;
+ try {
+ solved = await solveMates(arm, poses);
+ } catch {
+ continue;
+ }
+ if (solved.status !== 'solved' && solved.status !== 'redundant-ok') continue;
+ const solvedContacts: PhysicalUseCaseSolvedContact[] = [];
+ let sampleComplete = true;
+ for (const contact of useCase.contacts) {
+ const a = connectorWorldPoint(arm, solved.poses, contact.a);
+ const b = connectorWorldPoint(arm, solved.poses, contact.b);
+ if (a === undefined || b === undefined) {
+ sampleComplete = false;
+ continue;
+ }
+
+ const key = contactKey(contact.a, contact.b);
+ const entry = contactDistances.get(key);
+ if (entry === undefined) continue;
+ const distance = distanceMm(a, b);
+ solvedContacts.push({
+ contactA: contact.a,
+ contactB: contact.b,
+ pointA: a,
+ pointB: b,
+ distanceMm: distance,
+ });
+ contactDistances.set(key, {
+ ...entry,
+ minDistanceMm: entry.minDistanceMm === undefined
+ ? distance
+ : Math.min(entry.minDistanceMm, distance),
+ });
+ }
+
+ const complete = sampleComplete && solvedContacts.length === useCase.contacts.length;
+ const maxDistanceMm = complete
+ ? solvedContacts.reduce((maxDistance, contact) => Math.max(maxDistance, contact.distanceMm), 0)
+ : undefined;
+ const witness: PhysicalUseCasePoseWitness = {
+ poses: { ...poses },
+ transforms: solved.poses,
+ contacts: solvedContacts,
+ complete,
+ ...(maxDistanceMm === undefined ? {} : { maxDistanceMm }),
+ };
+ solvedSamples.push(witness);
+ if (!complete || maxDistanceMm === undefined) continue;
+ const evidence = solvedContacts.map((contact) => ({
+ contactA: contact.contactA,
+ contactB: contact.contactB,
+ distanceMm: contact.distanceMm,
+ }));
+ if (bestCommonPose === undefined || maxDistanceMm < bestCommonPose.maxDistanceMm) {
+ bestCommonPose = { maxDistanceMm, contactDistances: evidence };
+ }
+ if (maxDistanceMm <= toleranceMm) commonPoseSamples.push(witness);
+ }
+
+ const contactIssues: PhysicalUseCaseReachabilityIssue[] = [...contactDistances.values()]
+ .filter((entry) => entry.minDistanceMm === undefined || entry.minDistanceMm > toleranceMm)
+ .map((entry) => ({
+ useCaseName: useCase.name,
+ contactA: entry.contactA,
+ contactB: entry.contactB,
+ ...(entry.minDistanceMm === undefined ? {} : { minDistanceMm: entry.minDistanceMm }),
+ toleranceMm,
+ }));
+ if (contactIssues.length > 0 || useCase.contacts.length < 2 || commonPoseSamples.length > 0) {
+ return { findings: contactIssues, samples: solvedSamples, commonPoseSamples };
+ }
+
+ const findings: PhysicalUseCaseReachabilityFinding[] = [{
+ kind: 'simultaneous-contacts-unreachable',
+ useCaseName: useCase.name,
+ toleranceMm,
+ ...(bestCommonPose === undefined ? {} : { bestMaxDistanceMm: bestCommonPose.maxDistanceMm }),
+ contactDistances: bestCommonPose?.contactDistances ?? useCase.contacts.map((contact) => ({
+ contactA: contact.a,
+ contactB: contact.b,
+ })),
+ }];
+ return { findings, samples: solvedSamples, commonPoseSamples };
+}
+
+export function buildTargetedReachabilitySamples(
+ arm: Assembly,
+ useCase: PhysicalUseCaseRecord,
+ opts: PhysicalUseCaseReachabilityOptions,
+): NumericPoses[] {
+ const samplesPerMate = Math.max(1, Math.floor(opts.samplesPerMate ?? 3));
+ const maxCombinations = Math.max(1, Math.floor(opts.maxCombinations ?? 64));
+ const matesByName = new Map(arm.__mates().map((mate) => [mate.name, mate]));
+ const mateSamples: Array<{ mateName: string; values: readonly number[] }> = [];
+ const seen = new Set();
+
+ for (const limit of useCase.actuatorLimits) {
+ if (seen.has(limit.mate)) continue;
+ seen.add(limit.mate);
+ const mate = matesByName.get(limit.mate);
+ const limits = scalarSampleLimits(mate);
+ if (mate === undefined || limits === undefined) continue;
+ mateSamples.push({
+ mateName: mate.name,
+ values: sampleRange(limits, samplesPerMate),
+ });
+ }
+
+ if (mateSamples.length === 0) return [];
+
+ const totalCombinations = mateSamples.reduce((product, sample) => product * sample.values.length, 1);
+ if (totalCombinations > maxCombinations) {
+ return buildDistributedCappedSamples(mateSamples, maxCombinations)
+ .map((poses) => expandCoupledPoses(arm.__mates(), arm.__mateCouplings(), poses));
+ }
+
+ const samples: NumericPoses[] = [];
+ const visit = (index: number, current: NumericPoses): void => {
+ if (index === mateSamples.length) {
+ samples.push(expandCoupledPoses(arm.__mates(), arm.__mateCouplings(), current));
+ return;
+ }
+ const sample = mateSamples[index];
+ for (const value of sample.values) {
+ current[sample.mateName] = value;
+ visit(index + 1, current);
+ }
+ delete current[sample.mateName];
+ };
+ visit(0, {});
+ return samples;
+}
+
+function scalarSampleLimits(
+ mate: ReturnType[number] | undefined,
+): readonly [number, number] | undefined {
+ if (mate === undefined) return undefined;
+ if (
+ (mate.type === 'revolute' || mate.type === 'cylindrical' || mate.type === 'pin_slot') &&
+ mate.limitsDeg !== undefined
+ ) {
+ return mate.limitsDeg;
+ }
+ if (mate.type === 'prismatic' && mate.limitsMm !== undefined) {
+ return mate.limitsMm;
+ }
+ return undefined;
+}
+
+function buildDistributedCappedSamples(
+ mateSamples: ReadonlyArray<{ mateName: string; values: readonly number[] }>,
+ maxCombinations: number,
+): NumericPoses[] {
+ const sampleCount = Math.max(1, maxCombinations);
+ const totalCombinations = mateSamples.reduce((product, sample) => product * sample.values.length, 1);
+ const out: NumericPoses[] = [];
+ for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex++) {
+ const flatIndex = sampleCount === 1
+ ? 0
+ : Math.round((sampleIndex * (totalCombinations - 1)) / (sampleCount - 1));
+ out.push(flattenedIndexToPose(mateSamples, flatIndex));
+ }
+ return out;
+}
+
+function flattenedIndexToPose(
+ mateSamples: ReadonlyArray<{ mateName: string; values: readonly number[] }>,
+ flatIndex: number,
+): NumericPoses {
+ let remaining = flatIndex;
+ const indices = new Array(mateSamples.length).fill(0);
+ for (let i = mateSamples.length - 1; i >= 0; i--) {
+ const radix = mateSamples[i].values.length;
+ indices[i] = remaining % radix;
+ remaining = Math.floor(remaining / radix);
+ }
+ const pose: NumericPoses = {};
+ for (let i = 0; i < mateSamples.length; i++) {
+ pose[mateSamples[i].mateName] = mateSamples[i].values[indices[i]];
+ }
+ return pose;
+}
+
+function sampleRange(limits: readonly [number, number], samplesPerMate: number): readonly number[] {
+ const [min, max] = limits;
+ if (samplesPerMate <= 1 || min === max) return [min];
+ if (samplesPerMate === 2) return [min, max];
+
+ const values: number[] = [];
+ for (let i = 0; i < samplesPerMate; i++) {
+ const t = i / (samplesPerMate - 1);
+ values.push(min + (max - min) * t);
+ }
+ return values;
+}
+
+function connectorWorldPoint(
+ arm: Assembly,
+ partTransforms: ReadonlyMap,
+ ref: string,
+): Vec3 | undefined {
+ try {
+ const parsed = parseConnectorRef(ref);
+ const part = arm.__parts().find((candidate) => candidate.name === parsed.partName);
+ const transform = partTransforms.get(parsed.partName);
+ const connector = part?.mateConnectors.find((candidate) => candidate.name === parsed.connectorName);
+ if (connector?.origin.kind !== 'vec3' || transform === undefined) return undefined;
+ return [...transform.point(connector.origin.value)] as Vec3;
+ } catch {
+ return undefined;
+ }
+}
+
+function contactKey(contactA: string, contactB: string): string {
+ return `${contactA}\n${contactB}`;
+}
+
+function distanceMm(a: Vec3, b: Vec3): number {
+ return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
+}
diff --git a/src/modeling/mates/physicalUseCaseStatics.test.ts b/src/modeling/mates/physicalUseCaseStatics.test.ts
new file mode 100644
index 000000000..60c9c7409
--- /dev/null
+++ b/src/modeling/mates/physicalUseCaseStatics.test.ts
@@ -0,0 +1,478 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+import { describe, expect, it } from 'vitest';
+import { CaptureSession } from '../capture/captureSession';
+import { createApi } from '../api';
+import type { Assembly } from '../capture/assembly';
+import { makePhysicalUseCaseRecord, type PhysicalUseCaseRecord } from './physicalUseCase';
+import { assessPhysicalUseCaseReachability } from './physicalUseCaseReachability';
+import { reviewPhysicalUseCaseStatics } from './physicalUseCaseStatics';
+
+function makeSymmetricHoldRig(opts: {
+ loadAt?: string;
+ loadForce?: readonly [number, number, number];
+ loadTorque?: readonly [number, number, number];
+ maxTorqueNmm?: number;
+ coupleRight?: boolean;
+ transmissionRatio?: number;
+ omitRightActuator?: boolean;
+} = {}): { arm: Assembly; useCase: PhysicalUseCaseRecord } {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ const arm = kcad.assembly('symmetric hold rig');
+
+ arm
+ .part('base', kcad.box(50, 20, 8))
+ .connector('left-axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [-20, 0, 0] },
+ axis: [0, 1, 0],
+ })
+ .connector('right-axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [20, 0, 0] },
+ axis: [0, 1, 0],
+ });
+ arm
+ .part('left-finger', kcad.box(10, 4, 4))
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [-20, 0, 0] },
+ axis: [0, 1, 0],
+ })
+ .connector('tip', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [-10, 0, 0] },
+ });
+ arm
+ .part('right-finger', kcad.box(10, 4, 4))
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [20, 0, 0] },
+ axis: [0, 1, 0],
+ })
+ .connector('tip', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [10, 0, 0] },
+ });
+ arm
+ .part('held', kcad.box(20, 10, 10), { role: 'contact-target' })
+ .connector('center', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ })
+ .connector('left-contact', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [-10, 0, 0] },
+ })
+ .connector('right-contact', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [10, 0, 0] },
+ });
+
+ arm.mate('left-curl', 'base.left-axis', 'left-finger.axis', 'revolute', { limitsDeg: [0, 1] });
+ arm.mate('right-curl', 'base.right-axis', 'right-finger.axis', 'revolute', { limitsDeg: [0, 1] });
+ if (opts.coupleRight === true) {
+ arm.coupleMates('right-curl', { source: 'left-curl', ratio: 1 });
+ if (opts.transmissionRatio !== undefined) {
+ arm.transmission('finger-coupling', {
+ kind: 'link-rod',
+ sourceMate: 'left-curl',
+ drivenMates: ['right-curl'],
+ path: ['left-finger', 'base', 'right-finger'],
+ ratio: opts.transmissionRatio,
+ });
+ }
+ }
+
+ const maxTorqueNmm = opts.maxTorqueNmm ?? 100;
+ const useCase = makePhysicalUseCaseRecord('symmetric-hold', {
+ stableParts: ['base'],
+ loads: [{
+ part: 'held',
+ ...(opts.loadAt === undefined ? {} : { at: opts.loadAt }),
+ force: opts.loadForce ?? [0, 0, -6],
+ ...(opts.loadTorque === undefined ? {} : { torque: opts.loadTorque }),
+ }],
+ contacts: [
+ {
+ a: 'left-finger.tip',
+ b: 'held.left-contact',
+ normal: [-1, 0, 0],
+ friction: 0.5,
+ normalForceN: 8,
+ },
+ {
+ a: 'right-finger.tip',
+ b: 'held.right-contact',
+ normal: [1, 0, 0],
+ friction: 0.5,
+ normalForceN: 8,
+ },
+ ],
+ actuatorLimits: opts.coupleRight === true
+ ? [{ mate: 'left-curl', maxTorqueNmm }]
+ : opts.omitRightActuator === true
+ ? [{ mate: 'left-curl', maxTorqueNmm }]
+ : [
+ { mate: 'left-curl', maxTorqueNmm },
+ { mate: 'right-curl', maxTorqueNmm },
+ ],
+ criteria: {
+ maxSlipMm: 0.01,
+ maxForceResidualN: 0.01,
+ maxTorqueResidualNmm: 0.1,
+ },
+ });
+ return { arm, useCase };
+}
+
+function makeOffsetContactRig(): { arm: Assembly; useCase: PhysicalUseCaseRecord } {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ const arm = kcad.assembly('offset contact rig');
+
+ arm
+ .part('base', kcad.box(30, 20, 8))
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [20, 0, 0] },
+ axis: [0, 1, 0],
+ });
+ arm
+ .part('finger', kcad.box(10, 4, 4))
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [20, 0, 0] },
+ axis: [0, 1, 0],
+ })
+ .connector('tip', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [10, 0, 0] },
+ });
+ arm
+ .part('held', kcad.box(20, 10, 10), { role: 'contact-target' })
+ .connector('center', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ })
+ .connector('contact', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [10, 0, 0] },
+ });
+ arm.mate('curl', 'base.axis', 'finger.axis', 'revolute', { limitsDeg: [0, 1] });
+
+ const useCase = makePhysicalUseCaseRecord('offset-hold', {
+ stableParts: ['base'],
+ loads: [{ part: 'held', at: 'held.center', force: [0, 0, -1] }],
+ contacts: [{
+ a: 'finger.tip',
+ b: 'held.contact',
+ normal: [0, 0, -1],
+ friction: 0.2,
+ normalForceN: 2,
+ }],
+ actuatorLimits: [{ mate: 'curl', maxTorqueNmm: 100 }],
+ criteria: {
+ maxSlipMm: 0.01,
+ maxForceResidualN: 0.01,
+ maxTorqueResidualNmm: 0.1,
+ },
+ });
+ return { arm, useCase };
+}
+
+function makeRotatedNormalRig(): { arm: Assembly; useCase: PhysicalUseCaseRecord } {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ const arm = kcad.assembly('rotated normal rig');
+
+ arm
+ .part('base', kcad.box(20, 20, 8))
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+ });
+ arm
+ .part('finger', kcad.box(10, 4, 4))
+ .connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+ })
+ .connector('tip', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [10, 0, 0] },
+ });
+ arm
+ .part('held', kcad.box(4, 4, 4), { role: 'contact-target' })
+ .connector('contact', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [0, 10, 0] },
+ });
+ arm.mate('turn', 'base.axis', 'finger.axis', 'revolute', { limitsDeg: [90, 91] });
+
+ const useCase = makePhysicalUseCaseRecord('rotated-normal-hold', {
+ stableParts: ['base'],
+ loads: [{ part: 'held', at: 'held.contact', force: [-1, 0, 0] }],
+ contacts: [{
+ a: 'finger.tip',
+ b: 'held.contact',
+ normal: [0, 1, 0],
+ normalFrame: 'a',
+ friction: 0.2,
+ normalForceN: 2,
+ }],
+ actuatorLimits: [{ mate: 'turn', maxTorqueNmm: 20 }],
+ criteria: {
+ maxSlipMm: 0.01,
+ maxForceResidualN: 0.01,
+ maxTorqueResidualNmm: 0.1,
+ },
+ });
+ return { arm, useCase };
+}
+
+async function reviewRig(arm: Assembly, useCase: PhysicalUseCaseRecord) {
+ const reachability = await assessPhysicalUseCaseReachability(arm, useCase, { samplesPerMate: 1 });
+ expect(reachability.findings).toEqual([]);
+ expect(reachability.commonPoseSamples.length).toBeGreaterThan(0);
+ return reviewPhysicalUseCaseStatics(arm, useCase, reachability.commonPoseSamples);
+}
+
+describe('physical use case statics', () => {
+ it('rejects a force load without an explicit application connector', async () => {
+ const { arm, useCase } = makeSymmetricHoldRig();
+
+ const result = await reviewRig(arm, useCase);
+
+ expect(result.certificates).toEqual([]);
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ kind: 'static-input-incomplete',
+ useCaseName: 'symmetric-hold',
+ }),
+ ]);
+ });
+
+ it('rejects a supplied non-finite load vector instead of treating it as zero', async () => {
+ const { arm, useCase } = makeSymmetricHoldRig({
+ loadAt: 'held.center',
+ loadTorque: [Number.NaN, 0, 0],
+ });
+
+ const result = await reviewRig(arm, useCase);
+
+ expect(result.certificates).toEqual([]);
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ kind: 'static-input-incomplete',
+ useCaseName: 'symmetric-hold',
+ message: expect.stringMatching(/finite Vec3/),
+ }),
+ ]);
+ });
+
+ it('certifies a pure applied torque when load.at supplies the reference point', async () => {
+ const { arm, useCase } = makeSymmetricHoldRig({
+ loadAt: 'held.center',
+ loadForce: [0, 0, 0],
+ loadTorque: [0, 0, 4],
+ });
+
+ const result = await reviewRig(arm, useCase);
+
+ expect(result.issues).toEqual([]);
+ expect(result.certificates).toHaveLength(1);
+ expect(result.certificates[0].forceResidualN).toBeLessThanOrEqual(0.01);
+ expect(result.certificates[0].torqueResidualNmm).toBeLessThanOrEqual(0.1);
+ });
+
+ it('rejects coupled motion without declared physical transmission evidence', async () => {
+ const { arm, useCase } = makeSymmetricHoldRig({
+ loadAt: 'held.center',
+ coupleRight: true,
+ });
+
+ const result = await reviewRig(arm, useCase);
+
+ expect(result.certificates).toEqual([]);
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ kind: 'static-input-incomplete',
+ message: expect.stringMatching(/arm\.transmission/),
+ }),
+ ]);
+ });
+
+ it('rejects transmission evidence whose ratio contradicts the kinematic coupling', async () => {
+ const { arm, useCase } = makeSymmetricHoldRig({
+ loadAt: 'held.center',
+ coupleRight: true,
+ transmissionRatio: 2,
+ });
+
+ const result = await reviewRig(arm, useCase);
+
+ expect(result.certificates).toEqual([]);
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ kind: 'static-input-incomplete',
+ message: expect.stringMatching(/ratio/),
+ }),
+ ]);
+ });
+
+ it('rejects duplicate connector pairs instead of multiplying contact capacity', async () => {
+ const { arm, useCase } = makeSymmetricHoldRig({ loadAt: 'held.center' });
+ const duplicatedUseCase: PhysicalUseCaseRecord = {
+ ...useCase,
+ contacts: [...useCase.contacts, { ...useCase.contacts[0] }],
+ };
+
+ const result = await reviewRig(arm, duplicatedUseCase);
+
+ expect(result.certificates).toEqual([]);
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ kind: 'static-input-incomplete',
+ message: expect.stringMatching(/duplicate contact/i),
+ }),
+ ]);
+ });
+
+ it('rejects an independent contact-path hinge with no actuator limit', async () => {
+ const { arm, useCase } = makeSymmetricHoldRig({
+ loadAt: 'held.center',
+ omitRightActuator: true,
+ });
+
+ const result = await reviewRig(arm, useCase);
+
+ expect(result.certificates).toEqual([]);
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ kind: 'static-input-incomplete',
+ message: expect.stringMatching(/right-curl.*actuatorLimits/),
+ }),
+ ]);
+ });
+
+ it('rejects force balance that leaves an unbalanced moment', async () => {
+ const { arm, useCase } = makeOffsetContactRig();
+
+ const result = await reviewRig(arm, useCase);
+
+ expect(result.certificates).toEqual([]);
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ kind: 'static-equilibrium-unmet',
+ useCaseName: 'offset-hold',
+ bestForceResidualN: expect.any(Number),
+ bestTorqueResidualNmm: expect.any(Number),
+ }),
+ ]);
+ const issue = result.issues[0];
+ if (issue.kind !== 'static-equilibrium-unmet') throw new Error('expected equilibrium issue');
+ expect(issue.bestForceResidualN!).toBeGreaterThan(0.1);
+ expect(issue.bestTorqueResidualNmm!).toBeGreaterThan(1);
+ });
+
+ it('certifies a symmetric contact allocation that balances force and moment', async () => {
+ const { arm, useCase } = makeSymmetricHoldRig({ loadAt: 'held.center' });
+
+ const result = await reviewRig(arm, useCase);
+
+ expect(result.issues).toEqual([]);
+ expect(result.certificates).toHaveLength(1);
+ expect(result.certificates[0]).toMatchObject({
+ useCaseName: 'symmetric-hold',
+ heldPart: 'held',
+ forceResidualN: expect.any(Number),
+ torqueResidualNmm: expect.any(Number),
+ contactForces: expect.arrayContaining([
+ expect.objectContaining({
+ contactA: 'left-finger.tip',
+ contactB: 'held.left-contact',
+ pointWorldMm: [-10, 0, 0],
+ mechanismPart: 'left-finger',
+ forceOnHeldWorldN: expect.any(Array),
+ }),
+ expect.objectContaining({
+ contactA: 'right-finger.tip',
+ contactB: 'held.right-contact',
+ pointWorldMm: [10, 0, 0],
+ mechanismPart: 'right-finger',
+ forceOnHeldWorldN: expect.any(Array),
+ }),
+ ]),
+ });
+ expect(result.certificates[0].forceResidualN).toBeLessThanOrEqual(0.01);
+ expect(result.certificates[0].torqueResidualNmm).toBeLessThanOrEqual(0.1);
+ expect(
+ result.certificates[0].contactForces.reduce(
+ (sum, contact) => sum + contact.forceOnHeldWorldN[2],
+ 0,
+ ),
+ ).toBeCloseTo(6, 2);
+ });
+
+ it('rotates a contact normal from its endpoint frame into world space', async () => {
+ const { arm, useCase } = makeRotatedNormalRig();
+
+ const result = await reviewRig(arm, useCase);
+
+ expect(result.issues).toEqual([]);
+ expect(result.certificates).toHaveLength(1);
+ expect(result.certificates[0].contactForces[0].mechanismPart).toBe('finger');
+ expect(result.certificates[0].contactForces[0].pointWorldMm[0]).toBeCloseTo(0, 8);
+ expect(result.certificates[0].contactForces[0].pointWorldMm[1]).toBeCloseTo(10, 8);
+ expect(result.certificates[0].contactForces[0].pointWorldMm[2]).toBeCloseTo(0, 8);
+ expect(result.certificates[0].contactForces[0].forceOnHeldWorldN[0]).toBeCloseTo(1, 2);
+ expect(Math.abs(result.certificates[0].contactForces[0].forceOnHeldWorldN[1])).toBeLessThan(0.01);
+ expect(result.certificates[0].actuatorTorques[0]).toMatchObject({
+ mateName: 'turn',
+ maxTorqueNmm: 20,
+ });
+ expect(result.certificates[0].actuatorTorques[0].requiredTorqueNmm).toBeCloseTo(10, 1);
+ });
+
+ it('rejects a balanced allocation that exceeds actuator torque limits', async () => {
+ const { arm, useCase } = makeSymmetricHoldRig({
+ loadAt: 'held.center',
+ maxTorqueNmm: 25,
+ });
+
+ const result = await reviewRig(arm, useCase);
+
+ expect(result.certificates).toEqual([]);
+ expect(result.issues).toEqual([
+ expect.objectContaining({
+ kind: 'static-actuator-torque-insufficient',
+ useCaseName: 'symmetric-hold',
+ actuatorTorques: expect.arrayContaining([
+ expect.objectContaining({ mateName: 'left-curl', maxTorqueNmm: 25 }),
+ expect.objectContaining({ mateName: 'right-curl', maxTorqueNmm: 25 }),
+ ]),
+ }),
+ ]);
+ });
+
+ it('certifies the same allocation when actuator torque limits are sufficient', async () => {
+ const { arm, useCase } = makeSymmetricHoldRig({
+ loadAt: 'held.center',
+ maxTorqueNmm: 35,
+ });
+
+ const result = await reviewRig(arm, useCase);
+
+ expect(result.issues).toEqual([]);
+ expect(result.certificates).toHaveLength(1);
+ expect(result.certificates[0].actuatorTorques).toHaveLength(2);
+ for (const actuator of result.certificates[0].actuatorTorques) {
+ expect(actuator.requiredTorqueNmm).toBeCloseTo(30, 1);
+ expect(actuator.maxTorqueNmm).toBe(35);
+ }
+ });
+});
diff --git a/src/modeling/mates/physicalUseCaseStatics.ts b/src/modeling/mates/physicalUseCaseStatics.ts
new file mode 100644
index 000000000..60a73ca6c
--- /dev/null
+++ b/src/modeling/mates/physicalUseCaseStatics.ts
@@ -0,0 +1,1009 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+import type { Assembly } from '../capture/assembly';
+import type { NumericPoses } from '../capture/forwardKinematics';
+import type { Vec3 } from '../../shared/intent/types';
+import type { Transform } from '../../shared/runtime/se3';
+import { expandCoupledPoses } from './coupledPoses';
+import { parseConnectorRef } from './mate';
+import { solveMates } from './solver';
+import type {
+ PhysicalUseCaseContact,
+ PhysicalUseCaseRecord,
+} from './physicalUseCase';
+import type { PhysicalUseCasePoseWitness } from './physicalUseCaseReachability';
+
+export const DEFAULT_FORCE_RESIDUAL_N = 0.01;
+export const DEFAULT_TORQUE_RESIDUAL_NMM = 0.1;
+const FRICTION_PYRAMID_EDGE_COUNT = 8;
+const MAX_PROJECTED_GRADIENT_ITERATIONS = 12_000;
+const ACTUATOR_JACOBIAN_STEP_RAD = 1e-4;
+
+export interface PhysicalUseCaseStaticInputIssue {
+ readonly kind: 'static-input-incomplete';
+ readonly useCaseName: string;
+ readonly message: string;
+}
+
+export interface PhysicalUseCaseStaticEquilibriumIssue {
+ readonly kind: 'static-equilibrium-unmet';
+ readonly useCaseName: string;
+ readonly bestPoses?: NumericPoses;
+ readonly bestForceResidualN?: number;
+ readonly bestTorqueResidualNmm?: number;
+}
+
+export interface PhysicalUseCaseStaticActuatorTorqueIssue {
+ readonly kind: 'static-actuator-torque-insufficient';
+ readonly useCaseName: string;
+ readonly bestPoses?: NumericPoses;
+ readonly actuatorTorques: readonly PhysicalUseCaseStaticActuatorTorqueEvidence[];
+}
+
+export type PhysicalUseCaseStaticIssue =
+ | PhysicalUseCaseStaticInputIssue
+ | PhysicalUseCaseStaticEquilibriumIssue
+ | PhysicalUseCaseStaticActuatorTorqueIssue;
+
+export interface PhysicalUseCaseStaticContactForce {
+ readonly contactA: string;
+ readonly contactB: string;
+ readonly pointWorldMm: Vec3;
+ readonly mechanismPart: string;
+ readonly forceOnHeldWorldN: Vec3;
+ readonly normalForceN: number;
+ readonly tangentialForceN: number;
+ readonly normalCapacityN: number;
+ readonly friction: number;
+}
+
+export interface PhysicalUseCaseStaticActuatorTorqueEvidence {
+ readonly mateName: string;
+ readonly requiredTorqueNmm: number;
+ readonly maxTorqueNmm: number;
+}
+
+export interface PhysicalUseCaseStaticCertificate {
+ readonly useCaseName: string;
+ readonly heldPart: string;
+ readonly poses: NumericPoses;
+ readonly forceResidualN: number;
+ readonly torqueResidualNmm: number;
+ readonly contactForces: readonly PhysicalUseCaseStaticContactForce[];
+ readonly actuatorTorques: readonly PhysicalUseCaseStaticActuatorTorqueEvidence[];
+}
+
+export interface PhysicalUseCaseStaticsResult {
+ readonly issues: readonly PhysicalUseCaseStaticIssue[];
+ readonly certificates: readonly PhysicalUseCaseStaticCertificate[];
+}
+
+interface ResolvedLoad {
+ readonly force: Vec3;
+ readonly torque: Vec3;
+ readonly point?: Vec3;
+}
+
+interface ResolvedContact {
+ readonly contact: PhysicalUseCaseContact;
+ readonly point: Vec3;
+ readonly heldNormal: Vec3;
+ readonly generators: readonly Vec3[];
+ readonly capN: number;
+ readonly heldRef: string;
+ readonly mechanismRef: string;
+}
+
+interface ResolvedActuator {
+ readonly mateName: string;
+ readonly maxTorqueNmm: number;
+ readonly coefficients: readonly number[];
+}
+
+interface ResolvedStaticSample {
+ readonly heldPart: string;
+ readonly poses: NumericPoses;
+ readonly referencePoint: Vec3;
+ readonly externalForce: Vec3;
+ readonly externalTorque: Vec3;
+ readonly contacts: readonly ResolvedContact[];
+ readonly actuators: readonly ResolvedActuator[];
+ readonly forceToleranceN: number;
+ readonly torqueToleranceNmm: number;
+}
+
+interface SearchCandidate {
+ readonly weights: readonly number[];
+ readonly contactForces: readonly PhysicalUseCaseStaticContactForce[];
+ readonly forceResidualN: number;
+ readonly torqueResidualNmm: number;
+ readonly normalizedResidual: number;
+ readonly actuatorTorques: readonly PhysicalUseCaseStaticActuatorTorqueEvidence[];
+}
+
+export async function reviewPhysicalUseCaseStatics(
+ arm: Assembly,
+ useCase: PhysicalUseCaseRecord,
+ witnesses: readonly PhysicalUseCasePoseWitness[],
+): Promise {
+ if (witnesses.length === 0) {
+ return {
+ certificates: [],
+ issues: [{
+ kind: 'static-input-incomplete',
+ useCaseName: useCase.name,
+ message: 'No complete common-contact pose witness was provided for static review.',
+ }],
+ };
+ }
+
+ let best: { poses: NumericPoses; forceResidualN: number; torqueResidualNmm: number; score: number } | undefined;
+ let bestActuator: {
+ poses: NumericPoses;
+ actuatorTorques: readonly PhysicalUseCaseStaticActuatorTorqueEvidence[];
+ violation: number;
+ } | undefined;
+ let equilibriumFound = false;
+ for (const witness of witnesses) {
+ const resolved = await resolveStaticSample(arm, useCase, witness);
+ if (typeof resolved === 'string') {
+ return {
+ certificates: [],
+ issues: [{ kind: 'static-input-incomplete', useCaseName: useCase.name, message: resolved }],
+ };
+ }
+
+ const contactCandidate = searchContactAllocation(resolved, false);
+ if (
+ best === undefined ||
+ contactCandidate.normalizedResidual < best.score
+ ) {
+ best = {
+ poses: { ...resolved.poses },
+ forceResidualN: contactCandidate.forceResidualN,
+ torqueResidualNmm: contactCandidate.torqueResidualNmm,
+ score: contactCandidate.normalizedResidual,
+ };
+ }
+
+ if (!isVerifiedContactCertificate(resolved, contactCandidate)) continue;
+ equilibriumFound = true;
+ const candidate = resolved.actuators.length === 0
+ ? contactCandidate
+ : searchContactAllocation(resolved, true);
+ const actuatorViolation = totalActuatorViolation(candidate.actuatorTorques);
+ if (bestActuator === undefined || actuatorViolation < bestActuator.violation) {
+ bestActuator = {
+ poses: { ...resolved.poses },
+ actuatorTorques: candidate.actuatorTorques,
+ violation: actuatorViolation,
+ };
+ }
+ if (!isVerifiedContactCertificate(resolved, candidate) || !areActuatorsWithinLimits(candidate)) continue;
+ return {
+ issues: [],
+ certificates: [{
+ useCaseName: useCase.name,
+ heldPart: resolved.heldPart,
+ poses: { ...resolved.poses },
+ forceResidualN: candidate.forceResidualN,
+ torqueResidualNmm: candidate.torqueResidualNmm,
+ contactForces: candidate.contactForces,
+ actuatorTorques: candidate.actuatorTorques,
+ }],
+ };
+ }
+
+ if (equilibriumFound) {
+ return {
+ certificates: [],
+ issues: [{
+ kind: 'static-actuator-torque-insufficient',
+ useCaseName: useCase.name,
+ ...(bestActuator === undefined ? {} : { bestPoses: bestActuator.poses }),
+ actuatorTorques: bestActuator?.actuatorTorques ?? [],
+ }],
+ };
+ }
+
+ return {
+ certificates: [],
+ issues: [{
+ kind: 'static-equilibrium-unmet',
+ useCaseName: useCase.name,
+ ...(best === undefined ? {} : {
+ bestPoses: best.poses,
+ bestForceResidualN: best.forceResidualN,
+ bestTorqueResidualNmm: best.torqueResidualNmm,
+ }),
+ }],
+ };
+}
+
+async function resolveStaticSample(
+ arm: Assembly,
+ useCase: PhysicalUseCaseRecord,
+ witness: PhysicalUseCasePoseWitness,
+): Promise {
+ const heldParts = [...new Set(useCase.loads.map((load) => load.part))];
+ if (heldParts.length !== 1) {
+ return 'Static equilibrium v1 requires every load to act on one held part.';
+ }
+ const heldPart = heldParts[0];
+ if (useCase.stableParts.includes(heldPart)) {
+ return `Held part '${heldPart}' cannot also be a stable part.`;
+ }
+ if (arm.__mates().some((mate) =>
+ safePartName(mate.a) === heldPart || safePartName(mate.b) === heldPart)) {
+ return `Held part '${heldPart}' must be disconnected from structural mates in static equilibrium v1.`;
+ }
+
+ const forceToleranceN = useCase.criteria?.maxForceResidualN ?? DEFAULT_FORCE_RESIDUAL_N;
+ const torqueToleranceNmm = useCase.criteria?.maxTorqueResidualNmm ?? DEFAULT_TORQUE_RESIDUAL_NMM;
+ if (!isPositiveFinite(forceToleranceN) || !isPositiveFinite(torqueToleranceNmm)) {
+ return 'Static residual tolerances must be positive finite values.';
+ }
+ if (
+ forceToleranceN > DEFAULT_FORCE_RESIDUAL_N ||
+ torqueToleranceNmm > DEFAULT_TORQUE_RESIDUAL_NMM
+ ) {
+ return `Static residual tolerances cannot exceed ${DEFAULT_FORCE_RESIDUAL_N} N force and ${DEFAULT_TORQUE_RESIDUAL_NMM} Nmm torque.`;
+ }
+
+ const loads: ResolvedLoad[] = [];
+ for (const load of useCase.loads) {
+ if (load.force !== undefined && !isFiniteVec3(load.force)) {
+ return `Force load on '${heldPart}' must be a finite Vec3.`;
+ }
+ if (load.torque !== undefined && !isFiniteVec3(load.torque)) {
+ return `Torque load on '${heldPart}' must be a finite Vec3.`;
+ }
+ let point: Vec3 | undefined;
+ if (load.at !== undefined) {
+ const parsed = safeParseConnectorRef(load.at);
+ if (parsed?.partName !== heldPart) {
+ return `Load application connector '${load.at}' must belong to held part '${heldPart}'.`;
+ }
+ point = connectorWorldPoint(arm, witness.transforms, load.at);
+ if (point === undefined) {
+ return `Load application connector '${load.at}' could not be resolved at the sampled pose.`;
+ }
+ } else if (hasNonZeroVec(load.force)) {
+ return `Force load on '${heldPart}' requires load.at naming an application connector.`;
+ }
+ loads.push({
+ force: load.force === undefined ? [0, 0, 0] : copyVec(load.force),
+ torque: load.torque === undefined ? [0, 0, 0] : copyVec(load.torque),
+ ...(point === undefined ? {} : { point }),
+ });
+ }
+
+ const referencePoint = loads.find((load) => load.point !== undefined)?.point;
+ if (referencePoint === undefined) {
+ return `Held part '${heldPart}' requires at least one load with an explicit application connector.`;
+ }
+
+ const contacts: ResolvedContact[] = [];
+ const seenContactPairs = new Set();
+ for (const contact of useCase.contacts) {
+ const pairKey = [contact.a, contact.b].sort().join('\n');
+ if (seenContactPairs.has(pairKey)) {
+ return `Physical use case '${useCase.name}' declares duplicate contact endpoints '${contact.a}' and '${contact.b}'.`;
+ }
+ seenContactPairs.add(pairKey);
+ const aPart = safePartName(contact.a);
+ const bPart = safePartName(contact.b);
+ const heldIsA = aPart === heldPart;
+ const heldIsB = bPart === heldPart;
+ if (heldIsA === heldIsB) {
+ return `Contact '${contact.a}' to '${contact.b}' must have exactly one endpoint on held part '${heldPart}'.`;
+ }
+ if (!isPositiveFinite(contact.normalForceN)) {
+ return `Contact '${contact.a}' to '${contact.b}' requires a positive normalForceN capacity.`;
+ }
+ if (!Number.isFinite(contact.friction) || contact.friction <= 0) {
+ return `Contact '${contact.a}' to '${contact.b}' requires positive finite friction.`;
+ }
+
+ const witnessContact = witness.contacts.find((entry) =>
+ entry.contactA === contact.a && entry.contactB === contact.b);
+ if (witnessContact === undefined) {
+ return `Contact '${contact.a}' to '${contact.b}' is missing from the common-pose witness.`;
+ }
+ const worldNormal = contactWorldNormal(contact, witness.transforms);
+ if (worldNormal === undefined) {
+ return `Contact '${contact.a}' to '${contact.b}' has an unresolved normal frame.`;
+ }
+ const heldNormal = heldIsA ? worldNormal : scale(worldNormal, -1);
+ const point = midpoint(witnessContact.pointA, witnessContact.pointB);
+ contacts.push({
+ contact,
+ point,
+ heldNormal,
+ generators: frictionPyramidGenerators(heldNormal, contact.friction),
+ capN: contact.normalForceN,
+ heldRef: heldIsA ? contact.a : contact.b,
+ mechanismRef: heldIsA ? contact.b : contact.a,
+ });
+ }
+ if (contacts.length === 0) return `Held part '${heldPart}' has no declared contacts.`;
+
+ let externalForce: Vec3 = [0, 0, 0];
+ let externalTorque: Vec3 = [0, 0, 0];
+ for (const load of loads) {
+ externalForce = add(externalForce, load.force);
+ externalTorque = add(externalTorque, load.torque);
+ if (load.point !== undefined) {
+ externalTorque = add(externalTorque, cross(sub(load.point, referencePoint), load.force));
+ }
+ }
+
+ const actuators = await resolveActuators(arm, useCase, witness, contacts);
+ if (typeof actuators === 'string') return actuators;
+
+ return {
+ heldPart,
+ poses: { ...witness.poses },
+ referencePoint,
+ externalForce,
+ externalTorque,
+ contacts,
+ actuators,
+ forceToleranceN,
+ torqueToleranceNmm,
+ };
+}
+
+async function resolveActuators(
+ arm: Assembly,
+ useCase: PhysicalUseCaseRecord,
+ witness: PhysicalUseCasePoseWitness,
+ contacts: readonly ResolvedContact[],
+): Promise {
+ const matesByName = new Map(arm.__mates().map((mate) => [mate.name, mate]));
+ const couplings = arm.__mateCouplings();
+ const transmissions = arm.__transmissionIntents();
+ const actuators: ResolvedActuator[] = [];
+ const requiredSources = requiredContactPathActuatorSources(arm, useCase, contacts);
+ if (typeof requiredSources === 'string') return requiredSources;
+ const declaredActuators = new Set(useCase.actuatorLimits.map((limit) => limit.mate));
+ for (const sourceMate of requiredSources) {
+ if (!declaredActuators.has(sourceMate)) {
+ return `Contact load path requires independent mate '${sourceMate}' in actuatorLimits.`;
+ }
+ }
+
+ for (const limit of useCase.actuatorLimits) {
+ if (!isPositiveFinite(limit.maxTorqueNmm)) {
+ return `Actuator '${limit.mate}' requires a positive finite maxTorqueNmm.`;
+ }
+ const mate = matesByName.get(limit.mate);
+ if (mate === undefined) return `Actuator mate '${limit.mate}' does not exist.`;
+ if (mate.type !== 'revolute') {
+ return `Actuator mate '${limit.mate}' must be revolute for static torque review v1.`;
+ }
+ if (
+ mate.limitsDeg === undefined ||
+ !mate.limitsDeg.every(Number.isFinite) ||
+ mate.limitsDeg[0] > mate.limitsDeg[1]
+ ) {
+ return `Actuator mate '${limit.mate}' requires finite ordered limitsDeg.`;
+ }
+ if (couplings.some((coupling) => coupling.driven === limit.mate)) {
+ return `Actuator limit '${limit.mate}' names a driven coupled mate; name its independent source mate instead.`;
+ }
+
+ const movedCouplings = collectMovedCouplings(limit.mate, couplings);
+ for (const coupling of movedCouplings) {
+ const transmission = transmissions.find((candidate) =>
+ candidate.sourceMate === coupling.source &&
+ candidate.drivenMates.includes(coupling.driven));
+ if (transmission === undefined) {
+ return `Coupled motion '${coupling.source}' to '${coupling.driven}' requires arm.transmission(...) evidence for static torque review.`;
+ }
+ if (
+ transmission.ratio !== undefined &&
+ !nearlyEqual(transmission.ratio, coupling.ratio)
+ ) {
+ return `Transmission '${transmission.name}' ratio ${transmission.ratio} contradicts coupling ratio ${coupling.ratio} for '${coupling.source}' to '${coupling.driven}'.`;
+ }
+ }
+
+ const poseDeg = witness.poses[limit.mate];
+ if (typeof poseDeg !== 'number' || !Number.isFinite(poseDeg)) {
+ return `Actuator mate '${limit.mate}' has no finite scalar pose in the common-pose witness.`;
+ }
+ const [minDeg, maxDeg] = mate.limitsDeg;
+ if (poseDeg < minDeg - 1e-9 || poseDeg > maxDeg + 1e-9) {
+ return `Actuator mate '${limit.mate}' pose ${poseDeg} deg is outside limitsDeg.`;
+ }
+
+ const baseRelativePoints = relativeContactPoints(arm, witness.transforms, contacts);
+ if (baseRelativePoints === undefined) {
+ return `Actuator mate '${limit.mate}' has unresolved contact endpoints at the common pose.`;
+ }
+ const derivative = await relativeContactJacobian(
+ arm,
+ witness,
+ contacts,
+ limit.mate,
+ poseDeg,
+ minDeg,
+ maxDeg,
+ baseRelativePoints,
+ );
+ if (typeof derivative === 'string') return derivative;
+
+ const coefficients: number[] = [];
+ for (let contactIndex = 0; contactIndex < contacts.length; contactIndex++) {
+ for (const generator of contacts[contactIndex].generators) {
+ coefficients.push(-dot(derivative[contactIndex], generator));
+ }
+ }
+ actuators.push({
+ mateName: limit.mate,
+ maxTorqueNmm: limit.maxTorqueNmm,
+ coefficients,
+ });
+ }
+
+ return actuators;
+}
+
+function requiredContactPathActuatorSources(
+ arm: Assembly,
+ useCase: PhysicalUseCaseRecord,
+ contacts: readonly ResolvedContact[],
+): Set | string {
+ type Mate = ReturnType[number];
+ type PathEdge = { readonly partName: string; readonly mate: Mate };
+ const adjacency = new Map();
+ for (const part of arm.__parts()) adjacency.set(part.name, []);
+ for (const mate of arm.__mates()) {
+ const aPart = safePartName(mate.a);
+ const bPart = safePartName(mate.b);
+ if (aPart === undefined || bPart === undefined) continue;
+ adjacency.get(aPart)?.push({ partName: bPart, mate });
+ adjacency.get(bPart)?.push({ partName: aPart, mate });
+ }
+
+ const couplingByDriven = new Map();
+ for (const coupling of arm.__mateCouplings()) {
+ const existing = couplingByDriven.get(coupling.driven);
+ if (existing !== undefined && existing !== coupling.source) {
+ return `Driven mate '${coupling.driven}' has multiple coupling sources.`;
+ }
+ couplingByDriven.set(coupling.driven, coupling.source);
+ }
+
+ const stableParts = new Set(useCase.stableParts);
+ const required = new Set();
+ for (const contact of contacts) {
+ const mechanismPart = safePartName(contact.mechanismRef);
+ if (mechanismPart === undefined) {
+ return `Mechanism contact '${contact.mechanismRef}' has no resolvable part.`;
+ }
+ if (stableParts.has(mechanismPart)) continue;
+
+ const queue = [mechanismPart];
+ const visited = new Set(queue);
+ const parent = new Map();
+ let reachedStablePart: string | undefined;
+ while (queue.length > 0 && reachedStablePart === undefined) {
+ const partName = queue.shift()!;
+ for (const edge of adjacency.get(partName) ?? []) {
+ if (visited.has(edge.partName)) continue;
+ visited.add(edge.partName);
+ parent.set(edge.partName, { from: partName, mate: edge.mate });
+ if (stableParts.has(edge.partName)) {
+ reachedStablePart = edge.partName;
+ break;
+ }
+ queue.push(edge.partName);
+ }
+ }
+ if (reachedStablePart === undefined) {
+ return `Mechanism contact part '${mechanismPart}' has no mate path to a declared stable part.`;
+ }
+
+ let currentPart = reachedStablePart;
+ while (currentPart !== mechanismPart) {
+ const step = parent.get(currentPart);
+ if (step === undefined) {
+ return `Mechanism contact part '${mechanismPart}' has an unresolved stable-part path.`;
+ }
+ if (step.mate.type !== 'fastened') {
+ const source = ultimateCouplingSource(step.mate.name, couplingByDriven);
+ if (typeof source !== 'string') return source.message;
+ required.add(source);
+ }
+ currentPart = step.from;
+ }
+ }
+ return required;
+}
+
+function ultimateCouplingSource(
+ mateName: string,
+ couplingByDriven: ReadonlyMap,
+): string | { readonly message: string } {
+ const visited = new Set();
+ let current = mateName;
+ while (couplingByDriven.has(current)) {
+ if (visited.has(current)) {
+ return { message: `Mate coupling cycle includes '${current}'.` };
+ }
+ visited.add(current);
+ current = couplingByDriven.get(current)!;
+ }
+ return current;
+}
+
+function collectMovedCouplings(
+ sourceMate: string,
+ couplings: ReturnType,
+): ReturnType[number][] {
+ const movedMates = new Set([sourceMate]);
+ const movedCouplings: ReturnType[number][] = [];
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const coupling of couplings) {
+ if (!movedMates.has(coupling.source) || movedMates.has(coupling.driven)) continue;
+ movedCouplings.push(coupling);
+ movedMates.add(coupling.driven);
+ changed = true;
+ }
+ }
+ return movedCouplings;
+}
+
+async function relativeContactJacobian(
+ arm: Assembly,
+ witness: PhysicalUseCasePoseWitness,
+ contacts: readonly ResolvedContact[],
+ mateName: string,
+ poseDeg: number,
+ minDeg: number,
+ maxDeg: number,
+ baseRelativePoints: readonly Vec3[],
+): Promise {
+ const deltaDeg = ACTUATOR_JACOBIAN_STEP_RAD * 180 / Math.PI;
+ const availableMinus = Math.max(0, poseDeg - minDeg);
+ const availablePlus = Math.max(0, maxDeg - poseDeg);
+
+ if (availableMinus > 1e-12 && availablePlus > 1e-12) {
+ const stepDeg = Math.min(deltaDeg, availableMinus, availablePlus);
+ const minus = await solveRelativeContactPoints(arm, witness, contacts, mateName, poseDeg - stepDeg);
+ if (typeof minus === 'string') return minus;
+ const plus = await solveRelativeContactPoints(arm, witness, contacts, mateName, poseDeg + stepDeg);
+ if (typeof plus === 'string') return plus;
+ const denominatorRad = 2 * stepDeg * Math.PI / 180;
+ return plus.map((point, index) => scale(sub(point, minus[index]), 1 / denominatorRad));
+ }
+
+ if (availablePlus > 1e-12) {
+ const stepDeg = Math.min(deltaDeg, availablePlus);
+ const plus = await solveRelativeContactPoints(arm, witness, contacts, mateName, poseDeg + stepDeg);
+ if (typeof plus === 'string') return plus;
+ const denominatorRad = stepDeg * Math.PI / 180;
+ return plus.map((point, index) => scale(sub(point, baseRelativePoints[index]), 1 / denominatorRad));
+ }
+
+ if (availableMinus > 1e-12) {
+ const stepDeg = Math.min(deltaDeg, availableMinus);
+ const minus = await solveRelativeContactPoints(arm, witness, contacts, mateName, poseDeg - stepDeg);
+ if (typeof minus === 'string') return minus;
+ const denominatorRad = stepDeg * Math.PI / 180;
+ return baseRelativePoints.map((point, index) => scale(sub(point, minus[index]), 1 / denominatorRad));
+ }
+
+ return `Actuator mate '${mateName}' has no in-limit interval for a torque Jacobian.`;
+}
+
+async function solveRelativeContactPoints(
+ arm: Assembly,
+ witness: PhysicalUseCasePoseWitness,
+ contacts: readonly ResolvedContact[],
+ mateName: string,
+ poseDeg: number,
+): Promise {
+ const poses: NumericPoses = { ...witness.poses, [mateName]: poseDeg };
+ for (const coupling of arm.__mateCouplings()) delete poses[coupling.driven];
+ const expanded = expandCoupledPoses(arm.__mates(), arm.__mateCouplings(), poses);
+ let solved: Awaited>;
+ try {
+ solved = await solveMates(arm, expanded);
+ } catch {
+ return `Actuator mate '${mateName}' perturbation could not be solved.`;
+ }
+ if (solved.status !== 'solved' && solved.status !== 'redundant-ok') {
+ return `Actuator mate '${mateName}' perturbation returned solver status '${solved.status}'.`;
+ }
+ const points = relativeContactPoints(arm, solved.poses, contacts);
+ return points ?? `Actuator mate '${mateName}' perturbation has unresolved contact endpoints.`;
+}
+
+function relativeContactPoints(
+ arm: Assembly,
+ transforms: ReadonlyMap,
+ contacts: readonly ResolvedContact[],
+): Vec3[] | undefined {
+ const points: Vec3[] = [];
+ for (const contact of contacts) {
+ const mechanismPoint = connectorWorldPoint(arm, transforms, contact.mechanismRef);
+ const heldPoint = connectorWorldPoint(arm, transforms, contact.heldRef);
+ if (mechanismPoint === undefined || heldPoint === undefined) return undefined;
+ points.push(sub(mechanismPoint, heldPoint));
+ }
+ return points;
+}
+
+function searchContactAllocation(
+ sample: ResolvedStaticSample,
+ penalizeActuators: boolean,
+): SearchCandidate {
+ const variableCount = sample.contacts.length * FRICTION_PYRAMID_EDGE_COUNT;
+ const matrix: number[][] = Array.from({ length: 6 }, () => new Array(variableCount).fill(0));
+ for (let contactIndex = 0; contactIndex < sample.contacts.length; contactIndex++) {
+ const contact = sample.contacts[contactIndex];
+ const arm = sub(contact.point, sample.referencePoint);
+ for (let edge = 0; edge < FRICTION_PYRAMID_EDGE_COUNT; edge++) {
+ const column = contactIndex * FRICTION_PYRAMID_EDGE_COUNT + edge;
+ const force = contact.generators[edge];
+ const torque = cross(arm, force);
+ matrix[0][column] = force[0];
+ matrix[1][column] = force[1];
+ matrix[2][column] = force[2];
+ matrix[3][column] = torque[0];
+ matrix[4][column] = torque[1];
+ matrix[5][column] = torque[2];
+ }
+ }
+
+ const forceScale = Math.max(1, norm(sample.externalForce));
+ const maxArmMm = Math.max(
+ 1,
+ ...sample.contacts.map((contact) => norm(sub(contact.point, sample.referencePoint))),
+ );
+ const torqueScale = Math.max(1, norm(sample.externalTorque), forceScale * maxArmMm);
+ const target = [
+ -sample.externalForce[0] / forceScale,
+ -sample.externalForce[1] / forceScale,
+ -sample.externalForce[2] / forceScale,
+ -sample.externalTorque[0] / torqueScale,
+ -sample.externalTorque[1] / torqueScale,
+ -sample.externalTorque[2] / torqueScale,
+ ];
+ const scaledMatrix = matrix.map((row, rowIndex) =>
+ row.map((value) => value / (rowIndex < 3 ? forceScale : torqueScale)));
+
+ const weights = projectedGradientLeastSquares(
+ scaledMatrix,
+ target,
+ sample.contacts.map((contact) => contact.capN),
+ penalizeActuators ? sample.actuators : [],
+ );
+ return evaluateCandidate(sample, weights);
+}
+
+function projectedGradientLeastSquares(
+ matrix: readonly (readonly number[])[],
+ target: readonly number[],
+ caps: readonly number[],
+ actuators: readonly ResolvedActuator[],
+): number[] {
+ const variableCount = matrix[0]?.length ?? 0;
+ let current = new Array(variableCount).fill(0);
+ let best = current;
+ let bestScore = Infinity;
+ const frobeniusSquared = matrix.reduce(
+ (sum, row) => sum + row.reduce((rowSum, value) => rowSum + value * value, 0),
+ 0,
+ ) + actuators.reduce(
+ (sum, actuator) => sum + actuator.coefficients.reduce(
+ (rowSum, value) => rowSum + (value / actuator.maxTorqueNmm) ** 2,
+ 0,
+ ),
+ 0,
+ );
+ const step = 1 / Math.max(1e-9, 2 * frobeniusSquared);
+
+ for (let iteration = 0; iteration < MAX_PROJECTED_GRADIENT_ITERATIONS; iteration++) {
+ const residual = matrixVector(matrix, current).map((value, index) => value - target[index]);
+ let score = dotArray(residual, residual);
+ for (const actuator of actuators) {
+ const normalizedTorque = dotArray(actuator.coefficients, current) / actuator.maxTorqueNmm;
+ const violation = Math.max(0, Math.abs(normalizedTorque) - 1);
+ score += violation * violation;
+ }
+ if (score < bestScore) {
+ bestScore = score;
+ best = [...current];
+ }
+ const gradient = new Array(variableCount).fill(0);
+ for (let row = 0; row < matrix.length; row++) {
+ for (let column = 0; column < variableCount; column++) {
+ gradient[column] += 2 * matrix[row][column] * residual[row];
+ }
+ }
+ for (const actuator of actuators) {
+ const normalizedCoefficients = actuator.coefficients.map(
+ (value) => value / actuator.maxTorqueNmm,
+ );
+ const normalizedTorque = dotArray(normalizedCoefficients, current);
+ const violation = Math.abs(normalizedTorque) - 1;
+ if (violation <= 0) continue;
+ const gradientScale = 2 * violation * Math.sign(normalizedTorque);
+ for (let column = 0; column < variableCount; column++) {
+ gradient[column] += gradientScale * normalizedCoefficients[column];
+ }
+ }
+ const next = current.map((value, index) => value - step * gradient[index]);
+ projectContactGroups(next, caps);
+ if (normArray(next.map((value, index) => value - current[index])) < 1e-12) {
+ current = next;
+ break;
+ }
+ current = next;
+ }
+
+ const finalResidual = matrixVector(matrix, current).map((value, index) => value - target[index]);
+ let finalScore = dotArray(finalResidual, finalResidual);
+ for (const actuator of actuators) {
+ const normalizedTorque = dotArray(actuator.coefficients, current) / actuator.maxTorqueNmm;
+ const violation = Math.max(0, Math.abs(normalizedTorque) - 1);
+ finalScore += violation * violation;
+ }
+ if (finalScore < bestScore) return current;
+ return best;
+}
+
+function projectContactGroups(values: number[], caps: readonly number[]): void {
+ for (let group = 0; group < caps.length; group++) {
+ const start = group * FRICTION_PYRAMID_EDGE_COUNT;
+ const projected = projectCappedSimplex(
+ values.slice(start, start + FRICTION_PYRAMID_EDGE_COUNT),
+ caps[group],
+ );
+ for (let i = 0; i < projected.length; i++) values[start + i] = projected[i];
+ }
+}
+
+function projectCappedSimplex(values: readonly number[], cap: number): number[] {
+ const nonNegative = values.map((value) => Math.max(0, value));
+ if (nonNegative.reduce((sum, value) => sum + value, 0) <= cap) return nonNegative;
+
+ const sorted = [...nonNegative].sort((a, b) => b - a);
+ let cumulative = 0;
+ let threshold = 0;
+ for (let i = 0; i < sorted.length; i++) {
+ cumulative += sorted[i];
+ const candidate = (cumulative - cap) / (i + 1);
+ if (i === sorted.length - 1 || candidate >= sorted[i + 1]) {
+ threshold = candidate;
+ break;
+ }
+ }
+ return nonNegative.map((value) => Math.max(0, value - threshold));
+}
+
+function evaluateCandidate(
+ sample: ResolvedStaticSample,
+ weights: readonly number[],
+): SearchCandidate {
+ const contactForces: PhysicalUseCaseStaticContactForce[] = [];
+ let netForce = sample.externalForce;
+ let netTorque = sample.externalTorque;
+
+ for (let contactIndex = 0; contactIndex < sample.contacts.length; contactIndex++) {
+ const contact = sample.contacts[contactIndex];
+ let force: Vec3 = [0, 0, 0];
+ for (let edge = 0; edge < FRICTION_PYRAMID_EDGE_COUNT; edge++) {
+ const weight = weights[contactIndex * FRICTION_PYRAMID_EDGE_COUNT + edge] ?? 0;
+ force = add(force, scale(contact.generators[edge], weight));
+ }
+ const normalForceN = dot(force, contact.heldNormal);
+ const tangent = sub(force, scale(contact.heldNormal, normalForceN));
+ const tangentialForceN = norm(tangent);
+ contactForces.push({
+ contactA: contact.contact.a,
+ contactB: contact.contact.b,
+ pointWorldMm: copyVec(contact.point),
+ mechanismPart: safePartName(contact.mechanismRef)!,
+ forceOnHeldWorldN: copyVec(force),
+ normalForceN,
+ tangentialForceN,
+ normalCapacityN: contact.capN,
+ friction: contact.contact.friction,
+ });
+ netForce = add(netForce, force);
+ netTorque = add(netTorque, cross(sub(contact.point, sample.referencePoint), force));
+ }
+
+ const forceResidualN = norm(netForce);
+ const torqueResidualNmm = norm(netTorque);
+ const actuatorTorques = sample.actuators.map((actuator) => ({
+ mateName: actuator.mateName,
+ requiredTorqueNmm: Math.abs(dotArray(actuator.coefficients, weights)),
+ maxTorqueNmm: actuator.maxTorqueNmm,
+ }));
+ return {
+ weights,
+ contactForces,
+ forceResidualN,
+ torqueResidualNmm,
+ normalizedResidual:
+ forceResidualN / sample.forceToleranceN +
+ torqueResidualNmm / sample.torqueToleranceNmm,
+ actuatorTorques,
+ };
+}
+
+function isVerifiedContactCertificate(
+ sample: ResolvedStaticSample,
+ candidate: SearchCandidate,
+): boolean {
+ if (
+ !Number.isFinite(candidate.forceResidualN) ||
+ !Number.isFinite(candidate.torqueResidualNmm) ||
+ candidate.forceResidualN > sample.forceToleranceN ||
+ candidate.torqueResidualNmm > sample.torqueToleranceNmm
+ ) {
+ return false;
+ }
+
+ return candidate.contactForces.every((contact) =>
+ contact.normalForceN >= -1e-8 &&
+ contact.normalForceN <= contact.normalCapacityN + 1e-8 &&
+ contact.tangentialForceN <= contact.friction * Math.max(0, contact.normalForceN) + 1e-8);
+}
+
+function areActuatorsWithinLimits(candidate: SearchCandidate): boolean {
+ return candidate.actuatorTorques.every((actuator) =>
+ Number.isFinite(actuator.requiredTorqueNmm) &&
+ actuator.requiredTorqueNmm <= actuator.maxTorqueNmm + 1e-6);
+}
+
+function totalActuatorViolation(
+ actuators: readonly PhysicalUseCaseStaticActuatorTorqueEvidence[],
+): number {
+ return actuators.reduce(
+ (sum, actuator) => sum + Math.max(
+ 0,
+ (actuator.requiredTorqueNmm - actuator.maxTorqueNmm) / actuator.maxTorqueNmm,
+ ),
+ 0,
+ );
+}
+
+function contactWorldNormal(
+ contact: PhysicalUseCaseContact,
+ transforms: ReadonlyMap,
+): Vec3 | undefined {
+ const frame = contact.normalFrame ?? 'world';
+ let normal: Vec3;
+ if (frame === 'world') {
+ normal = copyVec(contact.normal);
+ } else if (frame === 'a' || frame === 'b') {
+ const ref = frame === 'a' ? contact.a : contact.b;
+ const partName = safePartName(ref);
+ const transform = partName === undefined ? undefined : transforms.get(partName);
+ if (transform === undefined) return undefined;
+ normal = [...transform.axisDir(contact.normal)] as Vec3;
+ } else {
+ return undefined;
+ }
+ const magnitude = norm(normal);
+ if (!Number.isFinite(magnitude) || magnitude <= 0) return undefined;
+ return scale(normal, 1 / magnitude);
+}
+
+function frictionPyramidGenerators(normal: Vec3, friction: number): Vec3[] {
+ const seed: Vec3 = Math.abs(normal[2]) < 0.9 ? [0, 0, 1] : [0, 1, 0];
+ const tangentA = unit(cross(seed, normal));
+ const tangentB = unit(cross(normal, tangentA));
+ return Array.from({ length: FRICTION_PYRAMID_EDGE_COUNT }, (_, index) => {
+ const angle = (2 * Math.PI * index) / FRICTION_PYRAMID_EDGE_COUNT;
+ const tangent = add(scale(tangentA, Math.cos(angle)), scale(tangentB, Math.sin(angle)));
+ return add(normal, scale(tangent, friction));
+ });
+}
+
+function connectorWorldPoint(
+ arm: Assembly,
+ transforms: ReadonlyMap,
+ ref: string,
+): Vec3 | undefined {
+ const parsed = safeParseConnectorRef(ref);
+ if (parsed === undefined) return undefined;
+ const part = arm.__parts().find((candidate) => candidate.name === parsed.partName);
+ const connector = part?.mateConnectors.find((candidate) => candidate.name === parsed.connectorName);
+ const transform = transforms.get(parsed.partName);
+ if (connector?.origin.kind !== 'vec3' || transform === undefined) return undefined;
+ return [...transform.point(connector.origin.value)] as Vec3;
+}
+
+function safeParseConnectorRef(ref: string): ReturnType | undefined {
+ try {
+ return parseConnectorRef(ref);
+ } catch {
+ return undefined;
+ }
+}
+
+function safePartName(ref: string): string | undefined {
+ return safeParseConnectorRef(ref)?.partName;
+}
+
+function hasNonZeroVec(value: readonly number[] | undefined): value is Vec3 {
+ return Array.isArray(value) &&
+ value.length === 3 &&
+ value.every((entry) => Number.isFinite(entry)) &&
+ Math.hypot(value[0], value[1], value[2]) > 0;
+}
+
+function isFiniteVec3(value: readonly number[]): value is Vec3 {
+ return value.length === 3 && value.every((entry) => Number.isFinite(entry));
+}
+
+function isPositiveFinite(value: number | undefined): value is number {
+ return value !== undefined && Number.isFinite(value) && value > 0;
+}
+
+function copyVec(value: readonly [number, number, number]): Vec3 {
+ return [value[0], value[1], value[2]];
+}
+
+function add(a: Vec3, b: Vec3): Vec3 {
+ return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
+}
+
+function sub(a: Vec3, b: Vec3): Vec3 {
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
+}
+
+function scale(value: Vec3, scalar: number): Vec3 {
+ return [value[0] * scalar, value[1] * scalar, value[2] * scalar];
+}
+
+function dot(a: Vec3, b: Vec3): number {
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
+}
+
+function cross(a: Vec3, b: Vec3): Vec3 {
+ return [
+ a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0],
+ ];
+}
+
+function norm(value: Vec3): number {
+ return Math.hypot(value[0], value[1], value[2]);
+}
+
+function unit(value: Vec3): Vec3 {
+ const magnitude = norm(value);
+ return magnitude <= 0 ? [0, 0, 0] : scale(value, 1 / magnitude);
+}
+
+function midpoint(a: Vec3, b: Vec3): Vec3 {
+ return scale(add(a, b), 0.5);
+}
+
+function matrixVector(matrix: readonly (readonly number[])[], vector: readonly number[]): number[] {
+ return matrix.map((row) => row.reduce((sum, value, index) => sum + value * vector[index], 0));
+}
+
+function dotArray(a: readonly number[], b: readonly number[]): number {
+ return a.reduce((sum, value, index) => sum + value * b[index], 0);
+}
+
+function normArray(values: readonly number[]): number {
+ return Math.sqrt(values.reduce((sum, value) => sum + value * value, 0));
+}
+
+function nearlyEqual(a: number, b: number): boolean {
+ return Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
+}
diff --git a/src/modeling/mates/solver.test.ts b/src/modeling/mates/solver.test.ts
index 3748fa49b..34b0ab0d2 100644
--- a/src/modeling/mates/solver.test.ts
+++ b/src/modeling/mates/solver.test.ts
@@ -57,6 +57,35 @@ describe('solveMates — tree topology', () => {
expect(cT.decomposeToTranslateAndRotate().translate[2]).toBeCloseTo(15);
});
+ it('solves every disconnected mate component only when the forest option is enabled', async () => {
+ const { arm, kcad } = makeArm();
+ arm
+ .part('unrelated', kcad.box(1, 1, 1))
+ .connector('origin', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ });
+ arm
+ .part('support', kcad.box(10, 10, 10))
+ .connector('out', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [100, 0, 0] },
+ });
+ arm
+ .part('mounted', kcad.box(5, 5, 5))
+ .connector('in', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ });
+ arm.mate('mount', 'support.out', 'mounted.in', 'fastened');
+
+ const legacy = await solveMates(arm);
+ const forest = await solveMates(arm, undefined, { solveDisconnectedComponents: true });
+
+ expect(legacy.poses.get('mounted')!.point([0, 0, 0])[0]).toBeCloseTo(0);
+ expect(forest.poses.get('mounted')!.point([0, 0, 0])[0]).toBeCloseTo(100);
+ });
+
});
describe('solveMates — closed loop (Newton-Raphson)', () => {
diff --git a/src/modeling/mates/solver.ts b/src/modeling/mates/solver.ts
index 0b348c18c..97516731b 100644
--- a/src/modeling/mates/solver.ts
+++ b/src/modeling/mates/solver.ts
@@ -195,6 +195,10 @@ export interface SolveMatesOptions {
* so it is opt-in per call site. Robot-description export uses it to
* stamp real per-link poses on closed-loop mechanisms. */
acceptConsistentArticulatedLoops?: boolean;
+ /** Solve each disconnected mate component from its first declared part.
+ * The default preserves the historical behavior: only the component
+ * containing parts[0] is traversed and all other parts are identity-filled. */
+ solveDisconnectedComponents?: boolean;
}
export async function solveMates(
@@ -225,7 +229,14 @@ export async function solveMates(
// 2. Build a spanning tree via BFS from the first declared part. Mates not
// in the tree become loop-closure constraints — passed to `loopSolve`.
const partByName = new Map(parts.map((p) => [p.name, p]));
- const { worldT, loopMates } = await walkSpanningTree(parts, adjacency, partByName, arm, expandedPoses);
+ const { worldT, loopMates } = await walkSpanningTree(
+ parts,
+ adjacency,
+ partByName,
+ arm,
+ expandedPoses,
+ opts?.solveDisconnectedComponents === true,
+ );
if (loopMates.length === 0) {
return { status: 'solved', poses: worldT };
@@ -440,43 +451,51 @@ interface SpanningTreeResult {
loopMates: MateRecord[];
}
-/** BFS from `parts[0]`. Visited neighbors compose their world transform from
- * the parent through the connecting mate. Mates that would connect already-
- * visited parts are deferred to `loopMates`. Disconnected parts default to
- * identity. */
+/** BFS from `parts[0]`, optionally continuing from the first unvisited part
+ * in each disconnected component. Visited neighbors compose their world
+ * transform from the parent through the connecting mate. Mates that would
+ * connect already-visited parts are deferred to `loopMates`. */
async function walkSpanningTree(
parts: readonly AssemblyPartStored[],
adjacency: ReadonlyMap,
partByName: ReadonlyMap,
arm: Assembly,
poses: NumericPoses | undefined,
+ solveDisconnectedComponents: boolean,
): Promise {
const worldT = new Map();
const loopMates: MateRecord[] = [];
const seenMate = new Set();
- const root = parts[0];
- worldT.set(root.name, Transform.identity());
-
- const queue: string[] = [root.name];
- const visited = new Set([root.name]);
-
- while (queue.length > 0) {
- const parentName = queue.shift()!;
- const parentT = worldT.get(parentName)!;
- for (const edge of adjacency.get(parentName) ?? []) {
- if (seenMate.has(edge.mate.name)) continue;
- seenMate.add(edge.mate.name);
- if (visited.has(edge.neighbor)) {
- // Loop-closure mate: both endpoints already placed by the tree.
- loopMates.push(edge.mate);
- continue;
+ const queue: string[] = [];
+ const visited = new Set();
+ let nextRoot: AssemblyPartStored | undefined = parts[0];
+ while (nextRoot !== undefined) {
+ worldT.set(nextRoot.name, Transform.identity());
+ visited.add(nextRoot.name);
+ queue.push(nextRoot.name);
+
+ while (queue.length > 0) {
+ const parentName = queue.shift()!;
+ const parentT = worldT.get(parentName)!;
+ for (const edge of adjacency.get(parentName) ?? []) {
+ if (seenMate.has(edge.mate.name)) continue;
+ seenMate.add(edge.mate.name);
+ if (visited.has(edge.neighbor)) {
+ // Loop-closure mate: both endpoints already placed by the tree.
+ loopMates.push(edge.mate);
+ continue;
+ }
+ visited.add(edge.neighbor);
+ const childT = await composeChildTransform(parentT, edge, partByName, arm, poses);
+ worldT.set(edge.neighbor, childT);
+ queue.push(edge.neighbor);
}
- visited.add(edge.neighbor);
- const childT = await composeChildTransform(parentT, edge, partByName, arm, poses);
- worldT.set(edge.neighbor, childT);
- queue.push(edge.neighbor);
}
+
+ nextRoot = solveDisconnectedComponents
+ ? parts.find((part) => !visited.has(part.name))
+ : undefined;
}
// Disconnected parts default to identity so callers always see one
diff --git a/src/modeling/mates/validator.test.ts b/src/modeling/mates/validator.test.ts
index 87b351c04..fae2ee978 100644
--- a/src/modeling/mates/validator.test.ts
+++ b/src/modeling/mates/validator.test.ts
@@ -352,6 +352,37 @@ describe('validateAssembly — v0.6 mate-aware codes', () => {
expect(info?.severity).toBe('info');
});
+ it('does not report used contact-target parts as floating structure', async () => {
+ const { arm, kcad } = makeArm();
+ arm
+ .part('frame', kcad.box(1, 1, 1))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } });
+ arm
+ .part('palm', kcad.box(10, 10, 2))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } })
+ .connector('pad', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 1] } });
+ arm
+ .part('target-bar', kcad.cylinder(20, 3), { role: 'contact-target' })
+ .connector('contact', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 1] } });
+ arm.part('stray', kcad.box(1, 1, 1));
+ arm.mate('palm-frame', 'frame.mount', 'palm.mount', 'fastened');
+ arm.physicalUseCase('touch-target', {
+ stableParts: ['palm'],
+ loads: [{ part: 'target-bar', force: [0, 0, -1] }],
+ contacts: [
+ { a: 'palm.pad', b: 'target-bar.contact', normal: [0, 0, 1], friction: 0.5, normalForceN: 1 },
+ ],
+ actuatorLimits: [],
+ });
+
+ const result = await validateAssemblyWithMates(arm);
+
+ const floatingPartNames = result.diagnostics
+ .filter((diagnostic) => diagnostic.code === 'assembly.part.floating')
+ .map((diagnostic) => diagnostic.partName);
+ expect(floatingPartNames).toEqual(['stray']);
+ });
+
it('type-system accepts the 17 v0.6 + v0.6.2 + v0.7.4 diagnostic codes', () => {
// Capture-time codes (`type-mismatch`, `connector-not-found`) are thrown
// as `KernelError` by `arm.mate(...)` and never surface through the
diff --git a/src/modeling/mates/validator.ts b/src/modeling/mates/validator.ts
index bee7e8d4e..a547d10ce 100644
--- a/src/modeling/mates/validator.ts
+++ b/src/modeling/mates/validator.ts
@@ -437,10 +437,14 @@ export async function validateAssemblyWithMates(
if (a) matePartNames.add(a);
if (b) matePartNames.add(b);
}
+ const usedContactTargetPartNames = collectUsedContactTargetPartNames(arm);
const diagnostics: ValidatorDiagnostic[] = base.diagnostics.filter((d) => {
if (d.code === 'assembly.part.floating' && d.partName && matePartNames.has(d.partName)) {
return false; // part is connected via a mate; v0.5 just couldn't see it.
}
+ if (d.code === 'assembly.part.floating' && d.partName && usedContactTargetPartNames.has(d.partName)) {
+ return false; // external physical-use-case target; intentionally not structural.
+ }
return true;
});
@@ -643,6 +647,32 @@ function safeParse(ref: string): string | undefined {
}
}
+function collectUsedContactTargetPartNames(arm: Assembly): Set {
+ const contactTargetPartNames = new Set(
+ arm.__parts()
+ .filter((part) => part.role === 'contact-target')
+ .map((part) => part.name),
+ );
+ const used = new Set();
+ if (contactTargetPartNames.size === 0) return used;
+
+ const maybeAdd = (partName: string | undefined): void => {
+ if (partName !== undefined && contactTargetPartNames.has(partName)) used.add(partName);
+ };
+
+ for (const useCase of arm.__physicalUseCases()) {
+ for (const load of useCase.loads) {
+ maybeAdd(load.part);
+ }
+ for (const contact of useCase.contacts) {
+ maybeAdd(safeParse(contact.a));
+ maybeAdd(safeParse(contact.b));
+ }
+ }
+
+ return used;
+}
+
/**
* Compute the final `ValidatorStatus` from the diagnostic chain plus the
* upstream mate solver verdict. Most-severe-wins (see
diff --git a/src/modeling/runtime/interferenceClassification.ts b/src/modeling/runtime/interferenceClassification.ts
new file mode 100644
index 000000000..16cdbf3b8
--- /dev/null
+++ b/src/modeling/runtime/interferenceClassification.ts
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+import type { InterferencePair } from './detectInterferences';
+import { jointContactCapMm3 } from './jointContactCap';
+
+export type InterferenceClassification = 'contact-noise' | 'actionable';
+
+export interface ClassifiedInterferencePair extends InterferencePair {
+ readonly capMm3: number;
+ readonly classification: InterferenceClassification;
+ readonly actionable: boolean;
+}
+
+export interface InterferenceSummary {
+ readonly rawCount: number;
+ readonly contactNoiseCount: number;
+ readonly actionableCount: number;
+ readonly capMm3: number;
+ readonly pairs: readonly ClassifiedInterferencePair[];
+}
+
+export function classifyInterferencePairs(
+ pairs: readonly InterferencePair[],
+ capMm3 = jointContactCapMm3(),
+): ClassifiedInterferencePair[] {
+ return pairs.map((pair) => {
+ const actionable = pair.volumeMm3 > capMm3;
+ return {
+ ...pair,
+ capMm3,
+ classification: actionable ? 'actionable' : 'contact-noise',
+ actionable,
+ };
+ });
+}
+
+export function summarizeInterferencePairs(
+ pairs: readonly InterferencePair[],
+ capMm3 = jointContactCapMm3(),
+): InterferenceSummary {
+ const classified = classifyInterferencePairs(pairs, capMm3);
+ const actionableCount = classified.filter((pair) => pair.actionable).length;
+ return {
+ rawCount: classified.length,
+ contactNoiseCount: classified.length - actionableCount,
+ actionableCount,
+ capMm3,
+ pairs: classified,
+ };
+}
diff --git a/src/studio/StudioShell.tsx b/src/studio/StudioShell.tsx
index 47fd62e52..7cd002190 100644
--- a/src/studio/StudioShell.tsx
+++ b/src/studio/StudioShell.tsx
@@ -28,6 +28,7 @@ import { useProject } from './context/ProjectContext';
import { useStudioChrome } from './context/StudioChromeContext';
import { useOptionalSession } from '../funnel/hooks/useSession';
import { isAuthConfigured } from '../funnel/lib/supabaseClient';
+import { jointContactCapMm3 } from '../modeling/runtime/jointContactCap';
/**
* Top-level Studio shell. Composes the six slots — Toolbar / Viewport /
@@ -190,14 +191,14 @@ export function StudioShell() {
animation: ,
};
- // HUD reads RAW interference pairs (pre-filter), not the validator's
- // diagnostics. Scripts can silence known-acceptable contacts via
- // `assembly.solvedModel({ ignore: [...] })` — that hides them from the
- // validator throw path and the Validity tab, but the user must still see
- // every live overlap on the status bar (especially when they drag a
- // Studio param slider into a colliding pose). The two channels are
- // wired separately on `useRecomputeResult` for exactly this reason.
- const interferenceCount = recompute.rawInterferencePairs?.length ?? 0;
+ // HUD counts actionable interferences, not contact-noise slivers. Raw
+ // pairs stay available to diagnostic tabs, but the footer follows the same
+ // absolute cap used by validator/mechanism-truth so clearance-fit clevis
+ // contacts do not make a plausible mechanism look broken.
+ const interferenceCount = recompute.interferenceSummary?.actionableCount
+ ?? (recompute.rawInterferencePairs ?? [])
+ .filter((pair) => pair.volumeMm3 > jointContactCapMm3())
+ .length;
return (
diff --git a/src/studio/__tests__/StudioShell.status.test.tsx b/src/studio/__tests__/StudioShell.status.test.tsx
index 8fb2532d0..47b33cfb6 100644
--- a/src/studio/__tests__/StudioShell.status.test.tsx
+++ b/src/studio/__tests__/StudioShell.status.test.tsx
@@ -7,6 +7,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
let workbenchComputing = false;
let agentRailOpen = false;
let recomputeRawPairs: Array<{ a: string; b: string; volumeMm3: number }> = [];
+let recomputeInterferenceSummary: {
+ rawCount: number;
+ contactNoiseCount: number;
+ actionableCount: number;
+ capMm3: number;
+} | null = null;
let recomputeValidity: { diagnostics: { code: string; severity: string }[] } | null = null;
vi.mock('../context/WorkbenchContext', () => ({
@@ -47,6 +53,7 @@ vi.mock('../hooks/useRecomputeResult', () => ({
recomputeMs: 0,
joints: [],
rawInterferencePairs: recomputeRawPairs,
+ interferenceSummary: recomputeInterferenceSummary,
}),
}));
@@ -86,6 +93,7 @@ beforeEach(() => {
workbenchComputing = false;
agentRailOpen = false;
recomputeRawPairs = [];
+ recomputeInterferenceSummary = null;
recomputeValidity = null;
});
@@ -108,21 +116,39 @@ describe('StudioShell status plumbing', () => {
expect(rail.compareDocumentPosition(viewport) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
- it('HUD interference count reads RAW pairs (pre-filter), not validator diagnostics', () => {
- // Simulate the lamp scenario: the script silences 3 known-acceptable
- // pairs via `ignore`, so the validator's diagnostic stream is empty
- // for `assembly.interference.overlap` — but the raw detection found
- // 3 contacts. The HUD MUST show 3, not 0.
+ it('HUD interference count excludes contact-noise pairs below the mechanism cap', () => {
+ // Simulate a jointed mechanism: raw detection still reports tiny
+ // adjacent-joint contacts, but the footer should only count actionable
+ // interferences above the same 20 mm3 cap used by validator/mechanism
+ // truth. The raw pairs remain available to the Params tab.
recomputeRawPairs = [
{ a: 'base', b: 'lower-arm', volumeMm3: 12 },
{ a: 'lower-arm', b: 'upper-arm', volumeMm3: 14 },
{ a: 'upper-arm', b: 'lamp-head', volumeMm3: 8 },
+ { a: 'servo', b: 'palm', volumeMm3: 22 },
];
recomputeValidity = { diagnostics: [] };
render( );
- expect(screen.getByTestId('status-interferences').textContent).toBe('3');
+ expect(screen.getByTestId('status-interferences').textContent).toBe('1');
+ });
+
+ it('uses actionable interference summary for footer count', () => {
+ recomputeRawPairs = [
+ { a: 'raw-a', b: 'raw-b', volumeMm3: 1 },
+ { a: 'real-a', b: 'real-b', volumeMm3: 30 },
+ ];
+ recomputeInterferenceSummary = {
+ rawCount: 2,
+ contactNoiseCount: 1,
+ actionableCount: 1,
+ capMm3: 20,
+ };
+
+ render( );
+
+ expect(screen.getByTestId('status-interferences').textContent).toBe('1');
});
it('HUD shows 0 when raw pairs is empty, even if validator has unrelated diagnostics', () => {
diff --git a/src/studio/__tests__/useRecomputeResult.test.tsx b/src/studio/__tests__/useRecomputeResult.test.tsx
index 527f72232..b8a302675 100644
--- a/src/studio/__tests__/useRecomputeResult.test.tsx
+++ b/src/studio/__tests__/useRecomputeResult.test.tsx
@@ -229,10 +229,8 @@ describe('useRecomputeResult — Slice 1.2 data wiring', () => {
});
it('forwards rawInterferencePairs from scriptReview unchanged', async () => {
- // The HUD reads `.length` of this directly. It's the RAW detection
- // output — populated regardless of whether the script's
- // `solvedModel` set an `ignore` list. Validator filtering happens
- // upstream and lands on `validity.diagnostics`, NOT here.
+ // Raw detection output remains available for detail surfaces even
+ // when the footer uses the classified interferenceSummary.
workbenchValue.featureRecords = [];
workbenchValue.scriptReview = {
ok: false,
diff --git a/src/studio/components/Layout/StatusBar.test.tsx b/src/studio/components/Layout/StatusBar.test.tsx
index 4b21c50bc..883894cd5 100644
--- a/src/studio/components/Layout/StatusBar.test.tsx
+++ b/src/studio/components/Layout/StatusBar.test.tsx
@@ -116,6 +116,32 @@ describe('StatusBar', () => {
expect(screen.getByText('interferences: 3')).toBeDefined();
});
+ it('renders actionable interference count with summary title', () => {
+ render(
+
+ );
+
+ const text = screen.getByText(/interferences: 1/);
+ const title = text.getAttribute('title') ?? '';
+ expect(title).toContain('raw: 16');
+ expect(title).toContain('contact-noise: 15');
+ });
+
it('omits interferences when prop is undefined', () => {
render(
{typeof interferences === 'number' && (
- interferences: {interferences}
+ interferences: {interferences}
)}
{typeof recomputeMs === 'number' && recomputeMs > 0 && (
Last compute {recomputeMs} ms
diff --git a/src/studio/context/GeometryContext.test.tsx b/src/studio/context/GeometryContext.test.tsx
index b3d5d9137..56ca028e8 100644
--- a/src/studio/context/GeometryContext.test.tsx
+++ b/src/studio/context/GeometryContext.test.tsx
@@ -110,6 +110,10 @@ function Probe() {
{scriptParams[0]?.name ?? ''}
{String(scriptReview?.ok ?? '')}
{scriptReview?.suggestedRepairPrompt ?? ''}
+ {String(scriptReview?.rawInterferencePairs?.length ?? 0)}
+ {String(scriptReview?.interferenceSummary?.actionableCount ?? '')}
+ {scriptReview?.diagnostics?.map((d) => d.code).join(',') ?? ''}
+ {scriptReview?.fitness?.repairMode ?? ''}
{error ?? ''}
setPreviewCode('return makeBox(1,1,1);')}>Trigger
setPreviewCode('return makeBox(2,2,2);')}>Trigger2
@@ -695,8 +699,20 @@ describe('GeometryContext latest-intent-wins', () => {
ok: true,
json: async () => ({
ok: true,
- diagnostics: [],
+ diagnostics: [{ code: 'assembly.part.floating', severity: 'error' }],
+ fitness: {
+ functional: false,
+ repairMode: 'full-review',
+ blockingReasons: [{ code: 'assembly.part.floating' }],
+ },
suggestedRepairPrompt: 'initial review',
+ rawInterferencePairs: [{ a: 'old-a', b: 'old-b', volumeMm3: 50 }],
+ interferenceSummary: {
+ rawCount: 1,
+ contactNoiseCount: 0,
+ actionableCount: 99,
+ capMm3: 20,
+ },
}),
} as Response)
.mockResolvedValueOnce({
@@ -719,8 +735,53 @@ describe('GeometryContext latest-intent-wins', () => {
ok: true,
json: async () => ({
ok: true,
- diagnostics: [],
+ livePhysicalUseCaseReview: true,
+ diagnostics: [{ code: 'assembly.physical-use-case.contact-unreachable', severity: 'error' }],
+ fitness: { functional: false, repairMode: 'physical-use-case' },
suggestedRepairPrompt: 'post-relower review',
+ rawInterferencePairs: [
+ { a: 'raw-a', b: 'raw-b', volumeMm3: 1 },
+ { a: 'real-a', b: 'real-b', volumeMm3: 30 },
+ ],
+ interferenceSummary: {
+ rawCount: 2,
+ contactNoiseCount: 1,
+ actionableCount: 1,
+ capMm3: 20,
+ },
+ }),
+ } as Response)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ features: [],
+ bounds: { min: [0, 0, 0], max: [0, 0, 0] },
+ params: {
+ heightAdjustMm: {
+ name: 'heightAdjustMm',
+ type: 'number',
+ value: 4,
+ defaultValue: 0,
+ meta: { min: 0, max: 6.12 },
+ },
+ },
+ }),
+ } as Response)
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ ok: true,
+ livePhysicalUseCaseReview: true,
+ diagnostics: [],
+ rawInterferencePairs: [
+ { a: 'raw-a', b: 'raw-b', volumeMm3: 1 },
+ ],
+ interferenceSummary: {
+ rawCount: 1,
+ contactNoiseCount: 1,
+ actionableCount: 0,
+ capMm3: 20,
+ },
}),
} as Response);
@@ -746,10 +807,25 @@ describe('GeometryContext latest-intent-wins', () => {
expect(screen.getByTestId('script-param-name').textContent).toBe('heightAdjustMm');
// 5th fetch is the live-channel review re-run that refreshes the HUD
// interference count. The live payload overlays rawInterferencePairs
- // onto the last full review — validator output (including the repair
- // prompt) is intentionally kept from the initial review.
+ // and interferenceSummary onto the last full review — validator output
+ // (including the repair prompt) is intentionally kept from the initial
+ // review.
expect(fetchUrl(fetchMock, 5)).toBe('/__kernelcad/review?session=tok-abc&script=examples%2Frobot-arm%2Fdesktop-3axis-mates.kcad.ts&live=1');
expect(screen.getByTestId('script-review-repair').textContent).toBe('initial review');
+ expect(screen.getByTestId('script-review-raw-count').textContent).toBe('2');
+ expect(screen.getByTestId('script-review-summary-actionable').textContent).toBe('1');
+ expect(screen.getByTestId('script-review-diagnostic-codes').textContent).toBe('assembly.part.floating,assembly.physical-use-case.contact-unreachable');
+ expect(screen.getByTestId('script-review-fitness-mode').textContent).toBe('full-review');
+
+ await act(async () => {
+ relowerHandler?.();
+ });
+ await flushUntil(() => fetchMock.mock.calls.length >= 7);
+
+ expect(fetchUrl(fetchMock, 7)).toBe('/__kernelcad/review?session=tok-abc&script=examples%2Frobot-arm%2Fdesktop-3axis-mates.kcad.ts&live=1');
+ expect(screen.getByTestId('script-review-summary-actionable').textContent).toBe('0');
+ expect(screen.getByTestId('script-review-diagnostic-codes').textContent).toBe('assembly.part.floating');
+ expect(screen.getByTestId('script-review-fitness-mode').textContent).toBe('full-review');
});
it('viewport-driver lock suppresses the pose-only /transforms fast path (no fetch while animation drives)', async () => {
diff --git a/src/studio/context/GeometryContext.tsx b/src/studio/context/GeometryContext.tsx
index 53523ecb0..3f3160331 100644
--- a/src/studio/context/GeometryContext.tsx
+++ b/src/studio/context/GeometryContext.tsx
@@ -40,17 +40,22 @@ export interface ScriptReviewSummary {
/**
* Raw pairwise interference results at the script's current/default pose,
* BEFORE any `ignore` filtering applied by `assembly.solvedModel`. The
- * Studio status-bar HUD reads `.length` of this for the interferences
- * counter so users see what's overlapping right now even when the script
- * silences a known-acceptable pair (e.g. an elbow knuckle). The validator's
- * filtered diagnostics still flow through `diagnostics` above for the
- * Validity tab and the `validate: 'error'` throw path.
+ * Studio uses this as the live raw channel and pairs it with
+ * `interferenceSummary` for the actionable footer count/tooltip. The
+ * validator's filtered diagnostics still flow through `diagnostics` above
+ * for the Validity tab and the `validate: 'error'` throw path.
*/
rawInterferencePairs?: Array<{
a: string;
b: string;
volumeMm3: number;
}>;
+ interferenceSummary?: {
+ rawCount: number;
+ contactNoiseCount: number;
+ actionableCount: number;
+ capMm3: number;
+ };
/**
* Physics-grounded loop verdict (P1 surface convergence).
*
@@ -75,6 +80,7 @@ export interface ScriptReviewSummary {
message?: string;
hint?: string;
}>;
+ livePhysicalUseCaseReview?: boolean;
}
/**
@@ -108,6 +114,67 @@ function detectEmptyBuild(
return 'Model compiled but produced no visible geometry. Open the Validity panel for diagnostics.';
}
+function overlayLiveReview(
+ previous: ScriptReviewSummary | null,
+ live: ScriptReviewSummary,
+): ScriptReviewSummary {
+ if (!previous) return live;
+ const liveDiagnostics = live.diagnostics ?? [];
+ const hasLiveDiagnostics = liveDiagnostics.length > 0;
+ const hasLiveFitness = live.fitness !== undefined;
+ const hasLivePhysicalUseCaseReview = live.livePhysicalUseCaseReview === true;
+ if (hasLivePhysicalUseCaseReview) {
+ const nonPhysicalDiagnostics = (previous.diagnostics ?? []).filter((diagnostic) =>
+ !isPhysicalUseCaseReviewCode(diagnostic.code),
+ );
+ const nonPhysicalBlockingReasons = (previous.fitness?.blockingReasons ?? []).filter((reason) =>
+ !isPhysicalUseCaseReviewCode(reason.code),
+ );
+ const liveBlockingReasons = live.fitness?.blockingReasons ?? liveDiagnostics.map((diagnostic) => ({
+ code: diagnostic.code,
+ message: diagnostic.message,
+ repairHint: diagnostic.hint,
+ }));
+ const mergedBlockingReasons = [...nonPhysicalBlockingReasons, ...liveBlockingReasons];
+ const mergedFitness = previous.fitness !== undefined || live.fitness !== undefined
+ ? mergedBlockingReasons.length > 0
+ ? {
+ ...(previous.fitness ?? {}),
+ ...(live.fitness ?? {}),
+ functional: false,
+ repairMode: nonPhysicalBlockingReasons.length > 0
+ ? previous.fitness?.repairMode
+ : liveDiagnostics.length > 0
+ ? live.fitness?.repairMode ?? 'physical-use-case'
+ : previous.fitness?.repairMode,
+ blockingReasons: mergedBlockingReasons,
+ }
+ : undefined
+ : undefined;
+ return {
+ ...previous,
+ ok: nonPhysicalDiagnostics.length === 0 && nonPhysicalBlockingReasons.length === 0
+ ? live.ok
+ : false,
+ diagnostics: [...nonPhysicalDiagnostics, ...liveDiagnostics],
+ fitness: mergedFitness,
+ rawInterferencePairs: live.rawInterferencePairs,
+ interferenceSummary: live.interferenceSummary,
+ };
+ }
+ return {
+ ...previous,
+ ...(hasLiveDiagnostics ? { ok: live.ok, diagnostics: liveDiagnostics } : {}),
+ ...(hasLiveFitness ? { ok: live.ok, fitness: live.fitness } : {}),
+ rawInterferencePairs: live.rawInterferencePairs,
+ interferenceSummary: live.interferenceSummary,
+ };
+}
+
+function isPhysicalUseCaseReviewCode(code: string | undefined): boolean {
+ return code?.startsWith('assembly.physical-use-case.') === true;
+}
+
export interface GeometryContextType {
geometries: GeometryResult[];
previewGeometries: GeometryResult[];
@@ -420,11 +487,9 @@ export function GeometryProvider({ children, code }: { children: ReactNode; code
if (revision !== mainRevisionRef.current) return;
if (opts?.liveReview) {
// Live payload carries only the fresh interference
- // pairs — keep the last FULL review's validator and
+ // data — keep the last FULL review's validator and
// envelope output and overlay the live channel.
- setScriptReview((prev) => prev
- ? { ...prev, rawInterferencePairs: reviewPayload.rawInterferencePairs }
- : reviewPayload);
+ setScriptReview((prev) => overlayLiveReview(prev, reviewPayload));
return;
}
setScriptReview(reviewPayload);
@@ -617,9 +682,7 @@ export function GeometryProvider({ children, code }: { children: ReactNode; code
const response = await fetch(url, { headers });
const payload = await response.json() as ScriptReviewSummary;
if (!response.ok) return;
- setScriptReview((prev) => prev
- ? { ...prev, rawInterferencePairs: payload.rawInterferencePairs }
- : payload);
+ setScriptReview((prev) => overlayLiveReview(prev, payload));
} catch {
// A failed LIVE refresh keeps the last review — dropping it would
// blank the Validity tab mid-drag.
diff --git a/src/studio/hooks/useRecomputeResult.ts b/src/studio/hooks/useRecomputeResult.ts
index f31051955..ed592585d 100644
--- a/src/studio/hooks/useRecomputeResult.ts
+++ b/src/studio/hooks/useRecomputeResult.ts
@@ -47,16 +47,19 @@ export function useRecomputeResult(): StudioRecomputeResult {
);
// Raw interference pairs are read directly from the script review payload
- // (filtering is applied at the validator layer, not here). Empty array
- // when scriptReview is null OR when the server omitted the field — the
- // Studio HUD treats that as "interferences: 0" rather than a missing
- // signal. See StudioRecomputeResult.rawInterferencePairs JSDoc for why
- // the HUD reads this and not validity.diagnostics.
+ // for detail surfaces. The footer prefers the server-classified
+ // interferenceSummary, with raw pairs retained as compatibility fallback
+ // for older review payloads.
const rawInterferencePairs = useMemo(
() => workbench.scriptReview?.rawInterferencePairs ?? [],
[workbench.scriptReview],
);
+ const interferenceSummary = useMemo(
+ () => workbench.scriptReview?.interferenceSummary ?? null,
+ [workbench.scriptReview],
+ );
+
const paramTable = useMemo(
() => serializedParamsToTable(workbench.scriptParams ?? []),
[workbench.scriptParams],
@@ -119,6 +122,7 @@ export function useRecomputeResult(): StudioRecomputeResult {
recomputeMs: workbench.recomputeMs ?? 0,
joints,
rawInterferencePairs,
+ interferenceSummary,
mechanismBanner,
updateParam,
setGeometryTransformOverride,
@@ -137,6 +141,7 @@ export function useRecomputeResult(): StudioRecomputeResult {
repairEvidence,
joints,
rawInterferencePairs,
+ interferenceSummary,
mechanismBanner,
setGeometryTransformOverride,
clearGeometryTransformOverrides,
diff --git a/src/studio/types.ts b/src/studio/types.ts
index cb7386ec6..34cef86a6 100644
--- a/src/studio/types.ts
+++ b/src/studio/types.ts
@@ -59,19 +59,23 @@ export interface StudioRecomputeResult {
readonly recomputeMs: number;
/**
* Raw pairwise interference pairs at the current pose, BEFORE any `ignore`
- * filtering done by `assembly.solvedModel({ ignore: [...] })`. The status
- * bar HUD reads `.length` of this so users see the real overlap count
- * even when the script silences specific known-acceptable contacts. The
- * `validity` field (above) carries the validator's FILTERED diagnostic
- * stream — i.e. ignored pairs do not appear there. The two channels are
- * deliberately decoupled: validator runs filtered (Validity tab + throw
- * path), HUD shows raw (so authors can never accidentally blind the user).
+ * filtering done by `assembly.solvedModel({ ignore: [...] })`. Inspector
+ * surfaces keep this raw channel for detail, while the status footer
+ * prefers `interferenceSummary.actionableCount` and only falls back to
+ * locally classifying raw pairs when older review payloads omit the
+ * summary.
*/
readonly rawInterferencePairs: ReadonlyArray<{
readonly a: string;
readonly b: string;
readonly volumeMm3: number;
}>;
+ readonly interferenceSummary: {
+ readonly rawCount: number;
+ readonly contactNoiseCount: number;
+ readonly actionableCount: number;
+ readonly capMm3: number;
+ } | null;
/**
* Slice 2C — assembly joints with declared pose, extracted from
* `solvedAssembly` FeatureRecords. Empty array when the script doesn't
diff --git a/tests/fixtures/robot-hand/rejected-five-finger-kinematic-hand.kcad.ts b/tests/fixtures/robot-hand/rejected-five-finger-kinematic-hand.kcad.ts
new file mode 100644
index 000000000..1450e7ead
--- /dev/null
+++ b/tests/fixtures/robot-hand/rejected-five-finger-kinematic-hand.kcad.ts
@@ -0,0 +1,524 @@
+// Functional front-facing five-finger robot hand.
+//
+// This keeps the original robot-hand render language: broad palm plate,
+// black actuator window inserts, visible tendon rods into a wrist block, four
+// vertical fingers, and an angled thumb. Unlike the old render-budget
+// blockout, every visible solid is either unioned into a load-bearing body or
+// belongs to an articulated clevis-jointed phalanx part.
+
+const closeDeg = param('closeDeg', 22, { min: 0, max: 32 });
+
+setCameraTarget(0, 0, 35);
+setCameraDistance(285);
+
+const hand = assembly('front-facing-five-finger-robot-hand');
+
+const palmMaterial = { baseColor: '#b9b3a8', metalness: 0.0, roughness: 0.50 };
+const darkMaterial = { baseColor: '#151719', metalness: 0.0, roughness: 0.62 };
+const linkMaterial = { baseColor: '#d8d3c9', metalness: 0.0, roughness: 0.46 };
+const tipMaterial = { baseColor: '#191b1d', metalness: 0.0, roughness: 0.60 };
+const forkMaterial = { baseColor: '#676b6f', metalness: 0.0, roughness: 0.44 };
+const tongueMaterial = { baseColor: '#c8c2b5', metalness: 0.0, roughness: 0.45 };
+const pinMaterial = { baseColor: '#d6d9dc', metalness: 0.82, roughness: 0.22 };
+const targetMaterial = { baseColor: '#6fa9c8', metalness: 0.0, roughness: 0.42 };
+
+// Reference evidence from the original front-facing robot hand render. These
+// landmarks describe visible proportions only; the clevis, pin, mate, and load
+// code below completes the physically missing mechanism.
+const referenceLandmarks = {
+ palm: {
+ width: 150,
+ depth: 18,
+ height: 96,
+ centerZ: 17,
+ baseZ: 76,
+ darkWrist: { width: 132, height: 17, centerZ: -39 },
+ lowerWrist: { width: 66, height: 28, centerZ: -59 },
+ wristBlock: { width: 58, height: 34, centerZ: -87 },
+ sidePods: [
+ { x: -70, z: -55, width: 24, height: 32 },
+ { x: 70, z: -55, width: 24, height: 32 },
+ ],
+ thumbShoulder: { x: 86, z: 16, width: 30, height: 56 },
+ },
+ actuatorWindows: [
+ { x: -34, z: 24, width: 14, height: 32 },
+ { x: 0, z: 30, width: 16, height: 30 },
+ { x: 34, z: 24, width: 14, height: 32 },
+ { x: -14, z: -14, width: 12, height: 25 },
+ { x: 14, z: -14, width: 12, height: 25 },
+ ],
+ screws: [
+ { x: -48, z: 53 }, { x: -31, z: 55 }, { x: -9, z: 49 }, { x: 9, z: 49 }, { x: 31, z: 55 }, { x: 48, z: 53 },
+ { x: -48, z: -4 }, { x: 48, z: -4 }, { x: -38, z: -24 }, { x: 38, z: -24 }, { x: -20, z: 8 }, { x: 20, z: 8 },
+ ],
+ tendons: [
+ { end: [-46, -67] },
+ { end: [-27, -68] },
+ { end: [-9, -70] },
+ { end: [9, -70] },
+ { end: [27, -68] },
+ { end: [46, -67] },
+ ],
+ fingers: [
+ { name: 'little', x: -62, lengths: [47, 35, 25], width: 13.0, angleDeg: -3, curl: [24, 34, 24], loads: [0.24, 0.16, 0.08] },
+ { name: 'ring', x: -23, lengths: [55, 40, 28], width: 14.5, angleDeg: -1, curl: [28, 38, 28], loads: [0.30, 0.20, 0.10] },
+ { name: 'middle', x: 18, lengths: [61, 44, 31], width: 15.0, angleDeg: 0, curl: [30, 40, 30], loads: [0.34, 0.23, 0.12] },
+ { name: 'index', x: 58, lengths: [54, 39, 28], width: 14.0, angleDeg: 3, curl: [27, 38, 27], loads: [0.32, 0.21, 0.11] },
+ { name: 'thumb', x: 108, mcpZ: 21, lengths: [37, 31, 24], width: 13.0, angleDeg: 38, curl: [22, 32, 24], loads: [0.28, 0.19, 0.10] },
+ ],
+};
+
+const PALM_X = referenceLandmarks.palm.width;
+const PALM_Y = referenceLandmarks.palm.depth;
+const PALM_Z = referenceLandmarks.palm.height;
+const PALM_FRONT_Y = -PALM_Y / 2;
+const BASE_Z = referenceLandmarks.palm.baseZ;
+const FINGER_Y = 12;
+const HINGE_Y = PALM_FRONT_Y - FINGER_Y;
+const PIP_PIVOT_OVERHANG = 8;
+const DIP_PIVOT_OVERHANG = 7;
+
+const graspTask = {
+ id: 'power-cylinder-grasp',
+ objectDiameterMm: 38,
+ objectDepthMm: 34,
+ holdForceN: 3,
+ contactNormalForceN: 4,
+};
+const graspCylinderCenter = [104, HINGE_Y - 42, BASE_Z + 112];
+const graspCylinderRadius = graspTask.objectDiameterMm / 2;
+
+const mcpStyle = {
+ knuckleR: 5.4,
+ forkGapY: 28,
+ tongueY: 5.2,
+ plateT: 2.4,
+ pinR: 1.3,
+ pinCapR: 3.6,
+ holeClearance: 0.45,
+ forkMaterial,
+ tongueMaterial,
+ pinMaterial,
+};
+const pipStyle = {
+ ...mcpStyle,
+ knuckleR: 6.4,
+ forkGapY: 28,
+ tongueY: 6.8,
+ plateT: 3.0,
+ pinR: 1.6,
+ pinCapR: 5.0,
+ pinCapThickness: 4.2,
+};
+const dipStyle = {
+ ...mcpStyle,
+ knuckleR: 5.2,
+ forkGapY: 23,
+ tongueY: 4.0,
+ plateT: 2.8,
+ pinR: 0.9,
+ pinCapR: 4.3,
+ holeClearance: 0.35,
+};
+
+const supportedGraspMcpNames = ['middle', 'index', 'thumb'];
+const palmConnectors = {};
+const palmMountConnectors = {};
+const assemblyTasks = [];
+const mcpDriveTasks = [];
+
+function plate(x, y, z, cx, cy, cz, material) {
+ return box(x, y, z, true).translate(cx, cy, cz).material(material);
+}
+
+function pointAlong(angleDeg, distance) {
+ const radians = angleDeg * Math.PI / 180;
+ return [Math.sin(radians) * distance, 0, Math.cos(radians) * distance];
+}
+
+function linkRod(a, b, width, depth, material, y = PALM_FRONT_Y - 1.2) {
+ const [x1, z1] = a;
+ const [x2, z2] = b;
+ const dx = x2 - x1;
+ const dz = z2 - z1;
+ const len = Math.sqrt(dx * dx + dz * dz);
+ const angle = Math.atan2(dx, dz) * 180 / Math.PI;
+ return box(width, depth, len, true)
+ .rotate([0, 1, 0], angle)
+ .translate((x1 + x2) / 2, y, (z1 + z2) / 2)
+ .material(material);
+}
+
+function screwHead(x, z, radius = 2.3) {
+ return cylinder(4, radius, 18)
+ .alongAxis([0, 1, 0])
+ .translate(x, PALM_FRONT_Y - 1.8, z)
+ .material(pinMaterial);
+}
+
+let palmGeometry = plate(PALM_X, PALM_Y, PALM_Z, 0, 0, referenceLandmarks.palm.centerZ, palmMaterial)
+ .union(plate(referenceLandmarks.palm.darkWrist.width, PALM_Y + 2, referenceLandmarks.palm.darkWrist.height, 0, 0, referenceLandmarks.palm.darkWrist.centerZ, darkMaterial))
+ .union(plate(referenceLandmarks.palm.lowerWrist.width, PALM_Y + 2, referenceLandmarks.palm.lowerWrist.height, 0, 0, referenceLandmarks.palm.lowerWrist.centerZ, { baseColor: '#55595d', metalness: 0, roughness: 0.5 }))
+ .union(plate(referenceLandmarks.palm.wristBlock.width, PALM_Y + 4, referenceLandmarks.palm.wristBlock.height, 0, 0, referenceLandmarks.palm.wristBlock.centerZ, { baseColor: '#3b3e40', metalness: 0, roughness: 0.52 }))
+ .union(plate(referenceLandmarks.palm.thumbShoulder.width, PALM_Y + 4, referenceLandmarks.palm.thumbShoulder.height, referenceLandmarks.palm.thumbShoulder.x, 0, referenceLandmarks.palm.thumbShoulder.z, palmMaterial));
+
+for (const pod of referenceLandmarks.palm.sidePods) {
+ palmGeometry = palmGeometry.union(plate(pod.width, PALM_Y + 4, pod.height, pod.x, 0, pod.z, darkMaterial));
+}
+
+// actuator window inserts are shallow overlaps into the palm face, not floating
+// labels. The bright rods overlap the insert and palm so disconnected-solid
+// review has real material continuity.
+for (const windowSpec of referenceLandmarks.actuatorWindows) {
+ const { x, z, width, height } = windowSpec;
+ palmGeometry = palmGeometry
+ .union(plate(width, 3.2, height, x, PALM_FRONT_Y - 1.0, z, darkMaterial))
+ .union(linkRod([x - 2, z - height * 0.32], [x + 2, z + height * 0.32], 1.8, 3.6, pinMaterial));
+}
+
+for (const { x, z } of referenceLandmarks.screws) {
+ palmGeometry = palmGeometry.union(screwHead(x, z));
+}
+
+for (const { end } of referenceLandmarks.tendons) {
+ const [x, z] = end;
+ palmGeometry = palmGeometry.union(linkRod([x * 0.40, -31], [x, z], 1.7, 3.6, pinMaterial));
+}
+
+function rootClearanceFor(width) {
+ return Math.max(25, width + 10);
+}
+
+function orient(shape, angleDeg) {
+ return angleDeg === 0 ? shape : shape.rotate([0, 1, 0], angleDeg, [0, 0, 0]);
+}
+
+function fingerLink(length, width, depth, material, angleDeg, rootClearance = rootClearanceFor(width)) {
+ const neckStart = 5;
+ const neckEnd = rootClearance + 2.5;
+ const rootNeck = box(width * 0.80, depth * 0.82, neckEnd - neckStart, true)
+ .translate(0, 0, (neckStart + neckEnd) / 2)
+ .material(material);
+ const core = box(width, depth, length, true)
+ .translate(0, 0, rootClearance + length / 2)
+ .material(material);
+ const leftRail = box(width * 0.22, depth * 0.94, length * 0.70, true)
+ .translate(-width * 0.34, 0, rootClearance + 5 + length * 0.36)
+ .material(material);
+ const rightRail = box(width * 0.22, depth * 0.94, length * 0.70, true)
+ .translate(width * 0.34, 0, rootClearance + 5 + length * 0.36)
+ .material(material);
+ const actuatorWindow = box(width * 0.54, 3.4, length * 0.40, true)
+ .translate(0, -depth / 2 - 1.1, rootClearance + length * 0.48)
+ .material(darkMaterial);
+ const actuatorRod = box(width * 0.10, 3.6, length * 0.62, true)
+ .translate(0, -depth / 2 - 1.3, rootClearance + length * 0.47)
+ .material(pinMaterial);
+ return orient(rootNeck.union(core).union(leftRail).union(rightRail).union(actuatorWindow).union(actuatorRod), angleDeg);
+}
+
+function distalPad(length, width, depth, angleDeg) {
+ const rootZ = dipStyle.knuckleR + 3;
+ const contactPadOverlap = 3.0;
+ const distalStructuralLen = length + 12;
+ const contactPadLen = Math.max(10, length * 0.45);
+ const structuralCenterZ = rootZ + distalStructuralLen / 2;
+ const contactPadCenterZ = rootZ + distalStructuralLen - contactPadLen / 2 + contactPadOverlap / 2;
+ const distalStructuralLink = box(width * 0.72, depth * 0.62, distalStructuralLen, true)
+ .translate(0, 0, structuralCenterZ)
+ .material(linkMaterial);
+ const contactPad = box(width * 0.90, depth * 0.74, contactPadLen, true)
+ .translate(0, 0, contactPadCenterZ)
+ .material(tipMaterial)
+ .union(
+ box(width * 0.70, 2.8, contactPadLen * 0.74, true)
+ .translate(0, -depth * 0.38, contactPadCenterZ + 1.0)
+ .material({ baseColor: '#070b10', metalness: 0.0, roughness: 0.58 }),
+ );
+ return orient(distalStructuralLink.union(contactPad), angleDeg);
+}
+
+function axisConnector(connectorData) {
+ return {
+ type: 'axis',
+ origin: { kind: 'vec3', value: connectorData.origin },
+ axis: connectorData.axis,
+ jointClearanceRadius: connectorData.clearanceRadius,
+ };
+}
+
+function frameConnector(origin) {
+ return {
+ type: 'frame',
+ origin: { kind: 'vec3', value: origin },
+ };
+}
+
+function supportRevolute(mate, shaft, output, around) {
+ hand.jointSupport(`${mate}-support`, {
+ mate,
+ shaft,
+ supports: [shaft],
+ output,
+ requiredSupport: {
+ kind: 'hinge-bracket',
+ around,
+ supports: [shaft],
+ minBearingLengthMm: 6,
+ },
+ });
+}
+
+function addPalmConnector(name, clevisData) {
+ palmConnectors[name] = {
+ origin: clevisData.parentConnector.origin,
+ axis: clevisData.parentConnector.axis,
+ clearanceRadius: clevisData.parentConnector.clearanceRadius,
+ };
+ palmMountConnectors[`${name}Mount`] = clevisData.parentConnector.origin;
+}
+
+function addFinger(spec) {
+ const [proxLen, midLen, distalLen] = spec.lengths;
+ const [mcpMax, pipMax, dipMax] = spec.curl;
+ const [mcpLoad, pipLoad, dipLoad] = spec.loads;
+ const mcpZ = spec.mcpZ === undefined ? BASE_Z : spec.mcpZ;
+ const proxRoot = rootClearanceFor(spec.width);
+ const midRoot = rootClearanceFor(spec.width * 0.86);
+ const straightPipPivot = [0, 0, proxRoot + proxLen + PIP_PIVOT_OVERHANG];
+ const straightDipPivot = [0, 0, midRoot + midLen + DIP_PIVOT_OVERHANG];
+ const straightTipFrame = [0, 0, dipStyle.knuckleR + 15 + distalLen];
+ const pipPivot = spec.angleDeg === 0 ? straightPipPivot : pointAlong(spec.angleDeg, straightPipPivot[2]);
+ const dipPivot = spec.angleDeg === 0 ? straightDipPivot : pointAlong(spec.angleDeg, straightDipPivot[2]);
+ const tipFrame = spec.angleDeg === 0 ? straightTipFrame : pointAlong(spec.angleDeg, straightTipFrame[2]);
+
+ const proximalRaw = fingerLink(proxLen, spec.width, FINGER_Y, linkMaterial, spec.angleDeg);
+ const middleRaw = fingerLink(midLen, spec.width * 0.86, FINGER_Y * 0.90, linkMaterial, spec.angleDeg);
+ const distalRaw = distalPad(distalLen, spec.width, FINGER_Y, spec.angleDeg);
+
+ const mcp = joint.clevis({
+ parentBody: palmGeometry,
+ childBody: proximalRaw,
+ axis: [1, 0, 0],
+ pivotParent: [spec.x, HINGE_Y, mcpZ],
+ pivotChild: [0, 0, 0],
+ limitsDeg: [0, mcpMax],
+ liftPivot: true,
+ style: mcpStyle,
+ });
+ palmGeometry = mcp.parentGeometry;
+ addPalmConnector(`${spec.name}Mcp`, mcp);
+ if (supportedGraspMcpNames.includes(spec.name)) {
+ const [mcpX, mcpY, mcpZLifted] = mcp.parentConnector.origin;
+ const servoMountZ = mcpZLifted - (spec.name === 'thumb' ? 11 : 14);
+ const servoPadDepth = 12;
+ palmGeometry = palmGeometry.union(plate(
+ 16,
+ servoPadDepth,
+ 10,
+ mcpX,
+ PALM_FRONT_Y - servoPadDepth / 2,
+ servoMountZ,
+ darkMaterial,
+ ));
+ palmMountConnectors[`${spec.name}McpServoMount`] = [
+ mcpX,
+ PALM_FRONT_Y - servoPadDepth,
+ servoMountZ,
+ ];
+ void mcpY;
+ }
+
+ const pip = joint.clevis({
+ parentBody: mcp.childGeometry,
+ childBody: middleRaw,
+ axis: [1, 0, 0],
+ pivotParent: pipPivot,
+ pivotChild: [0, 0, 0],
+ limitsDeg: [0, pipMax],
+ liftPivot: true,
+ style: pipStyle,
+ });
+
+ const dip = joint.clevis({
+ parentBody: pip.childGeometry,
+ childBody: distalRaw,
+ axis: [1, 0, 0],
+ pivotParent: dipPivot,
+ pivotChild: [0, 0, 0],
+ limitsDeg: [0, dipMax],
+ liftPivot: true,
+ style: dipStyle,
+ });
+
+ assemblyTasks.push(() => {
+ const proximal = hand
+ .part(`${spec.name}-proximal`, pip.parentGeometry, { density: 1180 })
+ .connector('mcp', axisConnector({
+ origin: mcp.childConnector.origin,
+ axis: mcp.childConnector.axis,
+ clearanceRadius: mcp.childConnector.clearanceRadius,
+ }))
+ .connector('pip', axisConnector({
+ origin: pip.parentConnector.origin,
+ axis: pip.parentConnector.axis,
+ clearanceRadius: pip.parentConnector.clearanceRadius,
+ }));
+
+ const middle = hand
+ .part(`${spec.name}-middle`, dip.parentGeometry, { density: 1180 })
+ .connector('pip', axisConnector({
+ origin: pip.childConnector.origin,
+ axis: pip.childConnector.axis,
+ clearanceRadius: pip.childConnector.clearanceRadius,
+ }))
+ .connector('dip', axisConnector({
+ origin: dip.parentConnector.origin,
+ axis: dip.parentConnector.axis,
+ clearanceRadius: dip.parentConnector.clearanceRadius,
+ }));
+
+ const distal = hand
+ .part(`${spec.name}-distal`, dip.childGeometry, { density: 1180 })
+ .connector('dip', axisConnector({
+ origin: dip.childConnector.origin,
+ axis: dip.childConnector.axis,
+ clearanceRadius: dip.childConnector.clearanceRadius,
+ }))
+ .connector('tip-frame', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: tipFrame },
+ });
+
+ hand.mate(`${spec.name}-mcp`, `palm-root.${spec.name}Mcp`, `${spec.name}-proximal.mcp`, 'revolute', {
+ pose: closeDeg.multiply(mcpMax / 32),
+ limitsDeg: [0, mcpMax],
+ maxLoad: { torque: mcpLoad },
+ });
+ hand.mate(`${spec.name}-pip`, `${spec.name}-proximal.pip`, `${spec.name}-middle.pip`, 'revolute', {
+ pose: closeDeg.multiply(pipMax / 42),
+ limitsDeg: [0, pipMax],
+ maxLoad: { torque: pipLoad },
+ });
+ hand.mate(`${spec.name}-dip`, `${spec.name}-middle.dip`, `${spec.name}-distal.dip`, 'revolute', {
+ pose: closeDeg.multiply(dipMax / 34),
+ limitsDeg: [0, dipMax],
+ maxLoad: { torque: dipLoad },
+ });
+
+ if (!supportedGraspMcpNames.includes(spec.name)) {
+ supportRevolute(`${spec.name}-mcp`, 'palm-root', `${spec.name}-proximal`, `palm-root.${spec.name}Mcp`);
+ }
+ supportRevolute(`${spec.name}-pip`, `${spec.name}-proximal`, `${spec.name}-middle`, `${spec.name}-proximal.pip`);
+ supportRevolute(`${spec.name}-dip`, `${spec.name}-middle`, `${spec.name}-distal`, `${spec.name}-middle.dip`);
+
+ if (supportedGraspMcpNames.includes(spec.name)) {
+ mcpDriveTasks.push(() => {
+ const servoName = `${spec.name}-mcp-servo`;
+
+ hand
+ .part(servoName,
+ box(10, 20, 6, true)
+ .translate(0, -10, 0)
+ .union(box(20, 13, 16, true).translate(0, -25, 0))
+ .union(cylinder(5, 4.5, 24).alongAxis([1, 0, 0]).translate(0, -32, 0))
+ .material(darkMaterial),
+ { density: 1350 },
+ )
+ .connector('mount', frameConnector([0, 0.8, 0]));
+
+ hand.mate(`${spec.name}-mcp-servo-fix`, `palm-root.${spec.name}McpServoMount`, `${servoName}.mount`, 'fastened');
+ hand.mechanicalJoint(`${spec.name}-mcp-drive`, {
+ mate: `${spec.name}-mcp`,
+ actuator: servoName,
+ shaft: 'palm-root',
+ supports: ['palm-root'],
+ output: `${spec.name}-proximal`,
+ requiredSupport: {
+ kind: 'hinge-bracket',
+ around: `palm-root.${spec.name}Mcp`,
+ supports: ['palm-root'],
+ minBearingLengthMm: 8,
+ },
+ });
+ });
+ }
+
+ void proximal;
+ void middle;
+ void distal;
+ });
+}
+
+referenceLandmarks.fingers.forEach(addFinger);
+
+const palm = hand.part('palm-root', palmGeometry, { density: 1180 });
+for (const [name, connectorData] of Object.entries(palmConnectors)) {
+ palm.connector(name, axisConnector(connectorData));
+}
+for (const [name, origin] of Object.entries(palmMountConnectors)) {
+ palm.connector(name, frameConnector(origin));
+}
+assemblyTasks.forEach((assemblyTask) => assemblyTask());
+mcpDriveTasks.forEach((driveTask) => driveTask());
+
+void palm;
+
+const graspCylinder = hand.part('grasp-cylinder',
+ cylinder(graspTask.objectDepthMm, graspCylinderRadius, 64)
+ .alongAxis([0, 1, 0])
+ .translate(graspCylinderCenter[0], graspCylinderCenter[1], graspCylinderCenter[2])
+ .material(targetMaterial),
+ { role: 'contact-target' },
+);
+graspCylinder
+ .connector('thumb-contact', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [graspCylinderCenter[0] + graspCylinderRadius, graspCylinderCenter[1], graspCylinderCenter[2] - 3] },
+ })
+ .connector('index-contact', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [graspCylinderCenter[0] - graspCylinderRadius * 0.45, graspCylinderCenter[1], graspCylinderCenter[2] + 10] },
+ })
+ .connector('middle-contact', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [graspCylinderCenter[0] - graspCylinderRadius * 0.65, graspCylinderCenter[1], graspCylinderCenter[2] - 10] },
+ });
+
+hand.physicalUseCase('power-cylinder-grasp', {
+ stableParts: ['palm-root'],
+ loads: [{ part: 'grasp-cylinder', force: [0, 0, -graspTask.holdForceN] }],
+ contacts: [
+ { a: 'grasp-cylinder.thumb-contact', b: 'thumb-distal.tip-frame', normal: [1, 0, 0], friction: 0.7, normalForceN: graspTask.contactNormalForceN },
+ { a: 'grasp-cylinder.index-contact', b: 'index-distal.tip-frame', normal: [-1, 0, 0], friction: 0.7, normalForceN: graspTask.contactNormalForceN },
+ { a: 'grasp-cylinder.middle-contact', b: 'middle-distal.tip-frame', normal: [-1, 0, 0], friction: 0.7, normalForceN: graspTask.contactNormalForceN },
+ ],
+ actuatorLimits: [
+ { mate: 'thumb-mcp', maxTorqueNmm: 650 },
+ { mate: 'index-mcp', maxTorqueNmm: 650 },
+ { mate: 'middle-mcp', maxTorqueNmm: 650 },
+ ],
+ criteria: { maxSlipMm: 8, settleTimeMs: 500 },
+});
+
+return hand.solvedModel({}, {
+ validate: 'error',
+ externalLoads: {
+ 'little-proximal': { torque: [0.06, 0, 0] },
+ 'little-middle': { torque: [0.035, 0, 0] },
+ 'little-distal': { torque: [0.018, 0, 0] },
+ 'ring-proximal': { torque: [0.08, 0, 0] },
+ 'ring-middle': { torque: [0.045, 0, 0] },
+ 'ring-distal': { torque: [0.022, 0, 0] },
+ 'middle-proximal': { torque: [0.09, 0, 0] },
+ 'middle-middle': { torque: [0.05, 0, 0] },
+ 'middle-distal': { torque: [0.025, 0, 0] },
+ 'index-proximal': { torque: [0.085, 0, 0] },
+ 'index-middle': { torque: [0.046, 0, 0] },
+ 'index-distal': { torque: [0.023, 0, 0] },
+ 'thumb-proximal': { torque: [0.07, 0, 0] },
+ 'thumb-middle': { torque: [0.04, 0, 0] },
+ 'thumb-distal': { torque: [0.02, 0, 0] },
+ },
+});
diff --git a/tests/fixtures/robot-hand/rejected-function-first-bar-grasp-skeleton.kcad.ts b/tests/fixtures/robot-hand/rejected-function-first-bar-grasp-skeleton.kcad.ts
new file mode 100644
index 000000000..f33679b19
--- /dev/null
+++ b/tests/fixtures/robot-hand/rejected-function-first-bar-grasp-skeleton.kcad.ts
@@ -0,0 +1,252 @@
+// Function-first bar grasp skeleton.
+//
+// This is deliberately not a finished hand render. It is a minimal mechanism
+// whose first contract is: three fingertips can reach declared contacts on a
+// horizontal bar, with supported joints and a declared physical use case.
+
+const gripDeg = param('gripDeg', 0, { min: 0, max: 24 });
+
+setCameraTarget(0, 0, 12);
+setCameraDistance(150);
+
+const hand = assembly('function-first bar-grasp skeleton');
+
+const materialPalm = { baseColor: '#7b7f83', metalness: 0.0, roughness: 0.48 };
+const materialFinger = { baseColor: '#d1c7ad', metalness: 0.0, roughness: 0.50 };
+const materialPad = { baseColor: '#1f2327', metalness: 0.0, roughness: 0.72 };
+const materialTarget = { baseColor: '#78aeca', metalness: 0.0, roughness: 0.42 };
+const materialDriver = { baseColor: '#2b333b', metalness: 0.25, roughness: 0.42 };
+
+const task = {
+ id: 'bar-grasp',
+ barRadiusMm: 8,
+ barLengthMm: 72,
+ holdForceN: 4,
+ contactNormalForceN: 3.5,
+};
+
+const palmZ = 10;
+const hingeGapY = 42;
+const fingerReach = hingeGapY - task.barRadiusMm;
+const fingerThickness = 5;
+const fingerWidth = 7;
+const padLen = 6;
+const driverAxis = [-42, 0, palmZ];
+const actuatorMount = [-42, -20, palmZ];
+const frameMount = [-42, 42, palmZ];
+const barCenter = [0, 0, palmZ];
+
+const contactTargets = [
+ { finger: 'thumb-finger', connector: 'thumb-contact', point: [0, -task.barRadiusMm, palmZ], normal: [0, -1, 0] },
+ { finger: 'index-finger', connector: 'index-contact', point: [-20, task.barRadiusMm, palmZ], normal: [0, 1, 0] },
+ { finger: 'middle-finger', connector: 'middle-contact', point: [20, task.barRadiusMm, palmZ], normal: [0, 1, 0] },
+];
+
+function frame(origin) {
+ return {
+ type: 'frame',
+ origin: { kind: 'vec3', value: origin },
+ };
+}
+
+function axis(origin) {
+ return {
+ type: 'axis',
+ origin: { kind: 'vec3', value: origin },
+ axis: [0, 0, 1],
+ jointClearanceRadius: 2.2,
+ };
+}
+
+function fingerBody(name, direction) {
+ const padY = direction * (fingerReach - padLen / 2);
+ const hingeBoss = cylinder(fingerThickness + 2, 4.5, 24)
+ .translate(0, 0, 0)
+ .material(materialFinger);
+ return hingeBoss
+ .union(box(fingerWidth, fingerReach, fingerThickness, true)
+ .translate(0, direction * fingerReach / 2, 0)
+ .material(materialFinger))
+ .union(
+ box(fingerWidth + 3, padLen, fingerThickness + 1, true)
+ .translate(0, padY, 0)
+ .material(materialPad),
+ );
+}
+
+const palm = hand.part(
+ 'palm',
+ box(88, 100, 8, true)
+ .translate(0, 0, palmZ - 4)
+ .material(materialPalm)
+ .union(box(18, 18, 12, true).translate(driverAxis[0], driverAxis[1], palmZ).material(materialDriver)),
+);
+palm
+ .connector('driver', axis(driverAxis))
+ .connector('actuator-mount', frame(actuatorMount))
+ .connector('frame-mount', frame(frameMount))
+ .connector('thumb-hinge', axis([0, -hingeGapY, palmZ]))
+ .connector('index-hinge', axis([-20, hingeGapY, palmZ]))
+ .connector('middle-hinge', axis([20, hingeGapY, palmZ]));
+
+const frameBase = hand.part(
+ 'frame-base',
+ box(16, 16, 10, true)
+ .translate(frameMount[0], frameMount[1], frameMount[2])
+ .material(materialPalm),
+);
+frameBase.connector('mount', frame(frameMount));
+
+const targetBar = hand.part(
+ 'target-bar',
+ cylinder(task.barLengthMm, task.barRadiusMm, 48)
+ .alongAxis([1, 0, 0])
+ .translate(barCenter[0], barCenter[1], barCenter[2])
+ .material(materialTarget),
+ { role: 'contact-target' },
+);
+targetBar
+ .connector('load-point', frame(barCenter))
+ .connector('thumb-contact', frame(contactTargets[0].point))
+ .connector('index-contact', frame(contactTargets[1].point))
+ .connector('middle-contact', frame(contactTargets[2].point));
+
+const gripDriver = hand.part(
+ 'grip-driver',
+ cylinder(8, 5, 28)
+ .translate(driverAxis[0], driverAxis[1], driverAxis[2])
+ .material(materialDriver),
+);
+gripDriver.connector('axis', axis(driverAxis));
+
+const gripActuator = hand.part(
+ 'grip-actuator',
+ box(16, 18, 12, true)
+ .translate(actuatorMount[0], actuatorMount[1], actuatorMount[2])
+ .material(materialDriver),
+);
+gripActuator.connector('mount', frame(actuatorMount));
+
+const thumbFinger = hand.part('thumb-finger', fingerBody('thumb-finger', 1));
+thumbFinger
+ .connector('hinge', axis([0, 0, 0]))
+ .connector('tip', frame([0, fingerReach, 0]));
+
+const indexFinger = hand.part('index-finger', fingerBody('index-finger', -1));
+indexFinger
+ .connector('hinge', axis([0, 0, 0]))
+ .connector('tip', frame([0, -fingerReach, 0]));
+
+const middleFinger = hand.part('middle-finger', fingerBody('middle-finger', -1));
+middleFinger
+ .connector('hinge', axis([0, 0, 0]))
+ .connector('tip', frame([0, -fingerReach, 0]));
+
+hand.mate('grip', 'palm.driver', 'grip-driver.axis', 'revolute', {
+ pose: gripDeg,
+ limitsDeg: [0, 24],
+});
+hand.mate('palm-frame-fix', 'frame-base.mount', 'palm.frame-mount', 'fastened');
+hand.mate('grip-actuator-fix', 'palm.actuator-mount', 'grip-actuator.mount', 'fastened');
+hand.mate('thumb-curl', 'palm.thumb-hinge', 'thumb-finger.hinge', 'revolute', {
+ pose: 0,
+ limitsDeg: [0, 24],
+});
+hand.mate('index-curl', 'palm.index-hinge', 'index-finger.hinge', 'revolute', {
+ pose: 0,
+ limitsDeg: [-24, 0],
+});
+hand.mate('middle-curl', 'palm.middle-hinge', 'middle-finger.hinge', 'revolute', {
+ pose: 0,
+ limitsDeg: [-24, 0],
+});
+
+hand.coupleMates('thumb-curl', { source: 'grip', ratio: 1 });
+hand.coupleMates('index-curl', { source: 'grip', ratio: -1 });
+hand.coupleMates('middle-curl', { source: 'grip', ratio: -1 });
+
+hand.jointSupport('thumb-curl-support', {
+ mate: 'thumb-curl',
+ shaft: 'palm',
+ supports: ['palm'],
+ output: 'thumb-finger',
+ requiredSupport: { kind: 'hinge-bracket', around: 'palm.thumb-hinge', supports: ['palm'], minBearingLengthMm: 6 },
+});
+hand.jointSupport('index-curl-support', {
+ mate: 'index-curl',
+ shaft: 'palm',
+ supports: ['palm'],
+ output: 'index-finger',
+ requiredSupport: { kind: 'hinge-bracket', around: 'palm.index-hinge', supports: ['palm'], minBearingLengthMm: 6 },
+});
+hand.jointSupport('middle-curl-support', {
+ mate: 'middle-curl',
+ shaft: 'palm',
+ supports: ['palm'],
+ output: 'middle-finger',
+ requiredSupport: { kind: 'hinge-bracket', around: 'palm.middle-hinge', supports: ['palm'], minBearingLengthMm: 6 },
+});
+
+hand.mechanicalJoint('grip-drive-support', {
+ mate: 'grip',
+ actuator: 'grip-actuator',
+ shaft: 'palm',
+ supports: ['palm'],
+ output: 'grip-driver',
+ requiredSupport: { kind: 'hinge-bracket', around: 'palm.driver', supports: ['palm'], minBearingLengthMm: 6 },
+});
+
+hand.transmission('thumb-drive-linkage', {
+ kind: 'direct-horn',
+ sourceMate: 'grip',
+ drivenMates: ['thumb-curl'],
+ actuator: 'grip-actuator',
+ input: 'grip-driver',
+ output: 'thumb-finger',
+ path: ['grip-driver', 'palm', 'thumb-finger'],
+ ratio: 1,
+});
+hand.transmission('index-drive-linkage', {
+ kind: 'direct-horn',
+ sourceMate: 'grip',
+ drivenMates: ['index-curl'],
+ actuator: 'grip-actuator',
+ input: 'grip-driver',
+ output: 'index-finger',
+ path: ['grip-driver', 'palm', 'index-finger'],
+ ratio: -1,
+});
+hand.transmission('middle-drive-linkage', {
+ kind: 'direct-horn',
+ sourceMate: 'grip',
+ drivenMates: ['middle-curl'],
+ actuator: 'grip-actuator',
+ input: 'grip-driver',
+ output: 'middle-finger',
+ path: ['grip-driver', 'palm', 'middle-finger'],
+ ratio: -1,
+});
+
+hand.physicalUseCase('bar-grasp', {
+ stableParts: ['palm'],
+ loads: [{ part: 'target-bar', at: 'target-bar.load-point', force: [0, 0, -task.holdForceN] }],
+ contacts: [
+ { a: 'thumb-finger.tip', b: 'target-bar.thumb-contact', normal: contactTargets[0].normal, friction: 0.75, normalForceN: task.contactNormalForceN },
+ { a: 'index-finger.tip', b: 'target-bar.index-contact', normal: contactTargets[1].normal, friction: 0.75, normalForceN: task.contactNormalForceN },
+ { a: 'middle-finger.tip', b: 'target-bar.middle-contact', normal: contactTargets[2].normal, friction: 0.75, normalForceN: task.contactNormalForceN },
+ ],
+ actuatorLimits: [{ mate: 'grip', maxTorqueNmm: 220 }],
+ criteria: { maxSlipMm: 1, settleTimeMs: 400 },
+});
+
+return hand.solvedModel({}, {
+ validate: 'warn',
+ ignore: [
+ ['palm', 'thumb-finger'],
+ ['palm', 'index-finger'],
+ ['palm', 'middle-finger'],
+ ['palm', 'grip-driver'],
+ ['palm', 'grip-actuator'],
+ ['palm', 'frame-base'],
+ ],
+});
diff --git a/tests/fixtures/robot-hand/rejected-function-first-three-finger-hand.kcad.ts b/tests/fixtures/robot-hand/rejected-function-first-three-finger-hand.kcad.ts
new file mode 100644
index 000000000..09fd9bf80
--- /dev/null
+++ b/tests/fixtures/robot-hand/rejected-function-first-three-finger-hand.kcad.ts
@@ -0,0 +1,333 @@
+// Function-first three-finger hand.
+//
+// This is a grasp testbed, not a visual hand copy. The model starts from one
+// task: power-cylinder grasp. The target object is a free grasp target,
+// and the three moving fingers use joint.clevis(...) so the revolute joints are
+// drilled clearance joints instead of overlapping decorative blocks.
+
+const gripDeg = param('gripDeg', 18, { min: 10, max: 36 });
+
+setCameraTarget(0, 0, 18);
+setCameraDistance(150);
+
+const task = {
+ id: 'power-cylinder',
+ objectDiameterMm: 30.5,
+ objectHeightMm: 30,
+ requiredApertureMm: 44,
+ contactNormalForceN: 3,
+};
+
+const targetR = task.objectDiameterMm / 2;
+const rightContactY = 12;
+const rightContactX = Math.sqrt(targetR * targetR - rightContactY * rightContactY);
+const thumbContactY = 4.2;
+const thumbContactX = -Math.sqrt(targetR * targetR - thumbContactY * thumbContactY);
+
+const contactTargets = [
+ { finger: 'thumb-finger', point: [thumbContactX, thumbContactY, 0], normal: [-1, 0, 0] },
+ { finger: 'index-finger', point: [rightContactX, rightContactY, 0], normal: [1, 0, 0] },
+ { finger: 'middle-finger', point: [rightContactX, -rightContactY, 0], normal: [1, 0, 0] },
+];
+
+const materialPalm = { baseColor: '#8d815d', metalness: 0.0, roughness: 0.55 };
+const materialFinger = { baseColor: '#a5481f', metalness: 0.0, roughness: 0.50 };
+const materialPad = { baseColor: '#262626', metalness: 0.0, roughness: 0.75 };
+const materialTarget = { baseColor: '#80b8d8', metalness: 0.0, roughness: 0.45 };
+const materialPin = { baseColor: '#c9ced1', metalness: 0.75, roughness: 0.28 };
+const materialDriver = { baseColor: '#20282f', metalness: 0.25, roughness: 0.40 };
+
+const palmT = 8;
+const palmTop = palmT;
+const clevisR = 7;
+const thumbFingerLen = 23;
+const opposingFingerLen = 29;
+const fingerW = 8;
+const fingerT = 6;
+const padLen = 5;
+const padW = 5;
+const padT = 5;
+const fingerRootClearance = clevisR + 15;
+const driverAxis = [0, -26, palmTop + 8];
+const targetMount = [0, 5.4, palmTop + 8];
+const targetContactZ = targetMount[2];
+const thumbHinge = [-63, 0, palmTop + 8];
+const indexHinge = [63, 7, palmTop + 8];
+const middleHinge = [63, -21, palmTop + 8];
+
+const clevisStyle = {
+ knuckleR: clevisR,
+ forkGapY: 10,
+ tongueY: 8,
+ plateT: 2.0,
+ pinR: 1.2,
+ pinCapR: 3.6,
+ holeClearance: 1.0,
+ forkMaterial: materialPalm,
+ tongueMaterial: materialFinger,
+ pinMaterial: materialPin,
+};
+
+function fingerReach(len) {
+ return fingerRootClearance + len;
+}
+
+function fingerContactReach(len) {
+ return fingerReach(len);
+}
+
+function fingerBody(name, dir, len) {
+ const rootStartX = clevisR;
+ const rootLen = fingerRootClearance + 2 - rootStartX;
+ const proximalLen = len * 0.48;
+ const distalLen = len * 0.34;
+ const segmentGap = 2.2;
+ const proximalCenterX = dir * (fingerRootClearance + proximalLen / 2);
+ const distalCenterX = dir * (fingerRootClearance + proximalLen + segmentGap + distalLen / 2);
+ const spineLen = len - padLen;
+ const spineCenterX = dir * (fingerRootClearance + spineLen / 2);
+ const padCenterX = dir * (fingerRootClearance + len - padLen / 2);
+ const knuckleBossX = dir * (fingerRootClearance + proximalLen + segmentGap / 2);
+
+ const rootShank = box(rootLen, 6, 4, true)
+ .translate(dir * (rootStartX + rootLen / 2), 0, 0);
+ const proximalLink = box(proximalLen, fingerW, fingerT, true)
+ .translate(proximalCenterX, 0, 0);
+ const distalLink = box(distalLen, fingerW - 1.5, fingerT - 0.6, true)
+ .translate(distalCenterX, 0, 0);
+ const dorsalSpine = box(spineLen, 3.2, 3.2, true)
+ .translate(spineCenterX, 0, 0.4);
+ const knuckleBoss = box(5.6, 12, 5.8, true)
+ .translate(knuckleBossX, 0, 0)
+ .material(materialFinger);
+ const contactPad = box(padLen, padW, padT, true)
+ .translate(padCenterX, 0, 0)
+ .material(materialPad);
+
+ return rootShank
+ .union(proximalLink)
+ .union(distalLink)
+ .union(dorsalSpine)
+ .union(knuckleBoss)
+ .union(contactPad)
+ .material(materialFinger);
+}
+
+function palmShell() {
+ const wristCuff = box(28, 44, palmT, true).translate(-48, -16, palmT / 2);
+ const metacarpalBridge = box(86, 46, palmT, true).translate(12, -4, palmT / 2);
+ const thumbSaddle = box(28, 24, palmT + 2, true).translate(-50, 12, palmT / 2 + 1);
+ const indexKnuckleBoss = box(22, 14, palmT + 3, true).translate(indexHinge[0] - 4, indexHinge[1], palmT / 2 + 1.5);
+ const middleKnuckleBoss = box(22, 14, palmT + 3, true).translate(middleHinge[0] - 4, middleHinge[1], palmT / 2 + 1.5);
+ const dorsalRib = box(68, 5, 4, true).translate(8, -28, palmTop + 2);
+ return wristCuff
+ .union(metacarpalBridge)
+ .union(thumbSaddle)
+ .union(indexKnuckleBoss)
+ .union(middleKnuckleBoss)
+ .union(dorsalRib)
+ .material(materialPalm);
+}
+
+function axisConnector(connectorSpec) {
+ return {
+ type: 'axis',
+ origin: { kind: 'vec3', value: connectorSpec.origin },
+ axis: connectorSpec.axis,
+ jointClearanceRadius: connectorSpec.clearanceRadius,
+ };
+}
+
+let palmGeometry = palmShell()
+ .union(box(22, 16, 8, true).translate(driverAxis[0], driverAxis[1], palmTop + 4))
+ .material(materialPalm);
+
+const thumbClevis = joint.clevis({
+ parentBody: palmGeometry,
+ childBody: fingerBody('thumb-finger', 1, thumbFingerLen),
+ axis: 'Z',
+ pivotParent: thumbHinge,
+ pivotChild: [0, 0, 0],
+ limitsDeg: [6, 36],
+ liftDir: [0, 1, 0],
+ style: clevisStyle,
+});
+palmGeometry = thumbClevis.parentGeometry;
+
+const indexClevis = joint.clevis({
+ parentBody: palmGeometry,
+ childBody: fingerBody('index-finger', -1, opposingFingerLen),
+ axis: 'Z',
+ pivotParent: indexHinge,
+ pivotChild: [0, 0, 0],
+ limitsDeg: [-36, -10],
+ liftDir: [0, 1, 0],
+ style: clevisStyle,
+});
+palmGeometry = indexClevis.parentGeometry;
+
+const middleClevis = joint.clevis({
+ parentBody: palmGeometry,
+ childBody: fingerBody('middle-finger', -1, opposingFingerLen),
+ axis: 'Z',
+ pivotParent: middleHinge,
+ pivotChild: [0, 0, 0],
+ limitsDeg: [-36, -6],
+ liftDir: [0, 1, 0],
+ style: clevisStyle,
+});
+palmGeometry = middleClevis.parentGeometry;
+
+const hand = assembly('function-first three-finger grasp testbed');
+
+const palm = hand.part('palm', palmGeometry);
+palm
+ .connector('driver', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: driverAxis },
+ axis: [0, 0, 1],
+ })
+ .connector('target-mount', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: targetMount },
+ })
+ .connector('thumb-hinge', axisConnector(thumbClevis.parentConnector))
+ .connector('index-hinge', axisConnector(indexClevis.parentConnector))
+ .connector('middle-hinge', axisConnector(middleClevis.parentConnector));
+
+const targetCylinder = hand.part(
+ 'target-cylinder',
+ cylinder(task.objectHeightMm, task.objectDiameterMm / 2, 48)
+ .translate(targetMount[0], targetMount[1], palmTop + 1)
+ .material(materialTarget),
+);
+targetCylinder.connector('mount', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: targetMount },
+})
+ .connector('thumb-contact', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [targetMount[0] + thumbContactX, targetMount[1] + thumbContactY, targetContactZ] },
+ })
+ .connector('index-contact', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [targetMount[0] + rightContactX, targetMount[1] + rightContactY, targetContactZ] },
+ })
+ .connector('middle-contact', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [targetMount[0] + rightContactX, targetMount[1] - rightContactY, targetContactZ] },
+ });
+
+const gripDriver = hand.part(
+ 'grip-driver',
+ cylinder(5, 5, 28)
+ .material(materialDriver),
+);
+gripDriver.connector('axis', {
+ type: 'axis',
+ origin: { kind: 'vec3', value: [0, 0, 0] },
+ axis: [0, 0, 1],
+});
+
+const thumbFinger = hand.part('thumb-finger', thumbClevis.childGeometry);
+thumbFinger
+ .connector('hinge', axisConnector(thumbClevis.childConnector))
+ .connector('tip', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [fingerContactReach(thumbFingerLen), 0, 0] },
+ })
+ .connector('contact-normal', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [fingerContactReach(thumbFingerLen), 0, 0] },
+ });
+
+const indexFinger = hand.part('index-finger', indexClevis.childGeometry);
+indexFinger
+ .connector('hinge', axisConnector(indexClevis.childConnector))
+ .connector('tip', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [-fingerContactReach(opposingFingerLen), 0, 0] },
+ })
+ .connector('contact-normal', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [-fingerContactReach(opposingFingerLen), 0, 0] },
+ });
+
+const middleFinger = hand.part('middle-finger', middleClevis.childGeometry);
+middleFinger
+ .connector('hinge', axisConnector(middleClevis.childConnector))
+ .connector('tip', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [-fingerContactReach(opposingFingerLen), 0, 0] },
+ })
+ .connector('contact-normal', {
+ type: 'frame',
+ origin: { kind: 'vec3', value: [-fingerContactReach(opposingFingerLen), 0, 0] },
+ });
+
+hand.mate('grip', 'palm.driver', 'grip-driver.axis', 'revolute', {
+ pose: gripDeg,
+ limitsDeg: [10, 36],
+});
+hand.mate('thumb-curl', 'palm.thumb-hinge', 'thumb-finger.hinge', 'revolute', { pose: 18, limitsDeg: [6, 36] });
+hand.mate('index-curl', 'palm.index-hinge', 'index-finger.hinge', 'revolute', { pose: -18, limitsDeg: [-36, -10] });
+hand.mate('middle-curl', 'palm.middle-hinge', 'middle-finger.hinge', 'revolute', { pose: -6, limitsDeg: [-36, -6] });
+
+hand.coupleMates('thumb-curl', { source: 'grip', ratio: 1 });
+hand.coupleMates('index-curl', { source: 'grip', ratio: -1 });
+hand.coupleMates('middle-curl', { source: 'grip', ratio: -1 });
+
+hand.transmission('thumb-drive-linkage', {
+ kind: 'link-rod',
+ sourceMate: 'grip',
+ drivenMates: ['thumb-curl'],
+ actuator: 'grip-driver',
+ input: 'grip-driver',
+ output: 'thumb-finger',
+ path: ['grip-driver', 'palm', 'thumb-finger'],
+ ratio: 1,
+ notes: `Task ${task.id}: drive thumb toward cylinder contact normal ${contactTargets[0].normal.join(',')}.`,
+});
+hand.transmission('index-drive-linkage', {
+ kind: 'link-rod',
+ sourceMate: 'grip',
+ drivenMates: ['index-curl'],
+ actuator: 'grip-driver',
+ input: 'grip-driver',
+ output: 'index-finger',
+ path: ['grip-driver', 'palm', 'index-finger'],
+ ratio: -1,
+ notes: `Task ${task.id}: drive index toward cylinder contact normal ${contactTargets[1].normal.join(',')}.`,
+});
+hand.transmission('middle-drive-linkage', {
+ kind: 'link-rod',
+ sourceMate: 'grip',
+ drivenMates: ['middle-curl'],
+ actuator: 'grip-driver',
+ input: 'grip-driver',
+ output: 'middle-finger',
+ path: ['grip-driver', 'palm', 'middle-finger'],
+ ratio: -1,
+ notes: `Task ${task.id}: drive middle toward cylinder contact normal ${contactTargets[2].normal.join(',')}.`,
+});
+
+hand.physicalUseCase('power-cylinder-grasp', {
+ stableParts: ['target-cylinder'],
+ loads: [{ part: 'target-cylinder', force: [0, 0, -4] }],
+ contacts: [
+ { a: 'thumb-finger.tip', b: 'target-cylinder.thumb-contact', normal: [-1, 0, 0], friction: 0.7, normalForceN: task.contactNormalForceN },
+ { a: 'index-finger.tip', b: 'target-cylinder.index-contact', normal: [1, 0, 0], friction: 0.7, normalForceN: task.contactNormalForceN },
+ { a: 'middle-finger.tip', b: 'target-cylinder.middle-contact', normal: [1, 0, 0], friction: 0.7, normalForceN: task.contactNormalForceN },
+ ],
+ actuatorLimits: [{ mate: 'grip', maxTorqueNmm: 180 }],
+ criteria: { maxSlipMm: 5, settleTimeMs: 500 },
+});
+
+return hand.solvedModel({}, {
+ validate: 'warn',
+ ignore: [
+ ['palm', 'thumb-finger'],
+ ['palm', 'index-finger'],
+ ['palm', 'middle-finger'],
+ ],
+});
diff --git a/tests/integration/examples/fiveFingerKinematicHand.test.ts b/tests/integration/examples/fiveFingerKinematicHand.test.ts
new file mode 100644
index 000000000..23aa74172
--- /dev/null
+++ b/tests/integration/examples/fiveFingerKinematicHand.test.ts
@@ -0,0 +1,176 @@
+import { readFileSync } from 'node:fs';
+import { describe, expect, it } from 'vitest';
+import { evaluateAndBuildScript } from '../../../src/agent/cli/commands/evaluate';
+import { checkInterference } from '../../../src/agent/script-runtime/checkInterference';
+import { reviewJointTopology } from '../../../src/modeling/mates/jointTopology';
+import {
+ reviewPhysicalUseCases,
+ reviewPhysicalUseCasesWithReachability,
+} from '../../../src/modeling/mates/physicalUseCase';
+
+const EXAMPLE_PATH = 'tests/fixtures/robot-hand/rejected-five-finger-kinematic-hand.kcad.ts';
+
+describe('rejected five-finger kinematic hand fixture', () => {
+ it('keeps visible proportions in a reference-landmark evidence layer', () => {
+ const source = readFileSync(EXAMPLE_PATH, 'utf8');
+
+ expect(source).toContain('const referenceLandmarks =');
+ expect(source).toContain('referenceLandmarks.fingers.forEach(addFinger)');
+ expect(source).toContain('referenceLandmarks.actuatorWindows');
+ expect(source).toContain('referenceLandmarks.tendons');
+ expect(source).toContain('referenceLandmarks.screws');
+ expect(source).toContain('angleDeg: 38');
+ expect(source).not.toContain('].forEach(addFinger)');
+ });
+
+ it('rejects interference certification when a palm subtraction removes no material', async () => {
+ const result = await checkInterference({
+ fileName: EXAMPLE_PATH,
+ code: readFileSync(EXAMPLE_PATH, 'utf8'),
+ });
+ expect(result.diagnostics).toEqual(expect.arrayContaining([
+ expect.objectContaining({ code: 'feature.subtractive-noop', severity: 'error' }),
+ ]));
+ }, 120_000);
+
+ it('captures five-finger clevis and support intent while keeping lowering rejected', async () => {
+ const source = readFileSync(EXAMPLE_PATH, 'utf8');
+
+ expect(source.match(/=\s*joint\.clevis\s*\(/g)?.length ?? 0).toBe(3);
+ expect(source.match(/maxLoad:\s*\{\s*torque:/g)?.length ?? 0).toBe(3);
+ expect(source).toMatch(/validate:\s*'error'/);
+ expect(source).toMatch(/externalLoads:/);
+ expect(source).not.toMatch(/\bignore\s*:/);
+ expect(source).not.toContain("exposure: 'concealed'");
+ expect(source).not.toMatch(/\btype\s+\w+/);
+ expect(source).not.toMatch(/\bas\s+FingerSpec\b/);
+ expect(source).toContain('const mcpZ = spec.mcpZ === undefined ? BASE_Z : spec.mcpZ');
+ expect(source).toContain('pivotParent: [spec.x, HINGE_Y, mcpZ]');
+ expect(source).toContain('axis: [1, 0, 0]');
+ expect(source).toContain('const straightPipPivot = [0, 0, proxRoot + proxLen + PIP_PIVOT_OVERHANG]');
+ expect(source).toContain('const straightDipPivot = [0, 0, midRoot + midLen + DIP_PIVOT_OVERHANG]');
+ expect(source).toContain("const straightTipFrame = [0, 0, dipStyle.knuckleR + 15 + distalLen]");
+ expect(source).toContain('pointAlong(spec.angleDeg');
+ expect(source).toContain('distalStructuralLink');
+ expect(source).toContain('contactPadOverlap');
+ expect(source).toContain("hand.part('grasp-cylinder'");
+ expect(source).toContain("hand.physicalUseCase('power-cylinder-grasp'");
+ expect(source).toContain("hand.mechanicalJoint(`${spec.name}-mcp-drive`");
+ expect(source).toContain('normalForceN');
+ expect(source).not.toMatch(/\bspec\.baseZ\b/);
+ for (const finger of ['little', 'ring', 'middle', 'index', 'thumb']) {
+ expect(source).toContain(`name: '${finger}'`);
+ expect(source).toContain(`'${finger}-proximal'`);
+ expect(source).toContain(`'${finger}-middle'`);
+ expect(source).toContain(`'${finger}-distal'`);
+ }
+
+ const evaluated = await evaluateAndBuildScript({ file: EXAMPLE_PATH, code: source });
+
+ expect(evaluated.evaluation.exitCode).toBe(1);
+ expect(evaluated.evaluation.diagnostics).toEqual(expect.arrayContaining([
+ expect.objectContaining({ code: 'feature.subtractive-noop', severity: 'error' }),
+ ]));
+ const assembly = evaluated.model?.session.assemblies.get('front-facing-five-finger-robot-hand');
+ expect(assembly).toBeTruthy();
+ expect(assembly?.__parts().map((part) => part.name)).toEqual([
+ 'palm-root',
+ 'little-proximal', 'little-middle', 'little-distal',
+ 'ring-proximal', 'ring-middle', 'ring-distal',
+ 'middle-proximal', 'middle-middle', 'middle-distal',
+ 'index-proximal', 'index-middle', 'index-distal',
+ 'thumb-proximal', 'thumb-middle', 'thumb-distal',
+ 'middle-mcp-servo',
+ 'index-mcp-servo',
+ 'thumb-mcp-servo',
+ 'grasp-cylinder',
+ ]);
+ expect(assembly?.__mates().filter((mate) => mate.type === 'revolute')).toHaveLength(15);
+ expect(assembly?.__mates().filter((mate) => mate.type === 'fastened')).toHaveLength(3);
+ expect(assembly?.__mechanicalJointIntents().map((intent) => intent.name)).toEqual([
+ 'middle-mcp-drive',
+ 'index-mcp-drive',
+ 'thumb-mcp-drive',
+ ]);
+ expect(assembly?.__physicalUseCases()[0]?.actuatorLimits.map((limit) => limit.mate)).toEqual([
+ 'thumb-mcp',
+ 'index-mcp',
+ 'middle-mcp',
+ ]);
+ const physicalUseCaseReview = reviewPhysicalUseCases(assembly!, { requirePhysicalUseCase: true });
+ expect(physicalUseCaseReview.checkedUseCaseCount).toBe(1);
+ const unsupportedActuators = physicalUseCaseReview.diagnostics.filter((diagnostic) =>
+ diagnostic.code === 'assembly.physical-use-case.actuator-support-missing',
+ );
+ expect(unsupportedActuators).toEqual([]);
+ const closed = await evaluateAndBuildScript({
+ file: EXAMPLE_PATH,
+ code: source.replace("param('closeDeg', 22,", "param('closeDeg', 32,"),
+ });
+
+ expect(closed.evaluation.exitCode).toBe(1);
+ expect(closed.evaluation.diagnostics).toEqual(expect.arrayContaining([
+ expect.objectContaining({ code: 'feature.subtractive-noop', severity: 'error' }),
+ ]));
+ }, 240_000);
+
+ it('rejects the current hand on physical use case reachability', async () => {
+ const evaluated = await evaluateAndBuildScript({
+ file: EXAMPLE_PATH,
+ code: readFileSync(EXAMPLE_PATH, 'utf8'),
+ });
+ const assembly = evaluated.model?.session.assemblies.get('front-facing-five-finger-robot-hand');
+ expect(assembly).toBeTruthy();
+
+ const result = await reviewPhysicalUseCasesWithReachability(assembly!, {
+ requirePhysicalUseCase: true,
+ includeReachability: true,
+ reachabilitySamplesPerMate: 3,
+ });
+
+ const unreachableContacts = result.diagnostics.filter((diagnostic) =>
+ diagnostic.code === 'assembly.physical-use-case.contact-unreachable'
+ );
+ expect(unreachableContacts.map((diagnostic) =>
+ 'contactA' in diagnostic ? diagnostic.contactA : ''
+ )).toEqual([
+ 'grasp-cylinder.thumb-contact',
+ 'grasp-cylinder.index-contact',
+ 'grasp-cylinder.middle-contact',
+ ]);
+ }, 240_000);
+
+ it('keeps topology support declarations complete while reachability remains blocking', async () => {
+ const evaluated = await evaluateAndBuildScript({
+ file: EXAMPLE_PATH,
+ code: readFileSync(EXAMPLE_PATH, 'utf8'),
+ });
+ const assembly = evaluated.model?.session.assemblies.get('front-facing-five-finger-robot-hand');
+ expect(assembly).toBeTruthy();
+ if (assembly === undefined) throw new Error('front-facing-five-finger-robot-hand assembly was not captured');
+
+ const topologyReview = reviewJointTopology(assembly);
+
+ expect(topologyReview.diagnostics).toEqual([]);
+ }, 240_000);
+
+ it('preserves the original front-facing visual intent markers', async () => {
+ const source = readFileSync(EXAMPLE_PATH, 'utf8');
+
+ expect(source).toMatch(/actuator window/i);
+ expect(source).toMatch(/tendon/i);
+ expect(source).toMatch(/wrist block/i);
+ expect(source).toMatch(/angled thumb/i);
+ expect(source).toContain('const referenceLandmarks =');
+ expect(source).toContain('referenceLandmarks.fingers.forEach(addFinger)');
+ expect(source).toContain('referenceLandmarks.actuatorWindows');
+ expect(source).toContain('referenceLandmarks.tendons');
+ expect(source).toContain('referenceLandmarks.screws');
+ expect(source).toContain('angleDeg: 38');
+ expect(source).not.toContain('].forEach(addFinger)');
+ expect(source).not.toContain('return parts');
+ expect(source).not.toContain('parts.push');
+ expect(source).not.toContain('addPart(');
+ expect(source).not.toContain("exposure: 'concealed'");
+ });
+});
diff --git a/tests/integration/examples/functionFirstBarGraspSkeleton.test.ts b/tests/integration/examples/functionFirstBarGraspSkeleton.test.ts
new file mode 100644
index 000000000..02daff159
--- /dev/null
+++ b/tests/integration/examples/functionFirstBarGraspSkeleton.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, it } from 'vitest';
+import { readFile } from 'node:fs/promises';
+import { dirname, resolve as resolvePath } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { evaluateAndBuildScript } from '../../../src/agent/cli/commands/evaluate';
+import { reviewCadTool } from '../../../src/agent/mcp/tools/reviewCad';
+import { runScript } from '../../../src/modeling/runtime/runScript';
+import { Scene } from '../../../src/modeling/validation/scene';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const EXAMPLE_PATH = 'tests/fixtures/robot-hand/rejected-function-first-bar-grasp-skeleton.kcad.ts';
+const EXAMPLE_ABSOLUTE = resolvePath(__dirname, '../../..', EXAMPLE_PATH);
+
+describe('function-first bar grasp skeleton example', () => {
+ it('evaluates as a minimal mechanism built from bar-contact requirements', async () => {
+ const result = await evaluateAndBuildScript({ file: EXAMPLE_PATH });
+
+ expect(result.evaluation.exitCode).toBe(0);
+ expect(result.evaluation.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]);
+
+ const source = await readFile(EXAMPLE_ABSOLUTE, 'utf8');
+ expect(source).toContain('bar-grasp');
+ expect(source).toContain('target-bar');
+ expect(source).toContain('contactTargets');
+ expect(source).toContain('jointSupport');
+ expect(source).toContain('mechanicalJoint');
+ expect(source).toContain('physicalUseCase');
+
+ const { returnValue } = await runScript({
+ code: source,
+ fileName: EXAMPLE_ABSOLUTE,
+ scriptDir: dirname(EXAMPLE_ABSOLUTE),
+ });
+
+ expect(returnValue).toBeInstanceOf(Scene);
+ const scene = returnValue as Scene;
+ expect(scene.part('target-bar').connectors?.map((connector) => connector.name)).toEqual(expect.arrayContaining([
+ 'load-point',
+ 'thumb-contact',
+ 'index-contact',
+ 'middle-contact',
+ ]));
+ expect(scene.mates?.filter((mate) => mate.type === 'revolute').map((mate) => mate.name)).toEqual([
+ 'grip',
+ 'thumb-curl',
+ 'index-curl',
+ 'middle-curl',
+ ]);
+ }, 120_000);
+
+ it('passes review_cad for supported reachable bar contacts', async () => {
+ const result = await reviewCadTool({
+ file: EXAMPLE_PATH,
+ includeInterference: false,
+ requirePhysicalUseCase: true,
+ includePhysicalUseCaseReachability: true,
+ includePhysicalUseCaseStatics: true,
+ physicalUseCaseReachabilitySamplesPerMate: 3,
+ trackConnectors: ['thumb-finger.tip', 'index-finger.tip', 'middle-finger.tip'],
+ gripperAperture: { left: 'thumb-finger.tip', right: 'index-finger.tip' },
+ });
+
+ expect(result.ok).toBe(true);
+ expect(result.fitness?.blockingReasons).toEqual([]);
+ expect(result.diagnostics.filter((diagnostic) =>
+ diagnostic.severity === 'error'
+ )).toEqual([]);
+ expect(result.gripperAperture?.travelMm).toBeGreaterThan(8);
+ expect(result.fitness?.passedChecks).toContain('gripper-aperture-moves');
+ expect(result.physicalUseCaseStaticCertificates).toEqual([
+ expect.objectContaining({
+ useCaseName: 'bar-grasp',
+ heldPart: 'target-bar',
+ }),
+ ]);
+ }, 180_000);
+
+ it('does not present the hand-built bar hinges as structurally certified', async () => {
+ const result = await reviewCadTool({
+ file: EXAMPLE_PATH,
+ includeInterference: false,
+ requirePhysicalUseCase: true,
+ includePhysicalUseCaseJointReactions: true,
+ includePhysicalUseCaseJointStructure: true,
+ physicalUseCaseReachabilitySamplesPerMate: 3,
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.physicalUseCaseJointReactionCertificates).toEqual([
+ expect.objectContaining({ useCaseName: 'bar-grasp' }),
+ ]);
+ expect(result.diagnostics).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ code: 'assembly.physical-use-case.joint-capacity-undeclared',
+ }),
+ expect.objectContaining({
+ code: 'assembly.physical-use-case.joint-structure-input-incomplete',
+ }),
+ ]));
+ expect(result.physicalUseCaseJointStructuralCertificates?.[0].joints).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ envelope: expect.objectContaining({ status: 'undeclared' }),
+ }),
+ ]),
+ );
+ }, 180_000);
+});
diff --git a/tests/integration/examples/functionFirstThreeFingerHand.test.ts b/tests/integration/examples/functionFirstThreeFingerHand.test.ts
new file mode 100644
index 000000000..66b89634d
--- /dev/null
+++ b/tests/integration/examples/functionFirstThreeFingerHand.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from 'vitest';
+import { readFile } from 'node:fs/promises';
+import { dirname, resolve as resolvePath } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { evaluateAndBuildScript } from '../../../src/agent/cli/commands/evaluate';
+import { reviewCadTool } from '../../../src/agent/mcp/tools/reviewCad';
+import { runScript } from '../../../src/modeling/runtime/runScript';
+import { Scene } from '../../../src/modeling/validation/scene';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const EXAMPLE_PATH = 'tests/fixtures/robot-hand/rejected-function-first-three-finger-hand.kcad.ts';
+const EXAMPLE_ABSOLUTE = resolvePath(__dirname, '../../..', EXAMPLE_PATH);
+
+describe('rejected function-first three-finger robot hand fixture', () => {
+ it('preserves the grasp requirements while rejecting its no-op palm subtraction', async () => {
+ const result = await evaluateAndBuildScript({ file: EXAMPLE_PATH });
+
+ expect(result.evaluation.exitCode).toBe(1);
+ expect(result.evaluation.diagnostics).toEqual(expect.arrayContaining([
+ expect.objectContaining({ code: 'feature.subtractive-noop', severity: 'error' }),
+ ]));
+
+ const source = await readFile(EXAMPLE_ABSOLUTE, 'utf8');
+ expect(source).toContain('power-cylinder');
+ expect(source).toContain('contactTargets');
+ expect(source).toContain('target-cylinder');
+ expect(source).toContain('contact normal');
+ expect(source).toContain('normalForceN');
+ expect(source).toContain('palmShell');
+ expect(source).toContain('thumbSaddle');
+ expect(source).toContain('proximalLen');
+ expect(source).toContain('distalLen');
+ expect(source).toContain('knuckleBoss');
+
+ const { returnValue } = await runScript({
+ code: source,
+ fileName: EXAMPLE_ABSOLUTE,
+ scriptDir: dirname(EXAMPLE_ABSOLUTE),
+ });
+
+ expect(returnValue).toBeInstanceOf(Scene);
+ const scene = returnValue as Scene;
+ expect(scene.part('palm').connectors?.some((connector) => connector.name === 'target-mount')).toBe(true);
+ expect(scene.part('target-cylinder').connectors?.some((connector) => connector.name === 'mount')).toBe(true);
+ expect(scene.part('thumb-finger').connectors?.some((connector) => connector.name === 'tip')).toBe(true);
+ expect(scene.part('index-finger').connectors?.some((connector) => connector.name === 'tip')).toBe(true);
+ expect(scene.part('middle-finger').connectors?.some((connector) => connector.name === 'tip')).toBe(true);
+ expect(scene.mates?.filter((mate) => mate.type === 'fastened').map((mate) => mate.name)).not.toContain('target-fixture');
+ expect(scene.mates?.filter((mate) => mate.type === 'revolute').map((mate) => mate.name)).toEqual([
+ 'grip',
+ 'thumb-curl',
+ 'index-curl',
+ 'middle-curl',
+ ]);
+ }, 120_000);
+
+ it('keeps the lowering failure blocking in review_cad', async () => {
+ const result = await reviewCadTool({
+ file: EXAMPLE_PATH,
+ includeInterference: true,
+ trackConnectors: ['thumb-finger.tip', 'index-finger.tip', 'middle-finger.tip'],
+ gripperAperture: { left: 'thumb-finger.tip', right: 'index-finger.tip' },
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.diagnostics).toEqual(expect.arrayContaining([
+ expect.objectContaining({ code: 'feature.subtractive-noop', severity: 'error' }),
+ ]));
+ expect(result.fitness).toBeUndefined();
+ }, 180_000);
+});
diff --git a/tests/integration/examples/robotHandFunctionalRequirements.test.ts b/tests/integration/examples/robotHandFunctionalRequirements.test.ts
new file mode 100644
index 000000000..9c6d2a3ae
--- /dev/null
+++ b/tests/integration/examples/robotHandFunctionalRequirements.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import {
+ FUNCTION_FIRST_ROBOT_HAND_PRINCIPLES,
+ ROBOT_HAND_GRASP_TASKS,
+ ROBOT_HAND_ACCEPTANCE_GATES,
+ summarizeRobotHandFunctionalBrief,
+} from '../../../scripts/robotHandFunctionalRequirements';
+
+describe('function-first robot hand requirements', () => {
+ it('starts from grasp tasks instead of visual hand appearance', () => {
+ expect(FUNCTION_FIRST_ROBOT_HAND_PRINCIPLES[0]).toMatch(/function before form/i);
+ expect(ROBOT_HAND_GRASP_TASKS.map((task) => task.id)).toEqual([
+ 'pinch-thin-plate',
+ 'power-cylinder',
+ 'spherical-object',
+ 'box-grasp',
+ 'hook-handle',
+ 'wide-object',
+ ]);
+ });
+
+ it('defines measurable acceptance gates for every grasp task', () => {
+ for (const task of ROBOT_HAND_GRASP_TASKS) {
+ expect(task.object).toBeTruthy();
+ expect(task.contacts.length).toBeGreaterThanOrEqual(2);
+ expect(task.requiredChecks).toEqual(expect.arrayContaining([
+ 'reachable-contact-points',
+ 'joint-limits-respected',
+ 'no-self-collision',
+ 'load-path-to-palm',
+ ]));
+ }
+
+ expect(ROBOT_HAND_ACCEPTANCE_GATES).toEqual(expect.arrayContaining([
+ 'grasp-aperture-covers-target-object',
+ 'opposing-contact-normals-resist-escape',
+ 'pose-envelope-has-no-breaking-collisions',
+ 'all-loaded-parts-are-in-mate-graph',
+ 'actuation-path-has-anchored-transmission',
+ ]));
+ });
+
+ it('summarizes the recommended first artifact as a three-finger functional hand', () => {
+ const brief = summarizeRobotHandFunctionalBrief();
+
+ expect(brief.firstArtifact).toBe('three-finger functional hand');
+ expect(brief.deferred).toContain('five-finger visual styling');
+ expect(brief.why).toMatch(/grasp tests/i);
+ });
+});
diff --git a/tests/integration/examples/robotHandWorkflowCandidateModels.test.ts b/tests/integration/examples/robotHandWorkflowCandidateModels.test.ts
new file mode 100644
index 000000000..d841c85eb
--- /dev/null
+++ b/tests/integration/examples/robotHandWorkflowCandidateModels.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest';
+import { evaluateAndBuildScript } from '../../../src/agent/cli/commands/evaluate';
+
+const EXAMPLE_PATH = 'examples/robot-hand/workflow-candidates-comparison.kcad.ts';
+
+describe('robot hand workflow candidate models', () => {
+ it('builds the five actual visual candidate models without relying on sketch fonts', async () => {
+ const result = await evaluateAndBuildScript({ file: EXAMPLE_PATH });
+
+ expect(result.evaluation.exitCode).toBe(0);
+ expect(result.evaluation.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]);
+ expect(result.evaluation.featureCount).toBeGreaterThan(250);
+ }, 60_000);
+});
diff --git a/tests/integration/examples/robotHandWorkflowComparison.test.ts b/tests/integration/examples/robotHandWorkflowComparison.test.ts
new file mode 100644
index 000000000..e1b9d4e2b
--- /dev/null
+++ b/tests/integration/examples/robotHandWorkflowComparison.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+import {
+ compareRobotHandWorkflows,
+ ROBOT_HAND_WORKFLOW_WEIGHTS,
+} from '../../../scripts/robotHandWorkflowCompare';
+
+describe('robot hand workflow comparison', () => {
+ it('scores all five workflow options against the same robot hand target', () => {
+ const result = compareRobotHandWorkflows();
+
+ expect(result.weights).toEqual(ROBOT_HAND_WORKFLOW_WEIGHTS);
+ expect(result.candidates.map((candidate) => candidate.id).sort()).toEqual([
+ 'mechanism-templates',
+ 'reference-conditioned',
+ 'mesh-feature-fitting',
+ 'master-skeleton',
+ 'validation-loop',
+ ].sort());
+ expect(result.candidates.every((candidate) => candidate.weightedScore >= 0 && candidate.weightedScore <= 100)).toBe(true);
+ });
+
+ it('does not treat validation as a standalone generator', () => {
+ const result = compareRobotHandWorkflows();
+ const validation = result.candidates.find((candidate) => candidate.id === 'validation-loop');
+
+ expect(validation?.role).toBe('validator');
+ expect(validation?.builds).toContain('acceptance gates');
+ expect(validation?.caveat).toMatch(/not a generator/i);
+ });
+
+ it('recommends the hybrid path that preserves reference fit and physical validation', () => {
+ const result = compareRobotHandWorkflows();
+
+ expect(result.bestIndividual.id).toBe('reference-conditioned');
+ expect(result.recommendedCombination.ids).toEqual([
+ 'reference-conditioned',
+ 'master-skeleton',
+ 'validation-loop',
+ ]);
+ expect(result.recommendedCombination.reason).toMatch(/visible fit/i);
+ expect(result.recommendedCombination.reason).toMatch(/stable parametrics/i);
+ expect(result.recommendedCombination.reason).toMatch(/physical acceptance/i);
+ });
+});
diff --git a/tests/integration/examples/twoFingerCoupledGripper.test.ts b/tests/integration/examples/twoFingerCoupledGripper.test.ts
index ef7248c0e..8e0274ceb 100644
--- a/tests/integration/examples/twoFingerCoupledGripper.test.ts
+++ b/tests/integration/examples/twoFingerCoupledGripper.test.ts
@@ -32,7 +32,7 @@ describe('two-finger coupled gripper example', () => {
expect(scene.part('right-finger').connectors?.some((connector) => connector.name === 'tip')).toBe(true);
}, 120_000);
- it('passes review_cad with gripper aperture travel', async () => {
+ it('is rejected until its legacy pin-axis joints model finite supported hinges', async () => {
const result = await reviewCadTool({
file: EXAMPLE_PATH,
includeInterference: false,
@@ -40,12 +40,11 @@ describe('two-finger coupled gripper example', () => {
gripperAperture: { left: 'left-finger.tip', right: 'right-finger.tip' },
});
- expect(result.ok).toBe(true);
- if (result.ok) {
- expect(result.gripperAperture?.travelMm).toBeGreaterThan(10);
- expect(result.fitness.passedChecks).toContain('gripper-aperture-moves');
- expect(result.fitness.mechanismSummary.gripperApertureTravelMm).toBeGreaterThan(10);
- }
+ expect(result.ok).toBe(false);
+ expect(result.diagnostics).toEqual(expect.arrayContaining([
+ expect.objectContaining({ code: 'assembly.joint-topology.missing-limit' }),
+ expect.objectContaining({ code: 'assembly.joint-topology.unsupported-axis' }),
+ ]));
}, 180_000);
// P3 physics-loop sweep (2026-06-01): two-finger-coupled-gripper
diff --git a/tests/integration/mcp/designLoop.test.ts b/tests/integration/mcp/designLoop.test.ts
index 16bbd57e9..f9a8a6847 100644
--- a/tests/integration/mcp/designLoop.test.ts
+++ b/tests/integration/mcp/designLoop.test.ts
@@ -129,10 +129,16 @@ describe('design_loop MCP tool', () => {
box(20, 20, 4, true)
.union(box(10, 10, 10, true).translate(80, 0, 0))
)
- .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 2] }, axis: [0, 0, 1] });
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 2] }, axis: [0, 0, 1] })
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 2] } });
arm.part('link', box(30, 8, 6, true))
.connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('root', box(8, 8, 8, true).translate(0, 0, 2))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 2] } })
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 2] }, axis: [0, 0, 1] });
+ arm.mate('base-root', 'root.mount', 'base.mount', 'fastened');
arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ arm.mechanicalJoint('yaw-support', { mate: 'yaw', actuator: 'root', shaft: 'root', supports: ['root'], output: 'link' });
return arm.model();
`,
},
@@ -142,10 +148,16 @@ describe('design_loop MCP tool', () => {
code: `
const arm = assembly('clean-bracket');
arm.part('base', box(30, 20, 6, true))
- .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] })
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } });
arm.part('link', box(30, 8, 6, true))
.connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('root', box(8, 8, 8, true).translate(0, 0, 3))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } })
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ arm.mate('base-root', 'root.mount', 'base.mount', 'fastened');
arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ arm.mechanicalJoint('yaw-support', { mate: 'yaw', actuator: 'root', shaft: 'root', supports: ['root'], output: 'link' });
return arm.model();
`,
visualReview: {
@@ -185,7 +197,8 @@ describe('design_loop MCP tool', () => {
.union(box(20, 20, 20, true).translate(0, 0, 13))
.union(box(16, 8, 16, true).translate(0, 10, 30.5))
.union(box(16, 8, 16, true).translate(0, -10, 30.5))
- ).connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 24] }, axis: [0, 0, 1] });
+ ).connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 24] }, axis: [0, 0, 1] })
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 24] } });
const link = arm.part('link',
box(60, 10, 8, true).translate(30, 0, 0)
.union(box(8, 8, 12, true).translate(0, 0, 0))
@@ -194,7 +207,12 @@ describe('design_loop MCP tool', () => {
.union(box(20, 4, 4, true).translate(30, 0, 5.5))
.union(box(8, 8, 8, true).translate(63, 0, 0))
).connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('root', box(8, 8, 8, true).translate(0, 0, 24))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 24] } })
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 24] }, axis: [0, 0, 1] });
+ arm.mate('base-root', 'root.mount', 'base.mount', 'fastened');
arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ arm.mechanicalJoint('yaw-support', { mate: 'yaw', actuator: 'root', shaft: 'root', supports: ['root'], output: 'link' });
return arm.model();
`;
@@ -203,11 +221,17 @@ describe('design_loop MCP tool', () => {
const base = arm.part('base',
cylinder(18, 22, 32).translate(0, 0, 9)
.union(cylinder(8, 10, 32).translate(0, 0, 22))
- ).connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 24] }, axis: [0, 0, 1] });
+ ).connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 24] }, axis: [0, 0, 1] })
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 24] } });
const link = arm.part('link',
cylinder(60, 5, 24).alongAxis([1, 0, 0]).translate(30, 0, 0)
).connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [30, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('root', box(8, 8, 8, true).translate(0, 0, 24))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 24] } })
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 24] }, axis: [0, 0, 1] });
+ arm.mate('base-root', 'root.mount', 'base.mount', 'fastened');
arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ arm.mechanicalJoint('yaw-support', { mate: 'yaw', actuator: 'root', shaft: 'root', supports: ['root'], output: 'link' });
return arm.model();
`;
@@ -241,17 +265,281 @@ describe('design_loop MCP tool', () => {
expect(result.attempts[0].reviewFacts.some((fact) => fact.code === 'assembly.quality.box-fragment-clutter')).toBe(true);
});
+ it('rejects visual acceptance when physical use case contacts are physically unreachable', async () => {
+ const result = await designLoopTool({
+ goal: 'Build a servo-driven finger that can touch the declared base target.',
+ includePoseEnvelope: false,
+ includeInterference: false,
+ requirePhysicalAcceptance: true,
+ attempts: [
+ {
+ id: '01',
+ title: 'Visually accepted unreachable finger',
+ code: `
+ const arm = assembly('targeted reachability rig');
+ arm.part('base', box(40, 40, 4, true))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 2] }, axis: [0, 0, 1] })
+ .connector('support', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 2] } })
+ .connector('target', { type: 'frame', origin: { kind: 'vec3', value: [120, 0, 2] } });
+ arm.part('finger', box(40, 8, 6, true).translate(20, 0, 0))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [40, 0, 0] } });
+ arm.part('support', box(12, 12, 8, true))
+ .connector('base', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, -4] } })
+ .connector('servo', { type: 'frame', origin: { kind: 'vec3', value: [0, 6, 0] } })
+ .connector('shaft', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } });
+ arm.part('servo', box(16, 10, 12, true))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, -5, 0] } });
+ arm.part('shaft', cylinder(8, 2).translate(0, 0, -4))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } })
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.mate('support-fix', 'base.support', 'support.base', 'fastened');
+ arm.mate('servo-fix', 'support.servo', 'servo.mount', 'fastened');
+ arm.mate('shaft-fix', 'support.shaft', 'shaft.mount', 'fastened');
+ arm.mate('yaw', 'base.axis', 'finger.axis', 'revolute', { limitsDeg: [0, 30] });
+ arm.mechanicalJoint('yaw-drive', {
+ mate: 'yaw',
+ actuator: 'servo',
+ shaft: 'shaft',
+ supports: ['support'],
+ output: 'finger',
+ });
+ arm.physicalUseCase('touch-target', {
+ stableParts: ['base'],
+ loads: [{ part: 'finger', force: [0, 0, -2] }],
+ contacts: [{ a: 'finger.tip', b: 'base.target', normal: [1, 0, 0], friction: 0.5 }],
+ actuatorLimits: [{ mate: 'yaw', maxTorqueNmm: 120 }],
+ criteria: { maxSlipMm: 2 },
+ });
+ return arm.model();
+ `,
+ visualReview: {
+ accepted: true,
+ screenshotPath: '/tmp/visually-accepted-unreachable-finger.png',
+ findings: ['Screenshot shows one coherent base-mounted finger, servo, shaft, and support with no visible floating pieces.'],
+ checks: passingVisualChecks,
+ },
+ },
+ ],
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.attempts[0]).toMatchObject({
+ functional: false,
+ qualityOk: false,
+ ok: false,
+ });
+ expect(result.attempts[0].reviewFacts).toEqual(expect.arrayContaining([
+ expect.objectContaining({ code: 'assembly.physical-use-case.contact-unreachable' }),
+ ]));
+ expect(result.attempts[0].nextActionPrompt).toContain('assembly.physical-use-case.contact-unreachable');
+ });
+
+ it('carries simultaneous-contact reachability failures into the repair prompt', async () => {
+ const result = await designLoopTool({
+ goal: 'Build a one-axis gripper whose declared contacts close on both targets at the same pose.',
+ includePoseEnvelope: false,
+ includeInterference: false,
+ requirePhysicalAcceptance: true,
+ requireVisualReview: false,
+ attempts: [
+ {
+ id: '01',
+ title: 'Contacts split across open and closed poses',
+ code: `
+ const arm = assembly('split-pose grasp');
+ arm.part('base', box(10, 10, 10, true))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('target-a', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } })
+ .connector('target-b', { type: 'frame', origin: { kind: 'vec3', value: [0, 10, 0] } });
+ arm.part('finger', box(10, 2, 2, true))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('a', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } })
+ .connector('b', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } });
+ arm.mate('yaw', 'base.axis', 'finger.axis', 'revolute', { limitsDeg: [0, 90] });
+ arm.mechanicalJoint('yaw-drive', {
+ mate: 'yaw',
+ actuator: 'base',
+ shaft: 'base',
+ supports: ['base'],
+ output: 'finger',
+ });
+ arm.physicalUseCase('split-pose-grasp', {
+ stableParts: ['base'],
+ loads: [{ part: 'finger', force: [0, 0, -1] }],
+ contacts: [
+ { a: 'finger.a', b: 'base.target-a', normal: [1, 0, 0], friction: 0.5 },
+ { a: 'finger.b', b: 'base.target-b', normal: [0, 1, 0], friction: 0.5 },
+ ],
+ actuatorLimits: [{ mate: 'yaw', maxTorqueNmm: 10 }],
+ criteria: { maxSlipMm: 0.1 },
+ });
+ return arm.model();
+ `,
+ },
+ ],
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.attempts[0].reviewFacts).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ code: 'assembly.physical-use-case.simultaneous-contacts-unreachable',
+ severity: 'error',
+ }),
+ ]));
+ expect(result.attempts[0].nextActionPrompt).toContain(
+ 'assembly.physical-use-case.simultaneous-contacts-unreachable',
+ );
+ expect(result.attempts[0].nextActionPrompt).toContain('independent per-contact poses do not form a grasp');
+ });
+
+ it('enables and preserves pose-bound statics failures for physical attempts', async () => {
+ const result = await designLoopTool({
+ goal: 'Build a finger that can statically hold a loaded target.',
+ includePoseEnvelope: false,
+ includeInterference: false,
+ requirePhysicalAcceptance: true,
+ requireVisualReview: false,
+ attempts: [{
+ id: '01',
+ title: 'Missing load application point',
+ code: `
+ const arm = assembly('static input incomplete');
+ arm.part('base', box(20, 20, 8))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('finger', box(10, 4, 4))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } });
+ arm.part('held', box(4, 4, 4), { role: 'contact-target' })
+ .connector('contact', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } });
+ arm.mate('curl', 'base.axis', 'finger.axis', 'revolute', { limitsDeg: [0, 1] });
+ arm.mechanicalJoint('curl-drive', {
+ mate: 'curl', actuator: 'base', shaft: 'base', supports: ['base'], output: 'finger',
+ });
+ arm.physicalUseCase('hold-target', {
+ stableParts: ['base'],
+ loads: [{ part: 'held', force: [-1, 0, 0] }],
+ contacts: [{
+ a: 'finger.tip', b: 'held.contact', normal: [-1, 0, 0], friction: 0.5, normalForceN: 2,
+ }],
+ actuatorLimits: [{ mate: 'curl', maxTorqueNmm: 20 }],
+ criteria: { maxSlipMm: 0.01 },
+ });
+ return arm.model();
+ `,
+ }],
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.attempts[0].reviewFacts).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ code: 'assembly.physical-use-case.static-input-incomplete',
+ severity: 'error',
+ }),
+ ]));
+ expect(result.attempts[0].nextActionPrompt).toContain(
+ 'assembly.physical-use-case.static-input-incomplete',
+ );
+ });
+
+ it('automatically requires joint ratings and structural evidence for physical attempts', async () => {
+ const result = await designLoopTool({
+ goal: 'Build a rated finger that statically holds a loaded target.',
+ includePoseEnvelope: false,
+ includeInterference: false,
+ requireVisualReview: false,
+ attempts: [{
+ id: '01',
+ title: 'Static grasp with an unrated hand-built hinge',
+ code: `
+ const arm = assembly('unrated physical hinge');
+ arm.part('base', box(20, 20, 8))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('finger', box(50, 6, 6, true).translate(25, 0, 0))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [50, 0, 0] } });
+ arm.part('held', box(6, 6, 6, true), { role: 'contact-target' })
+ .connector('contact', { type: 'frame', origin: { kind: 'vec3', value: [50, 0, 0] } })
+ .connector('load-point', { type: 'frame', origin: { kind: 'vec3', value: [50, 0, 0] } });
+ arm.mate('hinge', 'base.axis', 'finger.axis', 'revolute', { pose: 0, limitsDeg: [-1, 1] });
+ arm.mechanicalJoint('hinge-drive', {
+ mate: 'hinge', actuator: 'base', shaft: 'base', supports: ['base'], output: 'finger',
+ });
+ arm.physicalUseCase('hold-load', {
+ stableParts: ['base'],
+ loads: [{ part: 'held', at: 'held.load-point', force: [0, -10, 0] }],
+ contacts: [{
+ a: 'finger.tip', b: 'held.contact', normal: [0, -1, 0], normalFrame: 'world',
+ friction: 0.5, normalForceN: 20,
+ }],
+ actuatorLimits: [{ mate: 'hinge', maxTorqueNmm: 1000 }],
+ criteria: { maxSlipMm: 0.001, maxForceResidualN: 0.01, maxTorqueResidualNmm: 0.1 },
+ });
+ return arm.model();
+ `,
+ }],
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.attempts[0].reviewFacts).toEqual(expect.arrayContaining([
+ expect.objectContaining({ code: 'assembly.physical-use-case.joint-capacity-undeclared' }),
+ expect.objectContaining({ code: 'assembly.physical-use-case.joint-structure-input-incomplete' }),
+ ]));
+ });
+
+ it('carries joint topology diagnostics into review facts and repair prompts', async () => {
+ const result = await designLoopTool({
+ goal: 'Build a finger hinge with declared bearing support before visual review.',
+ includePoseEnvelope: false,
+ includeInterference: false,
+ requireVisualReview: false,
+ attempts: [
+ {
+ id: '01',
+ title: 'Bare unsupported hinge',
+ code: `
+ const arm = assembly('bare finite hinge');
+ arm.part('base', box(20, 20, 10, true))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('link', box(30, 6, 6, true).translate(15, 0, 0))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ return arm.model();
+ `,
+ },
+ ],
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.attempts[0]).toMatchObject({
+ functional: false,
+ ok: false,
+ });
+ expect(result.attempts[0].reviewFacts).toEqual(expect.arrayContaining([
+ expect.objectContaining({ code: 'assembly.joint-topology.unsupported-axis', severity: 'error' }),
+ ]));
+ expect(result.attempts[0].nextActionPrompt).toContain('assembly.joint-topology.unsupported-axis');
+ expect(result.attempts[0].nextActionPrompt).toContain('[error] assembly.joint-topology.unsupported-axis');
+ expect(result.attempts[0].nextActionPrompt).toContain('mechanicalJoint');
+ });
+
it('requires explicit screenshot review before accepting visual-sensitive mechanism attempts', async () => {
const clean = `
const arm = assembly('clean-cylinder-arm');
const base = arm.part('base',
cylinder(18, 22, 32).translate(0, 0, 9)
.union(cylinder(8, 10, 32).translate(0, 0, 22))
- ).connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 24] }, axis: [0, 0, 1] });
+ ).connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 24] }, axis: [0, 0, 1] })
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 24] } });
const link = arm.part('link',
cylinder(60, 5, 24).alongAxis([1, 0, 0]).translate(30, 0, 0)
).connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [30, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('root', box(8, 8, 8, true).translate(0, 0, 24))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 24] } })
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 24] }, axis: [0, 0, 1] });
+ arm.mate('base-root', 'root.mount', 'base.mount', 'fastened');
arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ arm.mechanicalJoint('yaw-support', { mate: 'yaw', actuator: 'root', shaft: 'root', supports: ['root'], output: 'link' });
return arm.model();
`;
@@ -308,10 +596,16 @@ describe('design_loop MCP tool', () => {
code: `
const arm = assembly('clean-bracket');
arm.part('base', box(30, 20, 6, true))
- .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] })
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } });
arm.part('link', box(30, 8, 6, true))
.connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('root', box(8, 8, 8, true).translate(0, 0, 3))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } })
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ arm.mate('base-root', 'root.mount', 'base.mount', 'fastened');
arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ arm.mechanicalJoint('yaw-support', { mate: 'yaw', actuator: 'root', shaft: 'root', supports: ['root'], output: 'link' });
return arm.model();
`,
visualReview: {
@@ -345,10 +639,16 @@ describe('design_loop MCP tool', () => {
code: `
const arm = assembly('clean-bracket');
arm.part('base', box(30, 20, 6, true))
- .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] })
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } });
arm.part('link', box(30, 8, 6, true))
.connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('root', box(8, 8, 8, true).translate(0, 0, 3))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } })
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ arm.mate('base-root', 'root.mount', 'base.mount', 'fastened');
arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ arm.mechanicalJoint('yaw-support', { mate: 'yaw', actuator: 'root', shaft: 'root', supports: ['root'], output: 'link' });
return arm.model();
`,
visualReview: {
@@ -378,10 +678,16 @@ describe('design_loop MCP tool', () => {
code: `
const arm = assembly('clean-bracket');
arm.part('base', box(30, 20, 6, true))
- .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] })
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } });
arm.part('link', box(30, 8, 6, true))
.connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('root', box(8, 8, 8, true).translate(0, 0, 3))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } })
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ arm.mate('base-root', 'root.mount', 'base.mount', 'fastened');
arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ arm.mechanicalJoint('yaw-support', { mate: 'yaw', actuator: 'root', shaft: 'root', supports: ['root'], output: 'link' });
return arm.model();
`,
visualReview: {
@@ -416,10 +722,16 @@ describe('design_loop MCP tool', () => {
code: `
const arm = assembly('clean-bracket');
arm.part('base', box(30, 20, 6, true))
- .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] })
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } });
arm.part('link', box(30, 8, 6, true))
.connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('root', box(8, 8, 8, true).translate(0, 0, 3))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } })
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ arm.mate('base-root', 'root.mount', 'base.mount', 'fastened');
arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ arm.mechanicalJoint('yaw-support', { mate: 'yaw', actuator: 'root', shaft: 'root', supports: ['root'], output: 'link' });
return arm.model();
`,
visualReview: {
@@ -453,10 +765,16 @@ describe('design_loop MCP tool', () => {
code: `
const arm = assembly('clean-bracket');
arm.part('base', box(30, 20, 6, true))
- .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] })
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } });
arm.part('link', box(30, 8, 6, true))
.connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('root', box(8, 8, 8, true).translate(0, 0, 3))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } })
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ arm.mate('base-root', 'root.mount', 'base.mount', 'fastened');
arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ arm.mechanicalJoint('yaw-support', { mate: 'yaw', actuator: 'root', shaft: 'root', supports: ['root'], output: 'link' });
return arm.model();
`,
visualReview: {
@@ -491,10 +809,16 @@ describe('design_loop MCP tool', () => {
code: `
const arm = assembly('clean-bracket');
arm.part('base', box(30, 20, 6, true))
- .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] })
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } });
arm.part('link', box(30, 8, 6, true))
.connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('root', box(8, 8, 8, true).translate(0, 0, 3))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } })
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ arm.mate('base-root', 'root.mount', 'base.mount', 'fastened');
arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ arm.mechanicalJoint('yaw-support', { mate: 'yaw', actuator: 'root', shaft: 'root', supports: ['root'], output: 'link' });
return arm.model();
`,
visualReview: {
@@ -529,10 +853,16 @@ describe('design_loop MCP tool', () => {
code: `
const arm = assembly('clean-bracket');
arm.part('base', box(30, 20, 6, true))
- .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] })
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } });
arm.part('link', box(30, 8, 6, true))
.connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('root', box(8, 8, 8, true).translate(0, 0, 3))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } })
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ arm.mate('base-root', 'root.mount', 'base.mount', 'fastened');
arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ arm.mechanicalJoint('yaw-support', { mate: 'yaw', actuator: 'root', shaft: 'root', supports: ['root'], output: 'link' });
return arm.model();
`,
visualReview: {
@@ -568,10 +898,16 @@ describe('design_loop MCP tool', () => {
code: `
const arm = assembly('clean-bracket');
arm.part('base', box(30, 20, 6, true))
- .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] })
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } });
arm.part('link', box(30, 8, 6, true))
.connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('root', box(8, 8, 8, true).translate(0, 0, 3))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 3] } })
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 3] }, axis: [0, 0, 1] });
+ arm.mate('base-root', 'root.mount', 'base.mount', 'fastened');
arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ arm.mechanicalJoint('yaw-support', { mate: 'yaw', actuator: 'root', shaft: 'root', supports: ['root'], output: 'link' });
return arm.model();
`,
},
diff --git a/tests/integration/mcp/physicalUseCaseGate.test.ts b/tests/integration/mcp/physicalUseCaseGate.test.ts
new file mode 100644
index 000000000..8bdc5de35
--- /dev/null
+++ b/tests/integration/mcp/physicalUseCaseGate.test.ts
@@ -0,0 +1,610 @@
+import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import { clearActiveMcpSession } from '../../../src/agent/mcp/activeSession';
+import { reviewCadTool } from '../../../src/agent/mcp/tools/reviewCad';
+import { initOcct } from '../../../src/kernel/backends/occt/occtBackend';
+
+function cleanTorqueIntentRig(
+ maxTorqueNmm: number,
+ opts: { friction?: number; normalForceN?: number } = {},
+): string {
+ const friction = opts.friction ?? 0.5;
+ const normalForce = opts.normalForceN === undefined ? '' : `, normalForceN: ${opts.normalForceN}`;
+ return `
+ const arm = assembly('clean torque intent rig');
+ arm.part('base', box(40, 40, 4, true))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 2] }, axis: [0, 0, 1] })
+ .connector('support', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 2] } })
+ .connector('tip-contact', { type: 'frame', origin: { kind: 'vec3', value: [100, 0, 2] } });
+ arm.part('link', box(100, 8, 6, true).translate(50, 0, 0))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [100, 0, 0] } });
+ arm.part('support', box(12, 12, 8, true))
+ .connector('base', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, -4] } })
+ .connector('servo', { type: 'frame', origin: { kind: 'vec3', value: [0, 6, 0] } })
+ .connector('shaft', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } });
+ arm.part('servo', box(16, 10, 12, true))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, -5, 0] } });
+ arm.part('shaft', cylinder(8, 2).translate(0, 0, -4))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } })
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.mate('support-fix', 'base.support', 'support.base', 'fastened');
+ arm.mate('servo-fix', 'support.servo', 'servo.mount', 'fastened');
+ arm.mate('shaft-fix', 'support.shaft', 'shaft.mount', 'fastened');
+ arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-10, 10] });
+ arm.mechanicalJoint('yaw-drive', {
+ mate: 'yaw',
+ actuator: 'servo',
+ shaft: 'shaft',
+ supports: ['support'],
+ output: 'link',
+ });
+ arm.physicalUseCase('hold-tip-load', {
+ stableParts: ['base'],
+ loads: [{ part: 'link', force: [0, 10, 0] }],
+ contacts: [{ a: 'link.tip', b: 'base.tip-contact', normal: [0, 0, 1], friction: ${friction}${normalForce} }],
+ actuatorLimits: [{ mate: 'yaw', maxTorqueNmm: ${maxTorqueNmm} }],
+ criteria: { maxSlipMm: 1 },
+ });
+ return arm.model();
+ `;
+}
+
+function poseBoundStaticRig(maxTorqueNmm: number): string {
+ return `
+ const arm = assembly('pose bound static rig');
+ arm.part('base', box(50, 20, 8))
+ .connector('left-axis', { type: 'axis', origin: { kind: 'vec3', value: [-20, 0, 0] }, axis: [0, 1, 0] })
+ .connector('right-axis', { type: 'axis', origin: { kind: 'vec3', value: [20, 0, 0] }, axis: [0, 1, 0] });
+ arm.part('left-finger', box(10, 4, 4))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [-20, 0, 0] }, axis: [0, 1, 0] })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [-10, 0, 0] } });
+ arm.part('right-finger', box(10, 4, 4))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [20, 0, 0] }, axis: [0, 1, 0] })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } });
+ arm.part('held', box(20, 10, 10), { role: 'contact-target' })
+ .connector('center', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } })
+ .connector('left-contact', { type: 'frame', origin: { kind: 'vec3', value: [-10, 0, 0] } })
+ .connector('right-contact', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } });
+ arm.mate('left-curl', 'base.left-axis', 'left-finger.axis', 'revolute', { limitsDeg: [0, 1] });
+ arm.mate('right-curl', 'base.right-axis', 'right-finger.axis', 'revolute', { limitsDeg: [0, 1] });
+ arm.physicalUseCase('hold-object', {
+ stableParts: ['base'],
+ loads: [{ part: 'held', at: 'held.center', force: [0, 0, -6] }],
+ contacts: [
+ { a: 'left-finger.tip', b: 'held.left-contact', normal: [-1, 0, 0], friction: 0.5, normalForceN: 8 },
+ { a: 'right-finger.tip', b: 'held.right-contact', normal: [1, 0, 0], friction: 0.5, normalForceN: 8 },
+ ],
+ actuatorLimits: [
+ { mate: 'left-curl', maxTorqueNmm: ${maxTorqueNmm} },
+ { mate: 'right-curl', maxTorqueNmm: ${maxTorqueNmm} },
+ ],
+ criteria: { maxSlipMm: 0.01, maxForceResidualN: 0.01, maxTorqueResidualNmm: 0.1 },
+ });
+ return arm.model();
+ `;
+}
+
+function ratedClevisPhysicalRig(): string {
+ return `
+ const steel = {
+ name: 'test steel', model: 'isotropic-ductile',
+ yieldStrengthMPa: 250, bearingStrengthMPa: 400,
+ };
+ const clevis = joint.clevis({
+ parentBody: box(30, 30, 10, true),
+ childBody: box(50, 6, 6, true).translate(25, 0, 0),
+ axis: 'Z', pivotParent: [0, 0, 0], pivotChild: [0, 0, 0], liftPivot: false,
+ style: { knuckleR: 10, forkGapY: 6, tongueY: 5, plateT: 4, pinR: 3, holeClearance: 0.2 },
+ engineering: { pin: steel, fork: steel, tongue: steel },
+ });
+ const arm = assembly('rated clevis physical rig');
+ arm.part('base', clevis.parentGeometry)
+ .connector('axis', {
+ type: 'axis', origin: { kind: 'vec3', value: clevis.parentConnector.origin },
+ axis: clevis.parentConnector.axis, jointClearanceRadius: clevis.parentConnector.clearanceRadius,
+ });
+ arm.part('finger', clevis.childGeometry)
+ .connector('axis', {
+ type: 'axis', origin: { kind: 'vec3', value: clevis.childConnector.origin },
+ axis: clevis.childConnector.axis, jointClearanceRadius: clevis.childConnector.clearanceRadius,
+ })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [50, 0, 0] } });
+ arm.part('held', box(6, 6, 6, true), { role: 'contact-target' })
+ .connector('contact', { type: 'frame', origin: { kind: 'vec3', value: [50, 0, 0] } })
+ .connector('load-point', { type: 'frame', origin: { kind: 'vec3', value: [50, 0, 0] } });
+ arm.mate('hinge', 'base.axis', 'finger.axis', 'revolute', {
+ pose: 0,
+ limitsDeg: [-1, 1],
+ capacity: {
+ envelope: { maxResultantForceN: 100, maxResultantMomentNmm: 1000 },
+ structure: clevis.structural,
+ },
+ });
+ arm.mechanicalJoint('hinge-drive', {
+ mate: 'hinge', actuator: 'base', shaft: 'base', supports: ['base'], output: 'finger',
+ });
+ arm.physicalUseCase('hold-load', {
+ stableParts: ['base'],
+ loads: [{ part: 'held', at: 'held.load-point', force: [0, -10, 0] }],
+ contacts: [{
+ a: 'finger.tip', b: 'held.contact', normal: [0, -1, 0], normalFrame: 'world',
+ friction: 0.5, normalForceN: 20,
+ }],
+ actuatorLimits: [{ mate: 'hinge', maxTorqueNmm: 1000 }],
+ criteria: {
+ maxSlipMm: 0.001, maxForceResidualN: 0.01,
+ maxTorqueResidualNmm: 0.1, minJointSafetyFactor: 2,
+ },
+ });
+ return arm.model();
+ `;
+}
+
+describe('generic physical use-case gate', () => {
+ beforeAll(async () => { await initOcct(); }, 60_000);
+ beforeEach(() => { clearActiveMcpSession(); });
+
+ it('surfaces pose-bound static actuator failure through review_cad', async () => {
+ const result = await reviewCadTool({
+ code: poseBoundStaticRig(25),
+ includePoseEnvelope: false,
+ includeInterference: false,
+ includePhysicalUseCaseStatics: true,
+ physicalUseCaseReachabilitySamplesPerMate: 1,
+ });
+
+ expect(result.diagnostics).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ code: 'assembly.physical-use-case.static-actuator-torque-insufficient',
+ useCaseName: 'hold-object',
+ }),
+ ]));
+ expect(result.physicalUseCaseStaticCertificates).toEqual([]);
+ });
+
+ it('returns pose-bound static certificate evidence through review_cad', async () => {
+ const result = await reviewCadTool({
+ code: poseBoundStaticRig(35),
+ includePoseEnvelope: false,
+ includeInterference: false,
+ includePhysicalUseCaseStatics: true,
+ physicalUseCaseReachabilitySamplesPerMate: 1,
+ });
+
+ expect(result.diagnostics.some((diagnostic) =>
+ diagnostic.code === 'assembly.physical-use-case.static-actuator-torque-insufficient')).toBe(false);
+ expect(result.physicalUseCaseStaticCertificates).toEqual([
+ expect.objectContaining({
+ useCaseName: 'hold-object',
+ heldPart: 'held',
+ actuatorTorques: expect.arrayContaining([
+ expect.objectContaining({ mateName: 'left-curl' }),
+ expect.objectContaining({ mateName: 'right-curl' }),
+ ]),
+ }),
+ ]);
+ });
+
+ it('returns reaction and geometry-derived clevis certificates through review_cad', async () => {
+ const result = await reviewCadTool({
+ code: ratedClevisPhysicalRig(),
+ includePoseEnvelope: false,
+ includeInterference: false,
+ includePhysicalUseCaseJointReactions: true,
+ includePhysicalUseCaseJointStructure: true,
+ physicalUseCaseReachabilitySamplesPerMate: 3,
+ });
+
+ expect(result.physicalUseCaseJointReactionCertificates).toEqual([
+ expect.objectContaining({
+ useCaseName: 'hold-load',
+ reactions: [expect.objectContaining({
+ mateName: 'hinge',
+ resultantForceN: expect.any(Number),
+ resultantMomentNmm: expect.any(Number),
+ })],
+ }),
+ ]);
+ expect(result.physicalUseCaseJointStructuralCertificates).toEqual([
+ expect.objectContaining({
+ useCaseName: 'hold-load',
+ joints: [expect.objectContaining({
+ mateName: 'hinge',
+ envelope: expect.objectContaining({ status: 'pass' }),
+ structure: expect.objectContaining({ status: 'pass' }),
+ })],
+ }),
+ ]);
+ });
+
+ it('fails an articulated mechanism when physical evidence is required but absent', async () => {
+ const result = await reviewCadTool({
+ includePoseEnvelope: false,
+ includeInterference: false,
+ requirePhysicalUseCase: true,
+ code: `
+ const arm = assembly('bare hinge');
+ arm.part('base', box(20, 20, 10, true))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('link', box(30, 6, 6, true).translate(15, 0, 0))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ return arm.model();
+ `,
+ });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.fitness?.blockingReasons.some((reason) => reason.code === 'assembly.physical-use-case.missing')).toBe(true);
+ expect(result.suggestedRepairPrompt).toMatch(/assembly\.physical-use-case\.missing/);
+ }
+ });
+
+ it('fails malformed use-case evidence with actionable diagnostics', async () => {
+ const result = await reviewCadTool({
+ includePoseEnvelope: false,
+ includeInterference: false,
+ requirePhysicalUseCase: true,
+ code: `
+ const arm = assembly('bad use case');
+ arm.part('base', box(20, 20, 10, true))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('link', box(30, 6, 6, true).translate(15, 0, 0))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [30, 0, 0] } });
+ arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ arm.physicalUseCase('hold-load', {
+ stableParts: ['missing-target'],
+ loads: [{ part: 'missing-target', force: [0, 0, 0] }],
+ contacts: [{ a: 'link.tip', b: 'missing-target.contact', normal: [0, 0, 0], friction: 0 }],
+ actuatorLimits: [{ mate: 'missing-mate', maxTorqueNmm: 0 }],
+ });
+ return arm.model();
+ `,
+ });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ const codes = result.fitness?.blockingReasons.map((reason) => reason.code) ?? [];
+ expect(codes).toContain('assembly.physical-use-case.part-missing');
+ expect(codes).toContain('assembly.physical-use-case.zero-load');
+ expect(codes).toContain('assembly.physical-use-case.contact-invalid');
+ expect(codes).toContain('assembly.physical-use-case.actuator-limit-invalid');
+ }
+ });
+
+ it('accepts a minimal generic physical use case as evidence', async () => {
+ const result = await reviewCadTool({
+ includePoseEnvelope: false,
+ includeInterference: false,
+ requirePhysicalUseCase: true,
+ code: cleanTorqueIntentRig(5000),
+ });
+
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.fitness.passedChecks).toContain('physical-use-case-declared');
+ expect(result.fitness.mechanismSummary.physicalUseCaseCount).toBe(1);
+ }
+ });
+
+ it('blocks actuator limits on bare revolute mates with no mechanical joint support contract', async () => {
+ const result = await reviewCadTool({
+ includePoseEnvelope: false,
+ includeInterference: false,
+ requirePhysicalUseCase: true,
+ code: `
+ const arm = assembly('bare actuator limit');
+ arm.part('base', box(20, 20, 10, true))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('contact', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } });
+ arm.part('link', box(30, 6, 6, true).translate(15, 0, 0))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [30, 0, 0] } });
+ arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ arm.physicalUseCase('hold-load', {
+ stableParts: ['base'],
+ loads: [{ part: 'link', force: [0, 0, -5] }],
+ contacts: [{ a: 'link.tip', b: 'base.contact', normal: [0, 0, 1], friction: 0.5 }],
+ actuatorLimits: [{ mate: 'yaw', maxTorqueNmm: 120 }],
+ criteria: { maxSlipMm: 2, settleTimeMs: 500 },
+ });
+ return arm.model();
+ `,
+ });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ const reasons = result.fitness?.blockingReasons ?? [];
+ expect(reasons.some((reason) => reason.code === 'assembly.physical-use-case.actuator-support-missing')).toBe(true);
+ expect(result.suggestedRepairPrompt).toMatch(/assembly\.physical-use-case\.actuator-support-missing/);
+ }
+ });
+
+ it('reports unsupported revolute topology for finite bare hinges', async () => {
+ const result = await reviewCadTool({
+ includePoseEnvelope: false,
+ includeInterference: false,
+ code: `
+ const arm = assembly('bare finite hinge');
+ arm.part('base', box(20, 20, 10, true))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.part('link', box(30, 6, 6, true).translate(15, 0, 0))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ return arm.model();
+ `,
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.diagnostics).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ code: 'assembly.joint-topology.unsupported-axis',
+ severity: 'error',
+ mateName: 'yaw',
+ }),
+ ]));
+ if (!result.ok) {
+ expect(result.fitness?.blockingReasons).toEqual(expect.arrayContaining([
+ expect.objectContaining({ code: 'assembly.joint-topology.unsupported-axis' }),
+ ]));
+ expect(result.suggestedRepairPrompt).toMatch(/assembly\.joint-topology\.unsupported-axis/);
+ }
+ });
+
+ it('reports floating articulated load parts with no stable-root path', async () => {
+ const result = await reviewCadTool({
+ includePoseEnvelope: false,
+ includeInterference: false,
+ requirePhysicalUseCase: true,
+ code: `
+ const arm = assembly('isolated articulated load');
+ arm.part('base', box(20, 20, 10, true))
+ .connector('contact', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } });
+ arm.part('finger-proximal', box(30, 6, 6, true).translate(50, 0, 0))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [30, 0, 0] } });
+ arm.part('finger-distal', box(24, 6, 6, true).translate(80, 0, 0))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [24, 0, 0] } });
+ arm.mate('knuckle', 'finger-proximal.axis', 'finger-distal.axis', 'revolute', { limitsDeg: [-20, 70] });
+ arm.mechanicalJoint('knuckle-support', {
+ mate: 'knuckle',
+ actuator: 'finger-proximal',
+ shaft: 'finger-proximal',
+ supports: ['finger-proximal'],
+ output: 'finger-distal',
+ });
+ arm.physicalUseCase('hold-distal-load', {
+ stableParts: ['base'],
+ loads: [{ part: 'finger-distal', force: [0, 0, -5] }],
+ contacts: [{ a: 'finger-proximal.tip', b: 'base.contact', normal: [0, 0, 1], friction: 0.5 }],
+ actuatorLimits: [{ mate: 'knuckle', maxTorqueNmm: 120 }],
+ criteria: { maxSlipMm: 2 },
+ });
+ return arm.model();
+ `,
+ });
+
+ expect(result.ok).toBe(false);
+ expect(result.diagnostics).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ code: 'assembly.connectivity.floating-moving-part',
+ severity: 'error',
+ partName: 'finger-distal',
+ }),
+ ]));
+ if (!result.ok) {
+ expect(result.fitness?.blockingReasons).toEqual(expect.arrayContaining([
+ expect.objectContaining({ code: 'assembly.connectivity.floating-moving-part' }),
+ ]));
+ expect(result.suggestedRepairPrompt).toMatch(/assembly\.connectivity\.floating-moving-part/);
+ }
+ });
+
+ it('blocks declared contacts that never come within the allowed slip distance', async () => {
+ const result = await reviewCadTool({
+ includePoseEnvelope: true,
+ includeInterference: false,
+ includePhysics: false,
+ requirePhysicalUseCase: true,
+ samplesPerMate: 3,
+ code: `
+ const arm = assembly('unreachable contact');
+ arm.part('base', box(20, 20, 10, true))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('target-contact', { type: 'frame', origin: { kind: 'vec3', value: [80, 0, 0] } });
+ arm.part('link', box(30, 6, 6, true).translate(15, 0, 0))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [30, 0, 0] } });
+ arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ arm.physicalUseCase('touch-target', {
+ stableParts: ['base'],
+ loads: [{ part: 'link', force: [0, 0, -2] }],
+ contacts: [{ a: 'link.tip', b: 'base.target-contact', normal: [1, 0, 0], friction: 0.5 }],
+ actuatorLimits: [{ mate: 'yaw', maxTorqueNmm: 120 }],
+ criteria: { maxSlipMm: 2 },
+ });
+ return arm.model();
+ `,
+ });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ const reasons = result.fitness?.blockingReasons ?? [];
+ expect(reasons.some((reason) => reason.code === 'assembly.physical-use-case.contact-unreachable')).toBe(true);
+ const unreachableDiagnostics = result.diagnostics.filter((diagnostic) =>
+ diagnostic.code === 'assembly.physical-use-case.contact-unreachable' &&
+ 'contactA' in diagnostic &&
+ diagnostic.contactA === 'link.tip' &&
+ diagnostic.contactB === 'base.target-contact'
+ );
+ expect(unreachableDiagnostics).toHaveLength(1);
+ expect(result.suggestedRepairPrompt).toMatch(/assembly\.physical-use-case\.contact-unreachable/);
+ }
+ });
+
+ it('blocks declared physical-use-case contacts that targeted actuator sampling cannot reach', async () => {
+ const result = await reviewCadTool({
+ includePoseEnvelope: false,
+ includeInterference: false,
+ includePhysicalUseCaseReachability: true,
+ requirePhysicalUseCase: true,
+ code: `
+ const arm = assembly('targeted reachability rig');
+ arm.part('base', box(40, 40, 4, true))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 2] }, axis: [0, 0, 1] })
+ .connector('support', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 2] } })
+ .connector('target', { type: 'frame', origin: { kind: 'vec3', value: [120, 0, 2] } });
+ arm.part('finger', box(40, 8, 6, true).translate(20, 0, 0))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [40, 0, 0] } });
+ arm.part('support', box(12, 12, 8, true))
+ .connector('base', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, -4] } })
+ .connector('servo', { type: 'frame', origin: { kind: 'vec3', value: [0, 6, 0] } })
+ .connector('shaft', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } });
+ arm.part('servo', box(16, 10, 12, true))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, -5, 0] } });
+ arm.part('shaft', cylinder(8, 2).translate(0, 0, -4))
+ .connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } })
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] });
+ arm.mate('support-fix', 'base.support', 'support.base', 'fastened');
+ arm.mate('servo-fix', 'support.servo', 'servo.mount', 'fastened');
+ arm.mate('shaft-fix', 'support.shaft', 'shaft.mount', 'fastened');
+ arm.mate('yaw', 'base.axis', 'finger.axis', 'revolute', { limitsDeg: [0, 30] });
+ arm.mechanicalJoint('yaw-drive', {
+ mate: 'yaw',
+ actuator: 'servo',
+ shaft: 'shaft',
+ supports: ['support'],
+ output: 'finger',
+ });
+ arm.physicalUseCase('touch-target', {
+ stableParts: ['base'],
+ loads: [{ part: 'finger', force: [0, 0, -2] }],
+ contacts: [{ a: 'finger.tip', b: 'base.target', normal: [1, 0, 0], friction: 0.5 }],
+ actuatorLimits: [{ mate: 'yaw', maxTorqueNmm: 120 }],
+ criteria: { maxSlipMm: 2 },
+ });
+ return arm.model();
+ `,
+ });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ const diagnostic = result.diagnostics.find((reason) => reason.code === 'assembly.physical-use-case.contact-unreachable');
+ expect(diagnostic).toMatchObject({
+ code: 'assembly.physical-use-case.contact-unreachable',
+ contactA: 'finger.tip',
+ contactB: 'base.target',
+ toleranceMm: 2,
+ });
+ expect(result.fitness?.blockingReasons.some((reason) => reason.code === 'assembly.physical-use-case.contact-unreachable')).toBe(true);
+ }
+ });
+
+ it('blocks loads that have no declared path to a stable part', async () => {
+ const result = await reviewCadTool({
+ includePoseEnvelope: false,
+ includeInterference: false,
+ requirePhysicalUseCase: true,
+ code: `
+ const arm = assembly('unsupported payload');
+ arm.part('base', box(20, 20, 10, true))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('contact', { type: 'frame', origin: { kind: 'vec3', value: [10, 0, 0] } });
+ arm.part('link', box(30, 6, 6, true).translate(15, 0, 0))
+ .connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
+ .connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [30, 0, 0] } });
+ arm.part('payload', box(10, 10, 10, true).translate(70, 0, 0))
+ .connector('contact', { type: 'frame', origin: { kind: 'vec3', value: [70, 0, 0] } });
+ arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', { limitsDeg: [-20, 20] });
+ arm.physicalUseCase('hold-payload', {
+ stableParts: ['base'],
+ loads: [{ part: 'payload', force: [0, 0, -5] }],
+ contacts: [{ a: 'link.tip', b: 'base.contact', normal: [0, 0, 1], friction: 0.5 }],
+ actuatorLimits: [{ mate: 'yaw', maxTorqueNmm: 120 }],
+ criteria: { maxSlipMm: 2 },
+ });
+ return arm.model();
+ `,
+ });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ const reasons = result.fitness?.blockingReasons ?? [];
+ expect(reasons.some((reason) => reason.code === 'assembly.physical-use-case.load-path-missing')).toBe(true);
+ expect(result.suggestedRepairPrompt).toMatch(/assembly\.physical-use-case\.load-path-missing/);
+ }
+ });
+
+ it('blocks actuator limits that are below the declared load moment', async () => {
+ const result = await reviewCadTool({
+ includePoseEnvelope: true,
+ includeInterference: false,
+ includePhysics: false,
+ requirePhysicalUseCase: true,
+ samplesPerMate: 3,
+ code: cleanTorqueIntentRig(100),
+ });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ const reasons = result.fitness?.blockingReasons ?? [];
+ expect(reasons.some((reason) => reason.code === 'assembly.physical-use-case.torque-insufficient')).toBe(true);
+ expect(result.suggestedRepairPrompt).toMatch(/assembly\.physical-use-case\.torque-insufficient/);
+ }
+ });
+
+ it('accepts actuator limits that exceed the declared load moment', async () => {
+ const result = await reviewCadTool({
+ includePoseEnvelope: true,
+ includeInterference: false,
+ includePhysics: false,
+ requirePhysicalUseCase: true,
+ samplesPerMate: 3,
+ code: cleanTorqueIntentRig(2000),
+ });
+
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.fitness.passedChecks).toContain('physical-use-case-declared');
+ expect(result.fitness.mechanismSummary.physicalUseCaseIssueCount).toBeUndefined();
+ }
+ });
+
+ it('blocks declared contact force capacity below the applied load', async () => {
+ const result = await reviewCadTool({
+ includePoseEnvelope: true,
+ includeInterference: false,
+ includePhysics: false,
+ requirePhysicalUseCase: true,
+ samplesPerMate: 3,
+ code: cleanTorqueIntentRig(5000, { friction: 0.1, normalForceN: 20 }),
+ });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ const reasons = result.fitness?.blockingReasons ?? [];
+ expect(reasons.some((reason) => reason.code === 'assembly.physical-use-case.contact-force-insufficient')).toBe(true);
+ expect(result.suggestedRepairPrompt).toMatch(/assembly\.physical-use-case\.contact-force-insufficient/);
+ }
+ });
+
+ it('accepts declared contact force capacity above the applied load', async () => {
+ const result = await reviewCadTool({
+ includePoseEnvelope: true,
+ includeInterference: false,
+ includePhysics: false,
+ requirePhysicalUseCase: true,
+ samplesPerMate: 3,
+ code: cleanTorqueIntentRig(5000, { friction: 0.1, normalForceN: 200 }),
+ });
+
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.fitness.passedChecks).toContain('physical-use-case-declared');
+ expect(result.fitness.mechanismSummary.physicalUseCaseIssueCount).toBeUndefined();
+ }
+ });
+});
diff --git a/tests/integration/mcp/reviewCad.test.ts b/tests/integration/mcp/reviewCad.test.ts
index 000677932..2bb4c55ba 100644
--- a/tests/integration/mcp/reviewCad.test.ts
+++ b/tests/integration/mcp/reviewCad.test.ts
@@ -22,6 +22,9 @@ describe('review_cad MCP tool', () => {
pose: 120,
limitsDeg: [-90, 90],
});
+ arm.jointSupport('yaw-support', {
+ mate: 'yaw', shaft: 'base', supports: ['base'], output: 'link',
+ });
return arm.model();
`,
});
@@ -55,6 +58,9 @@ describe('review_cad MCP tool', () => {
arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', {
limitsDeg: [0, 90],
});
+ arm.jointSupport('yaw-support', {
+ mate: 'yaw', shaft: 'base', supports: ['base'], output: 'link',
+ });
return arm.model();
`,
});
@@ -347,10 +353,19 @@ describe('review_cad MCP tool', () => {
.connector('axis', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 0, 1] })
.connector('tip', { type: 'frame', origin: { kind: 'vec3', value: [-40, 0, 0] } });
arm.mate('grip', 'base.driver', 'driver.axis', 'revolute', { pose: 0, limitsDeg: [0, 40] });
- arm.mate('left-curl', 'base.left', 'left.axis', 'revolute');
- arm.mate('right-curl', 'base.right', 'right.axis', 'revolute');
+ arm.mate('left-curl', 'base.left', 'left.axis', 'revolute', { limitsDeg: [0, 40] });
+ arm.mate('right-curl', 'base.right', 'right.axis', 'revolute', { limitsDeg: [-40, 0] });
arm.coupleMates('left-curl', { source: 'grip', ratio: 1 });
arm.coupleMates('right-curl', { source: 'grip', ratio: -1 });
+ arm.jointSupport('grip-support', {
+ mate: 'grip', shaft: 'base', supports: ['base'], output: 'driver',
+ });
+ arm.jointSupport('left-support', {
+ mate: 'left-curl', shaft: 'base', supports: ['base'], output: 'left',
+ });
+ arm.jointSupport('right-support', {
+ mate: 'right-curl', shaft: 'base', supports: ['base'], output: 'right',
+ });
arm.transmission('left-drive', {
kind: 'link-rod',
sourceMate: 'grip',
@@ -396,6 +411,9 @@ describe('review_cad MCP tool', () => {
arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', {
limitsDeg: [0, 90],
});
+ arm.jointSupport('yaw-support', {
+ mate: 'yaw', shaft: 'base', supports: ['base'], output: 'link',
+ });
return arm.model();
`,
});
@@ -455,6 +473,9 @@ describe('review_cad MCP tool', () => {
arm.mate('yaw', 'base.axis', 'link.axis', 'revolute', {
limitsDeg: [0, 90],
});
+ arm.jointSupport('yaw-support', {
+ mate: 'yaw', shaft: 'base', supports: ['base'], output: 'link',
+ });
return arm.model();
`,
});
diff --git a/tests/unit/assemblies/assemblyCapture.test.ts b/tests/unit/assemblies/assemblyCapture.test.ts
index 0deac67af..d14af609f 100644
--- a/tests/unit/assemblies/assemblyCapture.test.ts
+++ b/tests/unit/assemblies/assemblyCapture.test.ts
@@ -271,6 +271,62 @@ describe('assembly capture contract', () => {
]);
});
+ it('captures passive joint support intent records', () => {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ const arm = kcad.assembly('passive support');
+
+ const returned = arm.jointSupport('pip-bearing', {
+ mate: 'index-pip',
+ shaft: 'index-proximal',
+ supports: ['index-proximal'],
+ output: 'index-middle',
+ requiredSupport: {
+ kind: 'hinge-bracket',
+ around: 'index-proximal.pip',
+ supports: ['index-proximal'],
+ minBearingLengthMm: 6,
+ },
+ });
+
+ expect(returned).toBe(arm);
+ expect(arm.__jointSupportIntents()).toEqual([
+ {
+ name: 'pip-bearing',
+ mate: 'index-pip',
+ shaft: 'index-proximal',
+ supports: ['index-proximal'],
+ output: 'index-middle',
+ requiredSupport: {
+ kind: 'hinge-bracket',
+ around: 'index-proximal.pip',
+ supports: ['index-proximal'],
+ minBearingLengthMm: 6,
+ },
+ },
+ ]);
+ });
+
+ it('stores contact target part roles', () => {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ const arm = kcad.assembly('contact target');
+
+ arm.part('grasp-cylinder', kcad.cylinder(20, 10), { role: 'contact-target' });
+
+ expect(arm.__parts().find((part) => part.name === 'grasp-cylinder')?.role).toBe('contact-target');
+ });
+
+ it('rejects unknown part roles', () => {
+ const session = new CaptureSession();
+ const kcad = createApi({ session });
+ const arm = kcad.assembly('bad role');
+
+ expect(() => arm.part('mystery', kcad.box(1, 1, 1), {
+ role: 'decorative' as never,
+ })).toThrow(/assembly.part.invalid-role/);
+ });
+
it('rejects duplicate or empty mechanical joint intent fields', () => {
const session = new CaptureSession();
const kcad = createApi({ session });
diff --git a/tests/unit/mcp/designLoopNextActionPrompt.test.ts b/tests/unit/mcp/designLoopNextActionPrompt.test.ts
index 29c155797..7479adb85 100644
--- a/tests/unit/mcp/designLoopNextActionPrompt.test.ts
+++ b/tests/unit/mcp/designLoopNextActionPrompt.test.ts
@@ -198,4 +198,71 @@ describe('design_loop nextActionPrompt rendering from RepairContext', () => {
expect.objectContaining({ code: 'assembly.visual.review-required' }),
]));
});
+
+ it('nextActionPrompt preserves physical use case contact reachability facts', async () => {
+ mockReviewCadTool.mockReset();
+ mockReviewCadTool.mockResolvedValue({
+ ok: false,
+ featureCount: 1,
+ diagnostics: [
+ {
+ code: 'assembly.physical-use-case.contact-unreachable',
+ severity: 'error',
+ useCaseName: 'touch-target',
+ contactA: 'finger.tip',
+ contactB: 'base.target',
+ minDistanceMm: 78,
+ toleranceMm: 2,
+ message: "Physical use case 'touch-target' contact 'finger.tip' to 'base.target' cannot be reached by the declared actuator limits; closest targeted sample is 78.00 mm away with tolerance 2.00 mm.",
+ hint: "physical-use-case.contact-unreachable — repair the target connector, move 'finger.tip' or 'base.target', or widen the declared actuatorLimits so the contact can get within maxSlipMm 2.00.",
+ },
+ ],
+ assembly: 'targeted reachability rig',
+ validator: { status: 'ok', diagnostics: [], partCount: 4, jointCount: 4 },
+ fitness: {
+ functional: false,
+ repairMode: 'parameter-tune',
+ repairDirective: 'Move the target connector or widen the declared actuator limits.',
+ passedChecks: ['validator-no-errors'],
+ blockingReasons: [
+ {
+ code: 'assembly.physical-use-case.contact-unreachable',
+ message: "Physical use case 'touch-target' contact 'finger.tip' to 'base.target' cannot be reached by the declared actuator limits; closest targeted sample is 78.00 mm away with tolerance 2.00 mm.",
+ repairHint: "physical-use-case.contact-unreachable — repair the target connector, move 'finger.tip' or 'base.target', or widen the declared actuatorLimits so the contact can get within maxSlipMm 2.00.",
+ },
+ ],
+ mechanismSummary: {
+ sampleCount: 0,
+ interferenceCount: 0,
+ trackedConnectorCount: 0,
+ },
+ },
+ repairContext: {
+ blockingReasons: [
+ "assembly.physical-use-case.contact-unreachable: Physical use case 'touch-target' contact 'finger.tip' to 'base.target' cannot be reached by the declared actuator limits; closest targeted sample is 78.00 mm away with tolerance 2.00 mm.",
+ ],
+ topDiagnostics: [
+ {
+ code: 'assembly.physical-use-case.contact-unreachable',
+ },
+ ],
+ preserveInterfaces: [],
+ designGoal: 'Build a servo-driven finger that can touch the declared base target.',
+ },
+ suggestedRepairPrompt: 'Repair the physical use case reachability.',
+ });
+
+ const result = await designLoopTool({
+ goal: 'Build a servo-driven finger that can touch the declared base target.',
+ includePoseEnvelope: false,
+ includeInterference: false,
+ requirePhysicalAcceptance: true,
+ attempts: [{ id: '01', title: 'Unreachable contact', code: 'return undefined;' }],
+ });
+
+ const prompt = result.attempts[0].nextActionPrompt;
+ expect(prompt).toContain('assembly.physical-use-case.contact-unreachable');
+ expect(prompt).toContain('closest');
+ expect(prompt).toContain('maxSlipMm');
+ });
});
diff --git a/tests/unit/mcp/reviewCadOutputSchema.test.ts b/tests/unit/mcp/reviewCadOutputSchema.test.ts
index 9d3071a13..094ca0cd1 100644
--- a/tests/unit/mcp/reviewCadOutputSchema.test.ts
+++ b/tests/unit/mcp/reviewCadOutputSchema.test.ts
@@ -11,4 +11,47 @@ describe('review_cad output schema', () => {
items: { type: 'object', additionalProperties: true },
});
});
+
+ it('declares pose-bound static certificates as structured evidence', () => {
+ const schema = TOOL_OUTPUT_SCHEMAS.review_cad;
+
+ expect(schema.properties.physicalUseCaseStaticCertificates).toMatchObject({
+ type: 'array',
+ items: { type: 'object', additionalProperties: true },
+ });
+ });
+
+ it('declares unit-bearing joint reaction and structural certificate arrays', () => {
+ const schema = TOOL_OUTPUT_SCHEMAS.review_cad;
+ const reactions = schema.properties.physicalUseCaseJointReactionCertificates;
+ const structures = schema.properties.physicalUseCaseJointStructuralCertificates;
+
+ expect(reactions).toMatchObject({
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ reactions: {
+ type: 'array',
+ items: {
+ required: expect.arrayContaining([
+ 'mateName',
+ 'forceWorldN',
+ 'momentWorldNmm',
+ 'resultantForceN',
+ 'resultantMomentNmm',
+ ]),
+ },
+ },
+ },
+ },
+ });
+ expect(structures).toMatchObject({
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: { joints: { type: 'array' } },
+ },
+ });
+ });
});
diff --git a/tests/unit/mcp/toolRegistrySchema.test.ts b/tests/unit/mcp/toolRegistrySchema.test.ts
index f3d7b093b..6e1ff1eb6 100644
--- a/tests/unit/mcp/toolRegistrySchema.test.ts
+++ b/tests/unit/mcp/toolRegistrySchema.test.ts
@@ -27,6 +27,16 @@ describe('toolRegistry pose-envelope schema options', () => {
expect(prop.type).toBe('boolean');
});
+ it('review_cad schema declares reaction and clevis structure review flags', () => {
+ const def = findTool('review_cad');
+ expect(def.inputSchema.properties.includePhysicalUseCaseJointReactions).toMatchObject({
+ type: 'boolean',
+ });
+ expect(def.inputSchema.properties.includePhysicalUseCaseJointStructure).toMatchObject({
+ type: 'boolean',
+ });
+ });
+
it('review_cad schema declares gripperAperture refs', () => {
const def = findTool('review_cad');
const prop = def.inputSchema.properties.gripperAperture as {
diff --git a/tests/unit/review/reviewPipelineStages.test.ts b/tests/unit/review/reviewPipelineStages.test.ts
index c08b31ad2..f3a695a82 100644
--- a/tests/unit/review/reviewPipelineStages.test.ts
+++ b/tests/unit/review/reviewPipelineStages.test.ts
@@ -9,6 +9,7 @@ describe('review pipeline stage structure', () => {
'default-pose-geometry',
'mechanical-review',
'pose-envelope',
+ 'physical-use-case',
'mechanism-truth',
'fitness-and-repair',
]);
diff --git a/tests/unit/runtime/interferenceClassification.test.ts b/tests/unit/runtime/interferenceClassification.test.ts
new file mode 100644
index 000000000..73c36d089
--- /dev/null
+++ b/tests/unit/runtime/interferenceClassification.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import { classifyInterferencePairs, summarizeInterferencePairs } from '../../../src/modeling/runtime/interferenceClassification';
+import { jointContactCapMm3 } from '../../../src/modeling/runtime/jointContactCap';
+
+describe('interference classification', () => {
+ it('classifies raw pairs below or equal to the cap as contact noise', () => {
+ const cap = jointContactCapMm3();
+
+ const result = classifyInterferencePairs([
+ { a: 'palm-root', b: 'index-proximal', volumeMm3: 0.5 },
+ { a: 'index-proximal', b: 'index-middle', volumeMm3: cap },
+ ]);
+
+ expect(result.map((pair) => pair.classification)).toEqual(['contact-noise', 'contact-noise']);
+ expect(result.map((pair) => pair.actionable)).toEqual([false, false]);
+ });
+
+ it('classifies raw pairs above the cap as actionable', () => {
+ const cap = jointContactCapMm3();
+
+ const result = classifyInterferencePairs([
+ { a: 'servo', b: 'palm-root', volumeMm3: cap + 0.01 },
+ ]);
+
+ expect(result).toEqual([
+ {
+ a: 'servo',
+ b: 'palm-root',
+ volumeMm3: cap + 0.01,
+ capMm3: cap,
+ classification: 'actionable',
+ actionable: true,
+ },
+ ]);
+ });
+
+ it('summarizes raw, contact-noise, and actionable counts', () => {
+ const cap = jointContactCapMm3();
+
+ expect(summarizeInterferencePairs([
+ { a: 'a', b: 'b', volumeMm3: 1 },
+ { a: 'c', b: 'd', volumeMm3: cap + 1 },
+ ])).toMatchObject({
+ rawCount: 2,
+ contactNoiseCount: 1,
+ actionableCount: 1,
+ capMm3: cap,
+ });
+ });
+});
diff --git a/tests/unit/server/viteReviewLiveEndpoint.test.ts b/tests/unit/server/viteReviewLiveEndpoint.test.ts
new file mode 100644
index 000000000..77731b041
--- /dev/null
+++ b/tests/unit/server/viteReviewLiveEndpoint.test.ts
@@ -0,0 +1,188 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Andrii Shylenko and kernelCAD contributors
+import { describe, expect, it, vi } from 'vitest';
+import type { IncomingMessage, ServerResponse } from 'node:http';
+import viteConfig from '../../../vite.config';
+
+const livePairs = [
+ { a: 'raw-a', b: 'raw-b', volumeMm3: 1 },
+ { a: 'real-a', b: 'real-b', volumeMm3: 30 },
+];
+
+vi.mock('../../../src/agent/mcp/tools/reviewCad', () => ({
+ reviewCadTool: vi.fn(async () => {
+ throw new Error('live-only review should not run full reviewCadTool');
+ }),
+}));
+
+vi.mock('../../../src/modeling/runtime/detectInterferences', () => ({
+ detectInterferences: vi.fn(() => ({ pairs: livePairs })),
+}));
+
+vi.mock('../../../src/modeling/mates/physicalUseCase', () => ({
+ reviewPhysicalUseCasesWithReachability: vi.fn(async () => ({
+ checkedUseCaseCount: 1,
+ diagnostics: [
+ {
+ code: 'assembly.physical-use-case.contact-unreachable',
+ severity: 'error',
+ message: 'target contact cannot be reached',
+ hint: 'move the contact connector',
+ },
+ ],
+ })),
+}));
+
+vi.mock('../../../src/kernel/backends/sceneBackend', () => ({
+ isSceneBackend: vi.fn(() => true),
+}));
+
+vi.mock('../../../src/server/sessionPool', () => ({
+ createSessionPool: vi.fn(() => ({
+ get: vi.fn(() => ({
+ model: {
+ session: {
+ cachedShapes: new Map([['tail-id', { parts: [{ name: 'a' }, { name: 'b' }] }]]),
+ assemblies: new Map([['hand', { name: 'hand' }]]),
+ },
+ tailId: 'tail-id',
+ tailShape: undefined,
+ },
+ })),
+ prune: vi.fn(),
+ rebuildByScript: vi.fn(),
+ })),
+}));
+
+vi.mock('../../../src/server/middleware/sessionEndpoint', () => ({
+ createSessionEndpoint: vi.fn(() => async () => undefined),
+}));
+
+vi.mock('../../../src/server/middleware/eventsEndpoint', () => ({
+ createEventsEndpoint: vi.fn(() => async () => undefined),
+}));
+
+vi.mock('../../../src/server/middleware/paramsEndpoint', () => ({
+ createParamsEndpoint: vi.fn(() => async () => undefined),
+}));
+
+vi.mock('../../../src/server/middleware/transformsEndpoint', () => ({
+ createTransformsEndpoint: vi.fn(() => async () => undefined),
+}));
+
+vi.mock('../../../src/server/middleware/animationBakeEndpoint', () => ({
+ createAnimationBakeEndpoint: vi.fn(() => async () => undefined),
+}));
+
+vi.mock('../../../src/modeling/buildModel', () => ({
+ buildModelFromFile: vi.fn(),
+}));
+
+type Handler = (req: IncomingMessage, res: ServerResponse) => Promise;
+
+async function getReviewHandler(): Promise {
+ const resolved = typeof viteConfig === 'function'
+ ? await viteConfig({ command: 'serve', mode: 'test' })
+ : viteConfig;
+ const plugin = (resolved.plugins ?? [])
+ .flat()
+ .find((candidate) => candidate && candidate.name === 'kernelcad-mesh-endpoint');
+ if (!plugin || !('configureServer' in plugin) || typeof plugin.configureServer !== 'function') {
+ throw new Error('kernelcad-mesh-endpoint plugin not found');
+ }
+
+ const handlers = new Map();
+ plugin.configureServer({
+ middlewares: {
+ use: (path: string, handler: Handler) => {
+ handlers.set(path, handler);
+ },
+ },
+ ws: { send: vi.fn() },
+ config: { logger: { info: vi.fn(), error: vi.fn() } },
+ } as never);
+
+ const handler = handlers.get('/__kernelcad/review');
+ if (!handler) throw new Error('/__kernelcad/review handler not registered');
+ return handler;
+}
+
+function createResponse() {
+ let body = '';
+ const headers = new Map();
+ const res = {
+ statusCode: 0,
+ setHeader: (name: string, value: string) => {
+ headers.set(name.toLowerCase(), value);
+ },
+ end: (chunk: string) => {
+ body = chunk;
+ },
+ };
+ return {
+ res: res as unknown as ServerResponse,
+ read: () => ({
+ statusCode: res.statusCode,
+ contentType: headers.get('content-type'),
+ body,
+ json: JSON.parse(body) as {
+ rawInterferencePairs?: typeof livePairs;
+ diagnostics?: Array<{ code?: string; severity?: string }>;
+ ok?: boolean;
+ livePhysicalUseCaseReview?: boolean;
+ fitness?: { functional?: boolean; repairMode?: string; blockingReasons?: unknown[] };
+ interferenceSummary?: {
+ rawCount: number;
+ contactNoiseCount: number;
+ actionableCount: number;
+ capMm3: number;
+ };
+ },
+ }),
+ };
+}
+
+describe('Vite /__kernelcad/review live endpoint', () => {
+ it('returns an interference summary with live raw pairs', async () => {
+ const handler = await getReviewHandler();
+ const { res, read } = createResponse();
+
+ await handler({
+ method: 'GET',
+ url: '?script=examples/robot-arm/desktop-3axis-mates.kcad.ts&session=tok-live&live=1',
+ } as IncomingMessage, res);
+
+ const response = read();
+ expect(response.statusCode).toBe(200);
+ expect(response.contentType).toBe('application/json');
+ expect(response.json.rawInterferencePairs).toEqual(livePairs);
+ expect(response.json.interferenceSummary).toMatchObject({
+ rawCount: 2,
+ contactNoiseCount: 1,
+ actionableCount: 1,
+ capMm3: 20,
+ });
+ });
+
+ it('adds physical use case reachability diagnostics on live review when the script declares one', async () => {
+ const handler = await getReviewHandler();
+ const { res, read } = createResponse();
+
+ await handler({
+ method: 'GET',
+ url: '?script=tests/fixtures/robot-hand/rejected-five-finger-kinematic-hand.kcad.ts&session=tok-live&live=1',
+ } as IncomingMessage, res);
+
+ const response = read();
+ expect(response.statusCode).toBe(200);
+ expect(response.json.ok).toBe(false);
+ expect(response.json.livePhysicalUseCaseReview).toBe(true);
+ expect(response.json.diagnostics?.map((diagnostic) => diagnostic.code)).toContain(
+ 'assembly.physical-use-case.contact-unreachable',
+ );
+ expect(response.json.fitness).toMatchObject({
+ functional: false,
+ repairMode: 'physical-use-case',
+ });
+ });
+});
diff --git a/vite.config.ts b/vite.config.ts
index 0936292ae..c056ac6d8 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { isAbsolute, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
+import { summarizeInterferencePairs } from './src/modeling/runtime/interferenceClassification';
const repoRoot = fileURLToPath(new URL('.', import.meta.url));
const require = createRequire(import.meta.url);
@@ -36,11 +37,14 @@ function isPathInside(parent: string, child: string): boolean {
function resolveExampleScript(script: string | null): string | null {
if (!script) return null;
- const examplesRoot = resolve(repoRoot, 'examples');
+ const allowedRoots = [
+ resolve(repoRoot, 'examples'),
+ resolve(repoRoot, 'tests/fixtures'),
+ ];
const scriptPath = resolve(repoRoot, script);
if (
!script.endsWith('.kcad.ts') ||
- !isPathInside(examplesRoot, scriptPath)
+ !allowedRoots.some((root) => isPathInside(root, scriptPath))
) {
return null;
}
@@ -558,30 +562,56 @@ function kernelCadMeshEndpoint(): Plugin {
const { detectInterferences } = await import('./src/modeling/runtime/detectInterferences');
const { isSceneBackend } = await import('./src/kernel/backends/sceneBackend');
- // When a session token is present, recompute raw interferences
+ // When a session token is present, recompute interferences
// against the LIVE pooled session's tail scene — that captures the
// user's current Params-tab edits via the SSE relower path. The
// base reviewCadTool still re-evaluates from the script source so
// its validator + envelope output stays comparable across reloads;
- // we overlay the live raw count on top for the Studio HUD's
- // slider-drag responsiveness.
+ // we overlay the live raw pairs and actionable summary on top for
+ // the Studio HUD's slider-drag responsiveness.
const sessionToken = url.searchParams.get('session');
// `live=1` (sent by the client's relower-triggered refresh): skip
// the FULL review — reviewCadTool re-evaluates the script from
// source and runs the pose-envelope sweep, which on a jointed
- // assembly takes MINUTES — and return only the live raw
- // interference pairs from the pooled session. The full review
- // still runs on initial load and on an explicit Validate press;
- // the client merges this lighter payload over the last full one.
+ // assembly takes MINUTES — and return only the live interference
+ // channel from the pooled session. Session-backed initial load
+ // also uses this cheap path; explicit Validate still runs the full
+ // review, and the client merges the lighter payload over the last
+ // full one when available.
const liveOnly = url.searchParams.get('live') === '1' && Boolean(sessionToken);
- const review = liveOnly
+ const review: {
+ ok: boolean;
+ diagnostics: unknown[];
+ fitness?: {
+ functional?: boolean;
+ repairMode?: string;
+ blockingReasons?: readonly unknown[];
+ };
+ live?: boolean;
+ livePhysicalUseCaseReview?: boolean;
+ [key: string]: unknown;
+ } = liveOnly
? { ok: true, diagnostics: [], live: true }
: await reviewCadTool({
file: scriptPath,
includePoseEnvelope: true,
includeInterference: true,
- });
+ }) as unknown as {
+ ok: boolean;
+ diagnostics: unknown[];
+ fitness?: {
+ functional?: boolean;
+ repairMode?: string;
+ blockingReasons?: readonly unknown[];
+ };
+ live?: boolean;
+ livePhysicalUseCaseReview?: boolean;
+ [key: string]: unknown;
+ };
+ const sourceDeclaresPhysicalUseCase = liveOnly
+ ? readFileSync(scriptPath, 'utf-8').includes('physicalUseCase(')
+ : false;
if (sessionToken) {
try {
@@ -605,7 +635,43 @@ function kernelCadMeshEndpoint(): Plugin {
0.01,
new Set(),
).pairs;
- (review as { rawInterferencePairs?: unknown }).rawInterferencePairs = livePairs;
+ Object.assign(review, {
+ rawInterferencePairs: livePairs,
+ interferenceSummary: summarizeInterferencePairs(livePairs),
+ });
+ }
+ if (sourceDeclaresPhysicalUseCase) {
+ review.livePhysicalUseCaseReview = true;
+ const { reviewPhysicalUseCasesWithReachability } = await import('./src/modeling/mates/physicalUseCase');
+ const session = entry?.model.session as unknown as {
+ assemblies?: Map[0]>;
+ } | undefined;
+ const assemblies = [...(session?.assemblies?.values() ?? [])];
+ const diagnostics = [];
+ for (const assembly of assemblies) {
+ const physical = await reviewPhysicalUseCasesWithReachability(assembly, {
+ requirePhysicalUseCase: true,
+ includeReachability: true,
+ reachabilitySamplesPerMate: 3,
+ });
+ diagnostics.push(...physical.diagnostics);
+ }
+ if (diagnostics.length > 0) {
+ review.diagnostics = [...review.diagnostics, ...diagnostics];
+ const hasError = diagnostics.some((diagnostic) => diagnostic.severity === 'error');
+ if (hasError) {
+ review.ok = false;
+ review.fitness = {
+ ...(review.fitness ?? {}),
+ functional: false,
+ repairMode: 'physical-use-case',
+ blockingReasons: [
+ ...(review.fitness?.blockingReasons ?? []),
+ ...diagnostics,
+ ],
+ };
+ }
+ }
}
} catch {
// Session-side overlay failed; fall back to the script-eval pairs