Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions cookbook/snippets/so-arm100.md
Original file line number Diff line number Diff line change
@@ -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.
```
62 changes: 58 additions & 4 deletions examples/robot-arm/so100/so100-arm.kcad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof param> };
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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'],
Expand Down
6 changes: 4 additions & 2 deletions scripts/cookbookEvaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ async function main(): Promise<void> {
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}`);
Expand Down
21 changes: 21 additions & 0 deletions src/agent/cookbook/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
17 changes: 17 additions & 0 deletions src/agent/cookbook/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]*)$/;
Expand Down Expand Up @@ -80,6 +86,16 @@ function parseSnippet(raw: string, filepath: string, allowedTags: Set<string>):
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,
Expand All @@ -88,5 +104,6 @@ function parseSnippet(raw: string, filepath: string, allowedTags: Set<string>):
when_to_use: fm.when_to_use as string,
body: matches[0][1].trim(),
filepath,
...(example !== undefined ? { example } : {}),
};
}
14 changes: 12 additions & 2 deletions src/agent/mcp/tools/executeCookbook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/agent/skills/kernelcad-mcp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
33 changes: 33 additions & 0 deletions src/kernel/backends/occt/brepDistance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?.();
}
}
2 changes: 1 addition & 1 deletion src/modeling/runtime/brepDistance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
54 changes: 53 additions & 1 deletion src/modeling/runtime/jointMeshContinuity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

});
Loading
Loading