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 ?? ''} @@ -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