From af2d17bf0dc018c9e6c7137cb6a914c286ab3c8d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 7 Oct 2026 16:31:48 +0000 Subject: [PATCH] fix(mechanism): fail a disconnected link when the pose sweep is skipped SO-100's elbow and wrist shells stop short of the joint, and a fastened servo box was enough for joint-mesh continuity to call that connected. The check also never ran once the arm exceeded the BREP sweep budget, so evaluate stayed ok. Rest-pose body continuity now runs anyway, only the two mated bodies count, and contact far from the axis does not. Printed axle bosses carry the elbow and wrist so those shells meet. Signed-off-by: Cursor Agent Co-authored-by: Andrii Shylenko --- cookbook/snippets/so-arm100.md | 35 +++ examples/robot-arm/so100/so100-arm.kcad.ts | 62 ++++- scripts/cookbookEvaluate.ts | 6 +- src/agent/cookbook/loader.test.ts | 21 ++ src/agent/cookbook/loader.ts | 17 ++ src/agent/mcp/tools/executeCookbook.ts | 14 +- src/agent/skills/kernelcad-mcp/SKILL.md | 1 + src/kernel/backends/occt/brepDistance.ts | 33 +++ src/modeling/runtime/brepDistance.ts | 2 +- .../runtime/jointMeshContinuity.test.ts | 54 ++++- src/modeling/runtime/jointMeshContinuity.ts | 214 ++++++++++-------- src/modeling/runtime/mechanismTruth.ts | 98 ++++---- src/shared/diagnostics/registry/mechanism.ts | 17 +- .../examples/gearfinityPlanetaryStage.test.ts | 3 +- .../examples/so100ArmMounts.test.ts | 3 +- .../assemblies/mechanismSweepBudget.test.ts | 41 +++- 16 files changed, 459 insertions(+), 162 deletions(-) create mode 100644 cookbook/snippets/so-arm100.md diff --git a/cookbook/snippets/so-arm100.md b/cookbook/snippets/so-arm100.md new file mode 100644 index 000000000..dad0944a9 --- /dev/null +++ b/cookbook/snippets/so-arm100.md @@ -0,0 +1,35 @@ +--- +id: so-arm100 +title: SO-ARM100 follower arm — vendor link meshes, STEP jaw, passive horn +tags: [assembly, connector, mate, revolute, joint, kinematic, parameter, actuator] +keywords: + - SO-ARM100 + - SO-100 robot arm + - LeRobot follower arm + - so100 gripper jaw + - real robot arm yellow links black servos + - Moving_Jaw passive horn pincer + - six axis pan lift elbow wrist roll jaw + - execute_cookbook so-arm100 +example: examples/robot-arm/so100/so100-arm.kcad.ts +when_to_use: >- + The prompt asks for the real SO-ARM100 / SO-100 follower arm, the + kernelcad.com hero arm, or a gripper with the vendor moving jaw — not a + tube-and-clevis sketch. execute_cookbook({ id: 'so-arm100' }) evaluates + examples/robot-arm/so100/so100-arm.kcad.ts (Apache-2.0 link STLs, the + Moving_Jaw STEP solid, and the passive horn on the wrist). One-liner: + kernelcad evaluate examples/robot-arm/so100/so100-arm.kcad.ts. Do not + redraw it as floating cylinders or the primitive 4-DOF cookbook arm. +--- + +```typescript +// Not a self-contained snippet. Vendor meshes live next to the example. +// execute_cookbook({ id: 'so-arm100' }) evaluates this file: +// examples/robot-arm/so100/so100-arm.kcad.ts +// CLI: kernelcad evaluate examples/robot-arm/so100/so100-arm.kcad.ts +// +// Jaw: parts/Moving_Jaw.step (solid, millimetres, same frame as the sim +// STL) baked by the gripper rpy and then the wrist-roll rpy, so the finger +// closes on the fixed jaw. parts/Passive_Horn.step is fastened to the jaw +// mount face (bolt circle at local z = -24). jawDeg -11 is the closed stop. +``` diff --git a/examples/robot-arm/so100/so100-arm.kcad.ts b/examples/robot-arm/so100/so100-arm.kcad.ts index c06fea2b3..b5f623a06 100644 --- a/examples/robot-arm/so100/so100-arm.kcad.ts +++ b/examples/robot-arm/so100/so100-arm.kcad.ts @@ -9,7 +9,9 @@ const lift = param('liftDeg', 0, { min: 0, max: 200, description: 'Shoulder pitc const elbow = param('elbowDeg', 0, { min: -180, max: 0, description: 'Elbow pitch' }); const wrist = param('wristDeg', 0, { min: -140, max: 65, description: 'Wrist pitch' }); const roll = param('rollDeg', 0, { min: -180, max: 180, description: 'Wrist roll' }); -const jaw = param('jawDeg', 0, { min: -11, max: 110, description: 'Gripper opening' }); +// -11° is the closed stop: the moving finger meets the fixed jaw. +// 0° leaves the pincer open; the reach animation still drives jawDeg. +const jaw = param('jawDeg', -11, { min: -11, max: 110, description: 'Gripper opening' }); type Vec3 = [number, number, number]; type Joint = { xyz: Vec3; rpy: Vec3; axis: Vec3; q: ReturnType }; @@ -55,6 +57,13 @@ function bake(shape: { rotate: (axis: Vec3, deg: number) => typeof shape }, j: J return shape.rotate([1, 0, 0], j.rpy[0]).rotate([0, 1, 0], j.rpy[1]).rotate([0, 0, 1], j.rpy[2]); } +// Printed boss on the joint axis. The vendor shells stop ~20 mm short of +// the elbow and wrist pivots, and a servo box fastened to one link is not +// the link. The boss is the revolute child; the next shell fastens to it. +function axleBoss() { + return cylinder(46, 23).translate(0, 0, -23).rotate([0, 1, 0], 90).color(PRINT); +} + const arm = assembly('so100-arm'); async function printed(name: string, file: string, incoming: Joint | null, parentBake: Joint | null) { @@ -105,21 +114,65 @@ const upper = await printed('upper-arm', 'Upper_Arm.stl', shoulderLift, shoulder const lower = await printed('lower-arm', 'Lower_Arm.stl', elbowFlex, shoulderLift); const wristLink = await printed('wrist', 'Wrist_Pitch_Roll.stl', wristFlex, elbowFlex); const hand = await printed('hand', 'Fixed_Jaw.stl', wristRoll, wristFlex); -await printed('jaw', 'Moving_Jaw.stl', gripper, wristRoll); +// The sim STL is an open shell in the jaw link frame. Baking only the +// gripper rpy (Ry 180) left that finger rolled about Y relative to the +// fixed jaw, which already carries the wrist-roll bake. The fingertip +// then sat beside the fixed finger and could not close. The STEP solid +// is the same frame in millimetres; bake the wrist roll on top of the +// gripper rpy so the finger lies in the closing plane. +const jawRaw = await lib.fromSTEP('parts/Moving_Jaw.step'); +const jawLink = arm.part('jaw', bake(bake(jawRaw, gripper), wristRoll).color(PRINT)); +jawLink.connector('in', { + type: 'axis', + origin: { kind: 'vec3', value: [0, 0, 0] }, + axis: spun(wristRoll, axisInParent(gripper)), +}); +// Passive horn bolts to the jaw mount face (bolt circle at local z = -24, +// same seat as so100.kcad.ts). A couple of millimetres of the disc sit in +// the hub so the fastened connector is inside both solids; the rest of +// the aluminium disc stands proud of the knuckle. +const HORN_Z = -26.8; +const hornRaw = (await lib.fromSTEP('parts/Passive_Horn.step')) + .finish('aluminium') + .translate(0, 0, HORN_Z); +const jawHorn = arm.part('jaw-horn', bake(bake(hornRaw, gripper), wristRoll)); +// Local (0, 0, -24.8) is inside the hub and inside the seated horn. +// Ry(90) * Ry(180) sends (0, 0, z) to (-z, 0, 0). +const hornMount: Vec3 = [24.8, 0, 0]; +jawLink.connector('horn', { type: 'frame', origin: { kind: 'vec3', value: hornMount } }); +jawHorn.connector('mount', { type: 'frame', origin: { kind: 'vec3', value: hornMount } }); hinge(base, 'pan', null, shoulderPan); hinge(shoulder, 'lift', shoulderPan, shoulderLift); hinge(upper, 'elbow', shoulderLift, elbowFlex); hinge(lower, 'wrist', elbowFlex, wristFlex); hinge(wristLink, 'roll', wristFlex, wristRoll); + +function boss(name: string) { + const part = arm.part(name, axleBoss()); + part.connector('in', { + type: 'axis', + origin: { kind: 'vec3', value: [0, 0, 0] }, + axis: [1, 0, 0], + }); + part.connector('link', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } }); + return part; +} +boss('elbow-boss'); +boss('wrist-boss'); +lower.connector('elbow-boss', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } }); +wristLink.connector('wrist-boss', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } }); hinge(hand, 'jaw', wristRoll, gripper); arm.mate('pan', 'base.pan', 'shoulder.in', 'revolute', { pose: pan, limitsDeg: [-110, 110] }); arm.mate('lift', 'shoulder.lift', 'upper-arm.in', 'revolute', { pose: lift, limitsDeg: [0, 200] }); -arm.mate('elbow', 'upper-arm.elbow', 'lower-arm.in', 'revolute', { pose: elbow, limitsDeg: [-180, 0] }); -arm.mate('wrist', 'lower-arm.wrist', 'wrist.in', 'revolute', { pose: wrist, limitsDeg: [-140, 65] }); +arm.mate('elbow', 'upper-arm.elbow', 'elbow-boss.in', 'revolute', { pose: elbow, limitsDeg: [-180, 0] }); +arm.mate('elbow-boss-fix', 'elbow-boss.link', 'lower-arm.elbow-boss', 'fastened'); +arm.mate('wrist', 'lower-arm.wrist', 'wrist-boss.in', 'revolute', { pose: wrist, limitsDeg: [-140, 65] }); +arm.mate('wrist-boss-fix', 'wrist-boss.link', 'wrist.wrist-boss', 'fastened'); arm.mate('roll', 'wrist.roll', 'hand.in', 'revolute', { pose: roll, limitsDeg: [-180, 180] }); arm.mate('jaw', 'hand.jaw', 'jaw.in', 'revolute', { pose: jaw, limitsDeg: [-11, 110] }); +arm.mate('jaw-horn-bolts', 'jaw.horn', 'jaw-horn.mount', 'fastened'); servo(base, 'base', 'base-servo', [0, -32.7, 46.5], [25, 46, 40], null); servo(shoulder, 'shoulder', 'shoulder-servo', [0, 90, 30.6], [40, 46, 25], shoulderPan); @@ -180,6 +233,7 @@ return arm.solvedModel({}, { ['lower-arm', 'wrist'], ['wrist', 'hand'], ['hand', 'jaw'], + ['jaw', 'jaw-horn'], ['base', 'base-servo'], ['shoulder', 'shoulder-servo'], ['upper-arm', 'upper-servo'], diff --git a/scripts/cookbookEvaluate.ts b/scripts/cookbookEvaluate.ts index 54441cecb..31bcbc240 100644 --- a/scripts/cookbookEvaluate.ts +++ b/scripts/cookbookEvaluate.ts @@ -23,8 +23,10 @@ async function main(): Promise { let failed = 0; for (const s of snippets) { - const file = join(tmp, `${s.id}.kcad.ts`); - writeFileSync(file, s.body); + // Snippets that point at a repo example import meshes beside that file. + // Evaluating a temp copy of the fence would miss those assets. + const file = s.example ?? join(tmp, `${s.id}.kcad.ts`); + if (!s.example) writeFileSync(file, s.body); const r = await evaluateScript(file); if (r.ok) { console.log(`✓ ${s.id}`); diff --git a/src/agent/cookbook/loader.test.ts b/src/agent/cookbook/loader.test.ts index fb77af06b..7b12ac7f7 100644 --- a/src/agent/cookbook/loader.test.ts +++ b/src/agent/cookbook/loader.test.ts @@ -85,4 +85,25 @@ describe('loadSnippets', () => { const snippets = loadSnippets(rootDir, tagsPath); expect(snippets.map((s) => s.id)).toEqual(['alpha-snippet', 'beta-snippet']); }); + + it('accepts an example path that points at a real .kcad.ts file', () => { + const root = mkdtempSync(join(tmpdir(), 'cookbook-ex-')); + mkdirSync(join(root, 'snippets'), { recursive: true }); + writeFileSync(join(root, 'model.kcad.ts'), 'return box(1, 1, 1);\n'); + writeFileSync(join(root, 'tags.json'), JSON.stringify(['assembly'])); + const md = goodSnippet + .replace('id: fillet-face-after-subtract', 'id: vendor-arm') + .replace('title: Fillet only the top face after subtract', 'title: Vendor arm') + .replace('tags: [fillet, subtract, face-ref]', 'tags: [assembly]') + .replace('when_to_use: After subtracting a hole or pocket, you want to round only the rim.', 'when_to_use: Run the example file.\nexample: model.kcad.ts'); + writeFileSync(join(root, 'snippets', 'vendor-arm.md'), md); + const prev = process.cwd(); + process.chdir(root); + try { + const snippets = loadSnippets(join(root, 'snippets'), join(root, 'tags.json')); + expect(snippets[0].example).toBe('model.kcad.ts'); + } finally { + process.chdir(prev); + } + }); }); diff --git a/src/agent/cookbook/loader.ts b/src/agent/cookbook/loader.ts index de113cf2e..26bd2bb93 100644 --- a/src/agent/cookbook/loader.ts +++ b/src/agent/cookbook/loader.ts @@ -12,6 +12,12 @@ export interface Snippet { when_to_use: string; body: string; // raw TS code, fences stripped filepath: string; // for traceability + /** + * Repo-relative `.kcad.ts` to evaluate instead of `body`. Used when the + * real model imports vendor meshes that cannot be copied into a temp + * snippet. `body` stays as the agent-facing note. + */ + example?: string; } const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; @@ -80,6 +86,16 @@ function parseSnippet(raw: string, filepath: string, allowedTags: Set): throw new Error(`${filepath}: exactly one code fence required, found ${matches.length}`); } + const example = typeof fm.example === 'string' ? fm.example.trim() : undefined; + if (example !== undefined) { + if (example.length === 0 || example.startsWith('/') || example.includes('..') || !example.endsWith('.kcad.ts')) { + throw new Error(`${filepath}: example must be a repo-relative .kcad.ts path`); + } + if (!existsSync(example)) { + throw new Error(`${filepath}: example file not found: ${example}`); + } + } + return { id: fm.id as string, title: fm.title as string, @@ -88,5 +104,6 @@ function parseSnippet(raw: string, filepath: string, allowedTags: Set): when_to_use: fm.when_to_use as string, body: matches[0][1].trim(), filepath, + ...(example !== undefined ? { example } : {}), }; } diff --git a/src/agent/mcp/tools/executeCookbook.ts b/src/agent/mcp/tools/executeCookbook.ts index 0e8f0615e..5377eb3f5 100644 --- a/src/agent/mcp/tools/executeCookbook.ts +++ b/src/agent/mcp/tools/executeCookbook.ts @@ -12,6 +12,8 @@ // freehand-author instead. import { randomBytes } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; import { search, type Snippet } from '../../cookbook/index'; import { evaluateScriptTool, type EvaluateScriptOutput } from './evaluateScript'; import { getCookbookSnippets } from './lookupCookbook'; @@ -243,11 +245,19 @@ export async function executeCookbookTool( } const { snippet } = resolved; - const code = snippet.body; + // Vendor-mesh examples (SO-ARM100) are not self-contained snippet bodies. + // Evaluate the declared file so relative lib.fromSTEP / fromSTL paths resolve. + const code = snippet.example + ? await readFile(resolve(snippet.example), 'utf8') + : snippet.body; let evaluate: EvaluateScriptOutput; try { - evaluate = await evaluateScriptTool({ code, dryRun: dryRun || undefined }); + evaluate = await evaluateScriptTool( + snippet.example + ? { file: resolve(snippet.example), dryRun: dryRun || undefined } + : { code, dryRun: dryRun || undefined }, + ); } catch (err) { return fail(executionId, 'evaluate', err instanceof Error ? err.message : String(err), { cookbookId: snippet.id, diff --git a/src/agent/skills/kernelcad-mcp/SKILL.md b/src/agent/skills/kernelcad-mcp/SKILL.md index dde1bbf90..82cd19c1c 100644 --- a/src/agent/skills/kernelcad-mcp/SKILL.md +++ b/src/agent/skills/kernelcad-mcp/SKILL.md @@ -50,6 +50,7 @@ Use before freehand authoring on production / industry prompts. Prefer `design_l | Multi-body mechanism + joints + animation | `multi-body-mechanism-real-proportions` | Proven pin **MLl-f0Uq@1**; CDN mesh+anim | | Scissor-lift / scissor-jack table | `scissor-lift-closed-loop` | Closed-loop FK unsupported — open-chain prismatic + mid-pose X-links | | 4-DOF / multi-axis production robot arm | `multi-dof-robot-arm-4axis` | Bridged yokes, yaw clearance, mechanicalJoint chain, reach anim | +| Real SO-ARM100 (LeRobot follower, yellow links, gripper) | `so-arm100` | `execute_cookbook({ id: 'so-arm100' })` runs `examples/robot-arm/so100/so100-arm.kcad.ts` — vendor STL links + STEP jaw and passive horn. Do not substitute the primitive 4-DOF arm | | 4-DOF release/evidence pack (static hold, BOM, drawing, USD, FEA stop) | `4dof-release-evidence-pack` | Runnable arm + honest FEA-unavailable / USD-mate stops | | 2-DOF pan/tilt inspection head + rigid routed tube + animation | `mated-inspection-head-routed-tube-animation` | `param()` beside `animationView`; tube is rigid, not a hose | | Shop-release GT2 belt rotary actuator (housing, 608, cover, materials) | `gt2-shop-release-actuator` | Cord/nubs ≠ tooth mesh; no USD (cylindrical mate) | diff --git a/src/kernel/backends/occt/brepDistance.ts b/src/kernel/backends/occt/brepDistance.ts index 357ddf113..a76f17f42 100644 --- a/src/kernel/backends/occt/brepDistance.ts +++ b/src/kernel/backends/occt/brepDistance.ts @@ -28,3 +28,36 @@ export function brepExtremaDistance(oc: any, shapeA: any, shapeB: any): number | dist.delete?.(); } } + +/** Closest-point pair from the same distance solver. Index is 1-based, + * matching OCCT's `PointOnShape*(N)`. */ +export function brepExtremaContact( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + oc: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + shapeA: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + shapeB: any, +): { distanceMm: number; pointA: readonly [number, number, number]; pointB: readonly [number, number, number] } | undefined { + const dist = new oc.BRepExtrema_DistShapeShape_1(); + dist.LoadS1(shapeA); + dist.LoadS2(shapeB); + try { + const ok = dist.Perform(new oc.Message_ProgressRange_1()); + if (!ok || !dist.IsDone() || dist.NbSolution() < 1) return undefined; + const p1 = dist.PointOnShape1(1); + const p2 = dist.PointOnShape2(1); + try { + return { + distanceMm: dist.Value(), + pointA: [p1.X(), p1.Y(), p1.Z()], + pointB: [p2.X(), p2.Y(), p2.Z()], + }; + } finally { + p1.delete?.(); + p2.delete?.(); + } + } finally { + dist.delete?.(); + } +} diff --git a/src/modeling/runtime/brepDistance.ts b/src/modeling/runtime/brepDistance.ts index f44fec54b..ff7a00fb2 100644 --- a/src/modeling/runtime/brepDistance.ts +++ b/src/modeling/runtime/brepDistance.ts @@ -17,4 +17,4 @@ export function wrappedShape(backend: OcctBackend): any { } /** Re-exported from the kernel layer (one implementation for every caller). */ -export { brepExtremaDistance } from '../../kernel/backends/occt/brepDistance'; +export { brepExtremaContact, brepExtremaDistance } from '../../kernel/backends/occt/brepDistance'; diff --git a/src/modeling/runtime/jointMeshContinuity.test.ts b/src/modeling/runtime/jointMeshContinuity.test.ts index d67a1a0bd..b9b41867b 100644 --- a/src/modeling/runtime/jointMeshContinuity.test.ts +++ b/src/modeling/runtime/jointMeshContinuity.test.ts @@ -173,8 +173,60 @@ describe('joint-mesh-continuity helper (P8)', () => { expect(gaps.length).toBeGreaterThanOrEqual(1); // The fallback ran and reported the true rigid-group clearance in // the diagnostic, so the agent sees both distances. - const withBearing = gaps.find((g) => g.message.includes('Nearest contact between the mated rigid groups')); + const withBearing = gaps.find((g) => g.message.includes('Nearest contact between the two mated bodies')); expect(withBearing).toBeDefined(); expect(result.mechanism).toBe('broken'); }, 90000); + + it('fingertip-only contact far from the axis does not connect the link to the body', async () => { + // Pads meet 0.2 mm apart, but 40 mm off the hinge. That is a finger + // pinch, not a knuckle. The pivot is in the air on both sides. + const { arm, kcad } = makeArm('far-finger'); + const parent = arm.part('wrist', kcad.box(10, 10, 10, true).translate(40, 0, 0)); + parent.connector('jaw', { + type: 'axis', + origin: { kind: 'vec3', value: [0, 0, 0] }, + axis: [0, 0, 1], + }); + const child = arm.part('claw', kcad.box(10, 10, 10, true).translate(40, 0, 10.2)); + child.connector('in', { + type: 'axis', + origin: { kind: 'vec3', value: [0, 0, 0] }, + axis: [0, 0, 1], + }); + arm.mate('jaw', 'wrist.jaw', 'claw.in', 'revolute', { limitsDeg: [-20, 20] }); + + const result = await checkMechanismTruth(arm); + const gaps = result.failures.filter((f) => f.code === 'mechanism.joint-mesh-gap'); + expect(gaps.length).toBeGreaterThanOrEqual(1); + expect(gaps.some((g) => g.message.includes('off the joint axis'))).toBe(true); + expect(result.mechanism).toBe('broken'); + }, 90000); + + it('a fastened box that only grazes the neighbour does not connect a floating link', async () => { + const { arm, kcad } = makeArm('servo-graze'); + const parent = arm.part('link', kcad.box(10, 10, 10, true).translate(0, 0, -20)); + parent.connector('hinge', { + type: 'axis', + origin: { kind: 'vec3', value: [0, 0, 0] }, + axis: [0, 0, 1], + }); + parent.connector('servo', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } }); + const child = arm.part('claw', kcad.box(10, 10, 10, true).translate(0, 0, 20)); + child.connector('in', { + type: 'axis', + origin: { kind: 'vec3', value: [0, 0, 0] }, + axis: [0, 0, 1], + }); + // Overlaps the claw completely, and does not contain the pivot at the origin. + const servo = arm.part('servo', kcad.box(10, 10, 10, true).translate(0, 0, 20)); + servo.connector('mount', { type: 'frame', origin: { kind: 'vec3', value: [0, 0, 0] } }); + arm.mate('servo-fix', 'link.servo', 'servo.mount', 'fastened'); + arm.mate('jaw', 'link.hinge', 'claw.in', 'revolute', { limitsDeg: [-20, 20] }); + + const result = await checkMechanismTruth(arm); + expect(result.failures.some((f) => f.code === 'mechanism.joint-mesh-gap')).toBe(true); + expect(result.mechanism).toBe('broken'); + }, 90000); + }); diff --git a/src/modeling/runtime/jointMeshContinuity.ts b/src/modeling/runtime/jointMeshContinuity.ts index d48c38ac5..799d0c2ba 100644 --- a/src/modeling/runtime/jointMeshContinuity.ts +++ b/src/modeling/runtime/jointMeshContinuity.ts @@ -31,15 +31,13 @@ // margin alone). // // Bearing-contact fallback: a pivot deliberately in open space (annular -// rim seats, spindles running in a bore of a part FASTENED to the mated -// part, hollow-axis valve rotors) is NOT floating — the joint is -// constrained by bearing contact away from the axis. When a pivot probe -// exceeds the allowed gap, the helper measures the true minimum distance -// between the two mated RIGID GROUPS (parts joined transitively by -// fastened mates) and reports it as `bearingGapMm`; the caller passes -// the joint when that distance is within the same tolerance. A -// genuinely floating part exceeds the tolerance everywhere and still -// fails. +// rim seat, hollow-axis rotor) is NOT floating when the two mated bodies +// themselves meet near the joint axis. The distance is between those two +// bodies only — a servo box or horn fastened to one side does not count, +// or a link that never reaches its joint passes by grazing a neighbour. +// Contact farther than JOINT_BEARING_AXIS_RADIUS_MM from the axis (a +// fingertip pinch) does not count either. The caller passes the joint +// when `bearingGapMm` is within the same 1 mm tolerance. // // Implementation notes: // @@ -60,7 +58,7 @@ import type { OcctBackend } from '../../kernel/backends/occt/occtBackend'; import { OcctBackend as OcctBackendClass } from '../../kernel/backends/occt/occtBackend'; import type { Transform, Vec3 } from '../../shared/runtime/se3'; import { parseConnectorRef, type MateRecord } from '../mates/mate'; -import { brepExtremaDistance, wrappedShape } from './brepDistance'; +import { brepExtremaContact, brepExtremaDistance, wrappedShape } from './brepDistance'; /** * Per-spec tolerance (mm) for the joint-mesh-continuity check. Wide @@ -75,6 +73,16 @@ import { brepExtremaDistance, wrappedShape } from './brepDistance'; */ export const JOINT_MESH_GAP_TOLERANCE_MM = 1.0; +/** + * A bearing that "connects" two links must sit on the joint, not on a + * fingertip or a servo box grazing the next link far from the axis. + * 32 mm covers the annular-rim seat (disc radius 28 mm, ring to 30 mm) + * and still rejects a gripper finger ~70 mm out on the jaw. Not a + * substitute for the 1 mm gap tolerance — a far contact never counts, + * however small the gap. + */ +export const JOINT_BEARING_AXIS_RADIUS_MM = 32; + /** * Radius (mm) of the probe sphere used to detect "point lies inside * the body" via boolean intersection. Smaller than the gap tolerance @@ -135,6 +143,13 @@ export interface JointMeshGapResult { * OCCT distance solver failed. */ readonly bearingGapMm?: number; + /** + * Set when the closest surface contact sits farther than + * {@link JOINT_BEARING_AXIS_RADIUS_MM} from the joint axis. That + * contact is not a bearing: a fingertip pinch or a distant box graze + * must not pass a joint whose pivot is in the air. + */ + readonly bearingOffAxisRadialMm?: number; } /** @@ -168,29 +183,6 @@ function indexAssemblyParts(arm: Assembly): Map> { - const fastenedAdj = new Map>(); - for (const m of arm.__mates()) { - if (m.type !== 'fastened') continue; - try { - const a = parseConnectorRef(m.a).partName; - const b = parseConnectorRef(m.b).partName; - if (!fastenedAdj.has(a)) fastenedAdj.set(a, new Set()); - if (!fastenedAdj.has(b)) fastenedAdj.set(b, new Set()); - fastenedAdj.get(a)!.add(b); - fastenedAdj.get(b)!.add(a); - } catch { - continue; - } - } - return fastenedAdj; -} - interface ResolvedMateContext { parsedA: { partName: string; connectorName: string }; parsedB: { partName: string; connectorName: string }; @@ -267,23 +259,27 @@ function buildSideRow( }; } +interface BearingAxis { + readonly origin: readonly [number, number, number]; + readonly direction: readonly [number, number, number]; +} + function applyBearingGap( rows: JointMeshGapResult[], - fastenedAdj: ReadonlyMap>, sceneByPartName: ReadonlyMap, parentPartName: string, childPartName: string, + axis: BearingAxis | undefined, ): JointMeshGapResult[] { - // Bearing-contact fallback (only paid for when a pivot probe fails): - // measure the true minimum distance between the two mated rigid - // groups. The caller passes the joint when this lands within - // tolerance — the pivot sits in deliberately open space (annular rim - // seat, bushing-at-a-distance) but real material constrains the - // joint elsewhere. + // Bearing-contact fallback (only paid for when a pivot probe fails). + // Distance is between the two mated bodies, not a servo or horn + // fastened to one of them — those used to graze the neighbour and + // pass a link that never reaches its joint. Contact farther than + // JOINT_BEARING_AXIS_RADIUS_MM from the axis is not a seat either. if (rows.some((r) => r.signedDistanceMm > r.clearanceRadiusMm + JOINT_MESH_GAP_TOLERANCE_MM)) { - const bearingGapMm = measureMateBearingGap(fastenedAdj, sceneByPartName, parentPartName, childPartName); - if (bearingGapMm !== undefined) { - return rows.map((r) => ({ ...r, bearingGapMm })); + const bearing = measureMateBearingGap(sceneByPartName, parentPartName, childPartName, axis); + if (bearing !== undefined) { + return rows.map((r) => ({ ...r, ...bearing })); } } return rows; @@ -294,7 +290,6 @@ function collectMateRows( rest: JointMeshContinuityRestSample, sceneByPartName: ReadonlyMap, partByName: ReadonlyMap[number]>, - fastenedAdj: ReadonlyMap>, ): JointMeshGapResult[] { const ctx = resolveMateContext(mate, rest, partByName); if (ctx === undefined) return []; @@ -320,7 +315,20 @@ function collectMateRows( if (row !== undefined) rows.push(row); } - return applyBearingGap(rows, fastenedAdj, sceneByPartName, ctx.parsedA.partName, ctx.parsedB.partName); + const axis = bearingAxisFromParent(ctx); + return applyBearingGap(rows, sceneByPartName, ctx.parsedA.partName, ctx.parsedB.partName, axis); +} + +function bearingAxisFromParent(ctx: ResolvedMateContext): BearingAxis | undefined { + const local = ctx.aConn.axis; + if (local === undefined) return undefined; + const spun = ctx.T_A.axisDir(local); + const len = Math.hypot(spun[0], spun[1], spun[2]); + if (len < 1e-9) return undefined; + return { + origin: ctx.pivotWorld, + direction: [spun[0] / len, spun[1] / len, spun[2] / len], + }; } export function checkJointMeshContinuity( @@ -332,79 +340,78 @@ export function checkJointMeshContinuity( const partByName = indexAssemblyParts(arm); - const fastenedAdj = buildFastenedAdjacency(arm); - for (const mate of arm.__mates()) { - out.push(...collectMateRows(mate, rest, sceneByPartName, partByName, fastenedAdj)); + out.push(...collectMateRows(mate, rest, sceneByPartName, partByName)); } return out; } /** - * Collect the rigid group of `root`: every part reachable from it over - * fastened-mate edges (inclusive of `root` itself). Articulated mates - * are NOT traversed — they are the joints whose bearing we're checking. - */ -function collectFastenedGroup( - fastenedAdj: ReadonlyMap>, - root: string, -): Set { - const visited = new Set([root]); - const queue = [root]; - while (queue.length > 0) { - const cur = queue.shift()!; - for (const next of fastenedAdj.get(cur) ?? []) { - if (!visited.has(next)) { - visited.add(next); - queue.push(next); - } - } - } - return visited; -} - -/** - * Minimum world-space distance (mm) between the rigid groups of the two - * mated parts. Early-exits as soon as a pair lands within - * `JOINT_MESH_GAP_TOLERANCE_MM` (the caller only compares against that - * threshold). If the two groups intersect — a pathological assembly - * that declares both an articulated mate and a fastened chain between - * the same parts — falls back to the directly-mated pair so a shared - * part can't trivially report zero. + * Minimum world-space distance (mm) between the two mated bodies. + * Early-exits once a contact within tolerance is also within the + * joint-axis radius. A fastened accessory is not consulted. */ function measureMateBearingGap( - fastenedAdj: ReadonlyMap>, sceneByPartName: ReadonlyMap, parentPartName: string, childPartName: string, -): number | undefined { - let groupA = collectFastenedGroup(fastenedAdj, parentPartName); - let groupB = collectFastenedGroup(fastenedAdj, childPartName); - if ([...groupA].some((n) => groupB.has(n))) { - groupA = new Set([parentPartName]); - groupB = new Set([childPartName]); - } + axis: BearingAxis | undefined, +): { bearingGapMm?: number; bearingOffAxisRadialMm?: number } | undefined { + const pair = [ + [parentPartName, childPartName], + ] as const; let best: number | undefined; - for (const aName of groupA) { + let offAxisRadial: number | undefined; + for (const [aName, bName] of pair) { const a = sceneByPartName.get(aName); if (a === undefined) continue; - for (const bName of groupB) { - const b = sceneByPartName.get(bName); - if (b === undefined) continue; - const d = measureBodyToBodyGap( + const b = sceneByPartName.get(bName); + if (b === undefined) continue; + { + const contact = measureBodyToBodyContact( a.shape as OcctBackend, a.worldTransform, b.shape as OcctBackend, b.worldTransform, ); - if (d === undefined) continue; - if (best === undefined || d < best) best = d; - if (best <= JOINT_MESH_GAP_TOLERANCE_MM) return best; + if (contact === undefined) continue; + if (axis !== undefined) { + const radial = Math.max( + radialDistanceMm(contact.pointA, axis), + radialDistanceMm(contact.pointB, axis), + ); + if (radial > JOINT_BEARING_AXIS_RADIUS_MM) { + if (offAxisRadial === undefined || radial < offAxisRadial) offAxisRadial = radial; + continue; + } + } + if (best === undefined || contact.distanceMm < best) best = contact.distanceMm; + if (best <= JOINT_MESH_GAP_TOLERANCE_MM) { + return { bearingGapMm: best }; + } } } - return best; + if (best === undefined && offAxisRadial === undefined) return undefined; + return { + ...(best !== undefined ? { bearingGapMm: best } : {}), + ...(offAxisRadial !== undefined ? { bearingOffAxisRadialMm: offAxisRadial } : {}), + }; +} + +function radialDistanceMm( + point: readonly [number, number, number], + axis: BearingAxis, +): number { + const rx = point[0] - axis.origin[0]; + const ry = point[1] - axis.origin[1]; + const rz = point[2] - axis.origin[2]; + const proj = rx * axis.direction[0] + ry * axis.direction[1] + rz * axis.direction[2]; + const px = rx - axis.direction[0] * proj; + const py = ry - axis.direction[1] * proj; + const pz = rz - axis.direction[2] * proj; + return Math.hypot(px, py, pz); } /** @@ -494,3 +501,20 @@ export function measureBodyToBodyGap( } } +function measureBodyToBodyContact( + localShapeA: OcctBackend, + worldTransformA: Transform, + localShapeB: OcctBackend, + worldTransformB: Transform, +): { distanceMm: number; pointA: readonly [number, number, number]; pointB: readonly [number, number, number] } | undefined { + try { + const worldA = localShapeA.clone().applyTransform(worldTransformA); + const worldB = localShapeB.clone().applyTransform(worldTransformB); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const oc = getOC() as any; + return brepExtremaContact(oc, wrappedShape(worldA), wrappedShape(worldB)); + } catch { + return undefined; + } +} + diff --git a/src/modeling/runtime/mechanismTruth.ts b/src/modeling/runtime/mechanismTruth.ts index 919aae5e9..2ddd90c2b 100644 --- a/src/modeling/runtime/mechanismTruth.ts +++ b/src/modeling/runtime/mechanismTruth.ts @@ -69,6 +69,7 @@ import { assemblyToMjcf } from './mjcfExport'; import { loadMujocoSession } from './mujocoSession'; import { checkJointMeshContinuity, + JOINT_BEARING_AXIS_RADIUS_MM, JOINT_MESH_GAP_TOLERANCE_MM, } from './jointMeshContinuity'; import { @@ -86,11 +87,13 @@ export const POSE_SAMPLE_COUNT_PER_MATE = 3; /** * Deterministic work budget for the BREP-lowering criteria (2 - * interpenetration, 3 dof-mismatch, 7 joint-mesh-gap, 8 - * tendon-body-intersect). Those criteria lower the whole assembly once - * per pose sample and run pairwise BREP overlap, so cost grows as - * `(distinct pose lowers) × parts`. On a dense assembly this is a - * Cartesian blow-up: the Gearfinity planetary stage (24 parts × 13 pose + * interpenetration, 3 dof-mismatch, 8 tendon-body-intersect). Those + * criteria lower the whole assembly once per pose sample and run + * pairwise BREP overlap, so cost grows as `(distinct pose lowers) × + * parts`. Criterion 7 (joint-mesh-gap) is NOT in that sweep: it probes + * the rest pose only, and a disconnected link must still fail when the + * sweep is skipped. On a dense assembly the pose sweep is a Cartesian + * blow-up: the Gearfinity planetary stage (24 parts × 13 pose * samples + 4 revolute mates × 3 dof micro-poses) timed the * `validate --include-interference` CLI out past 5 minutes (issue #348). * @@ -191,9 +194,11 @@ export interface MechanismTruthOptions { /** * Override for the deterministic BREP-sweep work budget (see * {@link BREP_SWEEP_BUDGET}). When the estimated sweep work exceeds - * this, criteria 2/3/7/8 are skipped and the verdict degrades to - * `'unverified'`. Mainly a test seam (pass a tiny budget to force the - * degradation path) and an escape hatch for tractable large assemblies + * this, criteria 2/3/8 (pose sweep) are skipped and the verdict + * degrades to `'unverified'` unless rest-pose joint-mesh continuity + * (criterion 7) finds a disconnected body, which is `'broken'`. + * Mainly a test seam (pass a tiny budget to force the degradation + * path) and an escape hatch for tractable large assemblies * (pass `Infinity` to always sweep). Defaults to `BREP_SWEEP_BUDGET`. */ readonly sweepBudget?: number; @@ -287,33 +292,40 @@ export async function checkMechanismTruth( return { mechanism: failures.length === 0 ? 'real' : 'broken', failures }; } - // BREP-sweep budget gate (issue #348). Criteria 2/3/7/8 each lower the + // BREP-sweep budget gate (issue #348). Criteria 2/3/8 each lower the // whole assembly per pose sample — a Cartesian cost that explodes on // dense mechanisms (Gearfinity: 24 parts × 13 samples + 4 mates × 3 dof // micro-poses ≈ 600 work units, > 5 min). Estimate the work from the - // assembly graph (no lowering) and skip the sweep when it's intractable; + // assembly graph (no lowering) and skip that sweep when it's intractable; // the verdict then degrades to 'unverified' rather than timing out. - // Computed BEFORE criterion 1 so the shared rest-scene lower below only - // runs when a criterion actually needs a lowered scene. + // Criterion 7 is not part of the sweep: one rest lower is enough to + // tell a floating end-effector from a connected chain, and skipping it + // let a disconnected claw pass as 'unverified' (ok: true). const partCount = arm.__parts().length; const sweepBudget = effectiveSweepBudget(opts.sweepBudget); const sweepWork = estimateSweepWork(arm, solved.length); const sweepSkipped = sweepWork > sweepBudget; // One lowered rest scene is shared by criterion 1 (local bbox corners), - // the sweep's per-pose detection (re-posed via `solveMates` transforms), - // and criteria 7/8. `Shape.lower()` re-runs the ENTIRE record chain, so - // the old per-fastened-part bbox lowering was O(parts) full-assembly - // lowers — the dominant cost of `validate --include-interference` on the - // 42-part turbojet. Skipped when no criterion needs a scene (no fastened - // mates AND an over-budget sweep). + // criterion 7 (joint-mesh continuity), and — when the sweep runs — the + // per-pose detection (re-posed via `solveMates` transforms) plus + // criterion 8. `Shape.lower()` re-runs the ENTIRE record chain, so the + // old per-fastened-part bbox lowering was O(parts) full-assembly lowers. + // The rest lower always runs: criterion 7 needs it even when the pose + // sweep is over budget. That is one lower, not the Cartesian sweep. const loweredBase: LoweredSceneBase = {}; - const hasFastenedMates = arm.__mates().some((m) => m.type === 'fastened'); - if (hasFastenedMates || !sweepSkipped) { - const restSample = solved.find((s) => s.sample.name === 'rest'); - if (restSample !== undefined) { - await lowerSceneForSample(arm, restSample, loweredBase); - } + const restSample = solved.find((s) => s.sample.name === 'rest'); + if (restSample !== undefined) { + await lowerSceneForSample(arm, restSample, loweredBase); + } + if (loweredBase.scene === undefined && arm.__mates().length > 0) { + // A failed lower used to skip criterion 7 and leave the verdict + // 'unverified'. That is a pass for a disconnected body. If the + // bodies cannot be measured, the chain is not certified connected. + failures.push(makeFailure( + 'mechanism.joint-mesh-gap', + 'Rest-pose body continuity could not be measured because the assembly did not lower. A link that cannot be shown to meet its joint is not an unverified mechanism.', + )); } // Criterion 1 (mechanism.disconnect) — fastened-mate invariant. Reads the @@ -321,6 +333,15 @@ export async function checkMechanismTruth( // to per-part `originalShape.lower()` only when that scene is missing. failures.push(...(await checkFastenedInvariant(arm, solved, loweredBase.scene))); + // Criterion 7 (mechanism.joint-mesh-gap) — at REST pose, every joint + // pivot must lie inside both its parent and child body meshes, or the + // two rigid groups must bear on each other. This is the "connected to + // a body" gate. It does not honor `solvedModel({ ignore })` and it runs + // even when the pose sweep is skipped: a floating claw is a defect, not + // an unverified sweep. P8 slice; closes the visual-mesh-gap hole MJCF + // cannot see. + failures.push(...(await checkJointMeshContinuityCriterion(arm, solved, loweredBase))); + if (sweepSkipped) { // LOUD skip (T3): the over-budget skip used to emit ONLY a console.warn // and push nothing into `failures`, so the resulting 'unverified' @@ -329,7 +350,8 @@ export async function checkMechanismTruth( // carrying the work estimate, the (auto-scaled) budget, and the part // count so 'unverified' is evidence, not silence. Severity 'warn': // "couldn't verify" must NOT make `ok: false` on its own (that's - // reserved for a definitive 'broken'). + // reserved for a definitive 'broken', including a rest-pose + // joint-mesh gap above). failures.push(makeBudgetExceeded(sweepWork, sweepBudget, partCount, solved.length)); } else { // `loweredBase` was populated above (the rest sample's full lower); @@ -346,13 +368,6 @@ export async function checkMechanismTruth( // assembly has no revolute mates. failures.push(...(await checkDofMismatch(arm, loweredBase))); - // Criterion 7 (mechanism.joint-mesh-gap) — at REST pose, every joint - // pivot must lie inside both its parent and child body meshes. P8 - // slice; closes the visual-mesh-gap hole MJCF cannot see (joints in - // physics are constraints on abstract rigid bodies, not assertions - // about material continuity in the rendered geometry). - failures.push(...(await checkJointMeshContinuityCriterion(arm, solved, loweredBase))); - // Criterion 8 (mechanism.tendon-body-intersect) — a balance tendon's // routed path must not cut through a non-anchor body at any sampled // pose. The static authoring backstop for "the spring goes through the @@ -831,10 +846,15 @@ async function checkJointMeshContinuityCriterion( // constrained by material away from the axis, not floating. if (r.bearingGapMm !== undefined && r.bearingGapMm <= JOINT_MESH_GAP_TOLERANCE_MM) continue; const bearingNote = r.bearingGapMm !== undefined - ? ` Nearest contact between the mated rigid groups is ` + + ? ` Nearest contact between the two mated bodies is ` + `${r.bearingGapMm.toFixed(1)}mm — no bearing surface constrains ` + `the joint within tolerance either.` : ''; + const offAxisNote = r.bearingOffAxisRadialMm !== undefined + ? ` The closest surface contact sits ${r.bearingOffAxisRadialMm.toFixed(1)}mm ` + + `off the joint axis, outside the ${JOINT_BEARING_AXIS_RADIUS_MM.toFixed(0)}mm ` + + `knuckle radius, so it does not connect the link to the body.` + : ''; const failure = makeFailure( 'mechanism.joint-mesh-gap', `Joint '${r.mateName}' ${r.side} body '${r.partName}': nearest solid is ` + @@ -842,7 +862,7 @@ async function checkJointMeshContinuityCriterion( `${allowedGap.toFixed(1)}mm = clearance bore ${r.clearanceRadiusMm.toFixed(1)}mm + ` + `${JOINT_MESH_GAP_TOLERANCE_MM.toFixed(1)}mm margin). The link mesh does not ` + `reach the joint it pivots on — extend the body geometry so its OCCT ` + - `knuckle solid surrounds the joint origin at rest pose.` + bearingNote + + `knuckle solid surrounds the joint origin at rest pose.` + bearingNote + offAxisNote + ` Do not fix this by moving the connector alone: the mate re-aligns the ` + `mated part '${r.otherPartName}' onto the moved connector, which can ` + `silently reopen a collision elsewhere — re-run the collision check ` + @@ -1349,14 +1369,14 @@ function makeBudgetExceeded( code, severity: 'warn', message: - `The articulated BREP pose-sweep (collision/dof/joint-mesh/tendon criteria) was SKIPPED: ` + + `The articulated BREP pose-sweep (collision/dof/tendon criteria) was SKIPPED: ` + `estimated work ${sweepWork} exceeds the budget ${sweepBudget} ` + `(${partCount} part${partCount === 1 ? '' : 's'} × ${poseSampleCount} solved pose ` + `sample${poseSampleCount === 1 ? '' : 's'} + revolute dof micro-poses). The mechanism ` + - `verdict is 'unverified' — it is NOT certified collision-free across its travel; the ` + - `cheap criteria (orphan-part, fastened-rigidity) still ran and found no defect. ` + - `Reduce the part/pose count, verify a tractable sub-assembly, or pass a larger ` + - `sweepBudget to force the full sweep.`, + `is NOT certified collision-free across its travel. Rest-pose joint-mesh ` + + `continuity still ran — a link that does not meet its body is 'broken', ` + + `not unverified. Reduce the part/pose count, verify a tractable ` + + `sub-assembly, or pass a larger sweepBudget to force the full sweep.`, hint: HINT_TEMPLATES[code].template, nextAction: HINT_TEMPLATES[code].nextAction, }; diff --git a/src/shared/diagnostics/registry/mechanism.ts b/src/shared/diagnostics/registry/mechanism.ts index 03163a2ed..d2edc6117 100644 --- a/src/shared/diagnostics/registry/mechanism.ts +++ b/src/shared/diagnostics/registry/mechanism.ts @@ -51,15 +51,16 @@ export const MECHANISM_CODES = { }, // Physics-grounded loop — T3 slice (post-condition trust gate). Emitted by // `mechanismTruth.ts` when the BREP pose-sweep work estimate exceeds the - // (auto-scaled) budget and criteria 2/3/7/8 are SKIPPED, degrading the - // verdict to 'unverified'. Replaces the old silent console.warn: the - // 'unverified' verdict now carries machine-readable evidence (work - // estimate, budget, part count) so an agent can react instead of mistaking - // it for a clean 'real'. Non-fatal (severity 'warn') — "could not verify" - // is not "broken". + // (auto-scaled) budget and criteria 2/3/8 are SKIPPED. Criterion 7 + // (rest-pose joint-mesh continuity) still runs; a gap there is 'broken'. + // Otherwise the verdict degrades to 'unverified'. Replaces the old silent + // console.warn: the 'unverified' verdict now carries machine-readable + // evidence (work estimate, budget, part count) so an agent can react + // instead of mistaking it for a clean 'real'. Non-fatal (severity 'warn') + // — "could not verify the pose sweep" is not "broken". 'mechanism.unverified-budget-exceeded': { hintTemplate: - "The articulated collision/pose sweep was skipped because its estimated work exceeded the budget, so the mechanism is 'unverified' (NOT certified collision-free). Verify a tractable subset of the assembly, reduce the part/pose count, or pass a larger sweepBudget to force a full sweep. The cheap criteria (orphan-part, fastened-rigidity) still ran.", + "The articulated collision/pose sweep was skipped because its estimated work exceeded the budget, so the mechanism is not certified collision-free across its travel. Rest-pose joint-mesh continuity still ran: a link that does not meet its body fails as mechanism.joint-mesh-gap. Verify a tractable subset of the assembly, reduce the part/pose count, or pass a larger sweepBudget to force a full sweep.", nextAction: { kind: 'inspect-message' }, defaultSeverity: 'warn', group: 'mechanism', @@ -67,7 +68,7 @@ export const MECHANISM_CODES = { }, 'mechanism.joint-mesh-gap': { hintTemplate: - 'Extend the parent body geometry so its OCCT solid reaches the joint origin at rest pose. Most commonly: increase the height of the column / boss that hosts the joint, or move the part-local connector origin onto an actual face/edge of the body. A pivot deliberately in open space (annular rim seat, spindle riding in the bore of a fastened block) passes when the mated rigid groups maintain bearing contact within tolerance somewhere away from the axis.', + 'Extend the parent body geometry so its OCCT solid reaches the joint origin at rest pose. Most commonly: increase the height of the column / boss that hosts the joint, or move the part-local connector origin onto an actual face/edge of the body. A pivot deliberately in open space (annular rim seat, spindle riding in the bore of a fastened block) passes when the mated rigid groups maintain bearing contact within tolerance near the joint axis. A fingertip pinch or a box grazing the next link far from that axis does not count.', nextAction: { kind: 'fix-arg', field: 'partGeometry' }, defaultSeverity: 'error', group: 'mechanism', diff --git a/tests/integration/examples/gearfinityPlanetaryStage.test.ts b/tests/integration/examples/gearfinityPlanetaryStage.test.ts index fd47bda3f..520f57e43 100644 --- a/tests/integration/examples/gearfinityPlanetaryStage.test.ts +++ b/tests/integration/examples/gearfinityPlanetaryStage.test.ts @@ -80,7 +80,8 @@ describe('Gearfinity-inspired planetary stage gallery example', () => { // micro-poses blew past the 5-minute CLI budget. The deterministic // BREP-sweep budget (`BREP_SWEEP_BUDGET`, see mechanismTruth.ts) now // estimates the sweep work up front (~600 work units > 300 budget) and - // SKIPS criteria 2/3/7/8 rather than grinding through them, so the + // SKIPS criteria 2/3/8 rather than grinding through them. Rest-pose + // joint-mesh continuity (criterion 7) still runs. With no gap, the // verdict degrades to `mechanism: 'unverified'` and the run completes // in normal time. Rest-pose static interference is still checked by // the validate interference surface (interferencePairs), which runs diff --git a/tests/integration/examples/so100ArmMounts.test.ts b/tests/integration/examples/so100ArmMounts.test.ts index fedde8fe6..d36fde48b 100644 --- a/tests/integration/examples/so100ArmMounts.test.ts +++ b/tests/integration/examples/so100ArmMounts.test.ts @@ -26,7 +26,8 @@ describe('SO-100 arm servo mounts sit on the servo body', () => { .sort(); // The two remaining points are the shoulder and wrist joint frames. // Each one sits in its servo, past the printed shell. Moving the frame - // would shift the arm. + // would shift the arm. The elbow and wrist bosses are separate parts + // on those axes; their own connectors sit inside the boss solid. expect(offSolid).toEqual(['lower-arm.wrist', 'shoulder.lift']); }, PER_EXAMPLE_TIMEOUT_MS); }); diff --git a/tests/unit/assemblies/mechanismSweepBudget.test.ts b/tests/unit/assemblies/mechanismSweepBudget.test.ts index 0a75a6f26..55a600e3e 100644 --- a/tests/unit/assemblies/mechanismSweepBudget.test.ts +++ b/tests/unit/assemblies/mechanismSweepBudget.test.ts @@ -1,17 +1,19 @@ // tests/unit/assemblies/mechanismSweepBudget.test.ts // // Issue #348: the BREP-lowering mechanism-truth criteria (2 interpenetration, -// 3 dof-mismatch, 7 joint-mesh-gap, 8 tendon-body-intersect) lower the whole -// assembly once per pose sample. On a dense mechanism (e.g. the 24-part -// Gearfinity planetary stage) that Cartesian cost times the CLI out past -// 5 minutes. `checkMechanismTruth` now estimates the sweep work up front -// (deterministically, from the assembly graph — no lowering, no wall-clock) -// and SKIPS the sweep when it exceeds `BREP_SWEEP_BUDGET`, degrading the -// verdict to `'unverified'` instead of grinding. +// 3 dof-mismatch, 8 tendon-body-intersect) lower the whole assembly once +// per pose sample. On a dense mechanism (e.g. the 24-part Gearfinity +// planetary stage) that Cartesian cost times the CLI out past 5 minutes. +// `checkMechanismTruth` estimates the sweep work up front (deterministically, +// from the assembly graph — no wall-clock) and SKIPS that sweep when it +// exceeds `BREP_SWEEP_BUDGET`, degrading the verdict to `'unverified'` +// instead of grinding. Rest-pose joint-mesh continuity (criterion 7) is +// not part of the sweep: a floating link is `'broken'` even at budget 0. // // This file pins that gate with a tiny hand-rolled hinge so the behaviour is // covered without paying the multi-minute heavy-assembly cost: -// - sweepBudget: 0 → over budget → sweep skipped → 'unverified', no failures +// - sweepBudget: 0 on a meeting hinge → sweep skipped → 'unverified' +// - sweepBudget: 0 on a floating link → joint-mesh-gap → 'broken' // - sweepBudget: 1e9 → under budget → sweep runs → NOT 'unverified' import { describe, it, expect } from 'vitest'; @@ -53,6 +55,9 @@ describe('checkMechanismTruth — BREP-sweep budget gate (issue #348)', () => { // the work estimate, the budget, and the part count. const result = await checkMechanismTruth(arm, { sweepBudget: 0 }); expect(result.mechanism).toBe('unverified'); + // The hinge faces meet, so the rest-pose body check that still runs + // under a skipped sweep must not invent a gap. + expect(result.failures.filter((d) => d.code === 'mechanism.joint-mesh-gap')).toEqual([]); const budgetDiags = result.failures.filter( (d) => d.code === 'mechanism.unverified-budget-exceeded', ); @@ -79,6 +84,26 @@ describe('checkMechanismTruth — BREP-sweep budget gate (issue #348)', () => { }); }); + it('fails a floating link as broken even when the pose sweep is over budget', async () => { + // The check that used to be skipped with the sweep: a child whose + // mesh never meets the parent must not come back 'unverified'. + const session = new CaptureSession(); + const kcad = createModelingApi({ session }); + const arm = kcad.assembly('floating-claw'); + arm.part('wrist', kcad.box(20, 20, 10, true).translate(0, 0, -5)) + .connector('jaw', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 5] }, axis: [0, 1, 0] }); + arm.part('claw', kcad.box(20, 20, 20, true).translate(0, 0, 13)) + .connector('in', { type: 'axis', origin: { kind: 'vec3', value: [0, 0, 0] }, axis: [0, 1, 0] }); + arm.mate('jaw', 'wrist.jaw', 'claw.in', 'revolute', { pose: 0, limitsDeg: [-45, 45] }); + const result = await checkMechanismTruth(arm, { sweepBudget: 0 }); + expect(result.mechanism).toBe('broken'); + const gaps = result.failures.filter((d) => d.code === 'mechanism.joint-mesh-gap'); + expect(gaps.length).toBeGreaterThanOrEqual(1); + expect(gaps.some((d) => d.severity === 'error')).toBe(true); + // The skipped sweep is still reported, and it does not hide the gap. + expect(result.failures.some((d) => d.code === 'mechanism.unverified-budget-exceeded')).toBe(true); + }); + it('runs the full sweep (verdict is real or broken, never unverified) under a generous budget', async () => { const arm = makeHinge(); const result = await checkMechanismTruth(arm, { sweepBudget: 1e9 });