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 });