Skip to content
Merged
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
37 changes: 37 additions & 0 deletions apps/desktop/e2e/solidcraft.contract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3392,6 +3392,43 @@ test('the assistant makes a knob for a D shaft with one call: a part of its own
}
});

test('the assistant clips a cover onto its base with snap hooks: each hook catches in its window and the parts share no volume', async () => {
test.setTimeout(240_000);
const { mkdtempSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const work = mkdtempSync(join(tmpdir(), 'partbuddy-snap-'));
const env = {
SOLIDCRAFT_CONTROL_DIR: join(work, 'control'),
PARTBUDDY_SOLIDCRAFT_PORT: '7958',
PARTBUDDY_PROJECTS_DIR: join(work, 'projects'),
PARTBUDDY_KEYRING_SERVICE: 'PartBuddy-e2e',
PARTBUDDY_CLAUDE_COMMAND: JSON.stringify([process.execPath, join(__dirname, '..', 'test', 'fakeClaude.mjs')]),
};
const launched = await launchApp({ isolated: false, work, env });
const { page } = launched;
try {
await expect(page.getByTestId('connection-status')).toHaveAttribute('data-engine', 'headless', { timeout: 30_000 });
await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a clipped cover');
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByText(/Built 1 snap hook "Back clip" on Base, each with its window through the 2 mm wall of Cover: an arm 12 mm long, 1\.5 mm thick and 5 mm wide, 0\.2 mm off the wall.* Built 1 snap hook "Front clip" on Base/)).toBeVisible({ timeout: 90_000 });
await expect(page.getByText(/Warning:/)).toHaveCount(0);
await expect(page.getByTestId('mesh-summary')).toContainText('2 bodies', { timeout: 30_000 });
await expect(page.getByText('Snap hooks “Back clip” with their windows')).toHaveCount(1);
await expect(page.locator('[data-testid="timeline-feature"][data-state="error"]')).toHaveCount(0);
// Hooks and cover share no volume: each catch lies in its window.
await page.getByTestId('print-check').click();
const findings = page.getByTestId('print-check-panel');
await expect(findings).toBeVisible();
await page.waitForTimeout(1500);
await expect(findings.locator('[data-kind="overlap"]')).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath('snap-hooks-built.png') });
} finally {
await launched.close();
rmSync(work, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 });
}
});

test('the assistant makes a screw lid for a jar with one call: the threads mesh as built and the lid prints on its top', async () => {
test.setTimeout(240_000);
const { mkdtempSync, rmSync } = await import('node:fs');
Expand Down
49 changes: 38 additions & 11 deletions apps/desktop/src/main/agents/mechanismTool.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LID, LIP, hingeProblem, hingeSteps, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, pinDiameter, screwLength, screwProblem, screwSteps, slideProblem, slideSteps, type HingeSpec, type LidSpec, type LipSpec, type ScrewSpec, type SlideSpec } from '../../shared/mechanisms';
import { LID, LIP, hingeProblem, hingeSteps, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, pinDiameter, screwLength, screwProblem, screwSteps, slideProblem, slideSteps, snapProblem, snapSteps, SNAP, type HingeSpec, type LidSpec, type LipSpec, type ScrewSpec, type SlideSpec, type SnapSpec } from '../../shared/mechanisms';
import { BLOCK_SIZES, INSERT, type BlockSize } from '../../shared/screwSizes';
import { componentOf } from '../solidcraft/actions';
import type { ToolResult, UpstreamTool, UpstreamTools } from './gateProxy';
Expand Down Expand Up @@ -27,25 +27,26 @@ const mechanismTool: UpstreamTool = {
'When the lid is built open beside the box and closes on a hinge, give the hinge line too (hinge_from, hinge_to), so the rille lands where the bead closes. It makes no joint. ' +
'screw: one or more screws that hold the moving body on the fixed one: a clearance hole with the room for the head in the moving body, and on the same line the seat of a heat-set insert (or a modelled thread from M5) in the fixed body; the screws go in as standard parts. ' +
'Give from where the screw goes in (on the outer face of the moving body) and to where its line passes into the fixed body (on the face the two meet at); more lists where further screws of the same kind go in. ' +
'snap: cantilever snap hooks standing on the fixed body, each catching in a window cut through a wall of the moving body, for a cover that clips on and comes off by pressing the catches back. Give from as the foot of the hook (on the face of the fixed body it stands on, at the inner face of that wall), up as the way the hook stands, which is the way the parts come apart, to as a point further into the wall (the way the catch points), wall as the wall\'s thickness, and more for further hooks catching the same way; call again for the opposite wall. It makes no joint. ' +
'lid: a screw lid for a round container. It coils a thread with 45° flanks onto the container\'s neck and makes the lid itself, with the matching thread, flutes to grip and its top as print face; the two mesh as built. ' +
'Give the container as fixed, the name the lid shall have as moving (no such body yet), from as the centre of the rim at its height, and diameter as the neck\'s outer diameter; the neck must be a plain upright cylinder of that diameter below the rim (the answer says how far). ' +
'Use this instead of modelling a hinge, a guide, a snap lid, a threaded lid or the holes of a screw joint yourself.',
inputSchema: {
type: 'object',
properties: {
kind: { type: 'string', enum: ['hinge', 'slide', 'closure', 'screw', 'lid'], description: 'hinge: turns about a line; slide: moves along a line; closure: a lid snaps shut on its box; screw: one body screwed onto the other; lid: a screw lid is made for a round container.' },
kind: { type: 'string', enum: ['hinge', 'slide', 'closure', 'screw', 'lid', 'snap'], description: 'hinge: turns about a line; slide: moves along a line; closure: a lid snaps shut on its box; screw: one body screwed onto the other; lid: a screw lid is made for a round container; snap: snap hooks on one body catch in windows of the other.' },
fixed: { type: 'string', description: 'The body that stays (exact name); it carries the pin or the rail. closure: the box.' },
moving: { type: 'string', description: 'The body that moves (exact name). closure: the lid.' },
from: { ...vector, description: 'One end of the line in mm. closure: one corner of the lid\'s inner room at its rim. lid: the centre of the container\'s rim.' },
to: { ...vector, description: 'The other end of the line in mm. closure: the opposite corner, at the same height. lid: not needed.' },
up: { ...vector, description: 'slide: the normal of the face of the fixed body the rail stands on; default +Z. closure: the way the lip stands out of the lid, +Z or -Z; default +Z.' },
from: { ...vector, description: 'One end of the line in mm. closure: one corner of the lid\'s inner room at its rim. lid: the centre of the container\'s rim. snap: the foot of the hook.' },
to: { ...vector, description: 'The other end of the line in mm. closure: the opposite corner, at the same height. lid: not needed. snap: a point further into the wall the catch goes into.' },
up: { ...vector, description: 'slide: the normal of the face of the fixed body the rail stands on; default +Z. closure: the way the lip stands out of the lid, +Z or -Z; default +Z. snap: the way the hook stands out of the fixed body; default +Z.' },
side: { type: 'string', enum: ['x-', 'x+', 'y-', 'y+'], description: 'closure: the wall of the lid\'s inner room with the catch and the notch.' },
wall: { type: 'number', minimum: 1.6, maximum: 10, description: 'closure: the lid\'s wall thickness in mm; default 2.' },
wall: { type: 'number', minimum: 1.6, maximum: 10, description: 'closure: the lid\'s wall thickness in mm; default 2. snap: the thickness of the wall the window goes through; default 2.' },
radius: { type: 'number', minimum: 0, maximum: 100, description: 'closure: the radius of the inner room\'s corners in mm; default 0 (sharp).' },
size: { type: 'string', enum: [...BLOCK_SIZES], description: 'screw: the screw; default M3.' },
head: { type: 'string', enum: ['cap', 'countersunk', 'none'], description: 'screw: socket head sunk in a counterbore (default), countersunk head, or a head lying on the face.' },
thread: { type: 'string', enum: ['insert', 'tapped'], description: 'screw: a heat-set insert (default, M2 to M6) or a thread modelled into the plastic (M5 and larger).' },
more: { type: 'array', items: vector, maxItems: 40, description: 'screw: where further screws like this one go in; they run the same way through the same thickness.' },
more: { type: 'array', items: vector, maxItems: 40, description: 'screw: where further screws like this one go in; they run the same way through the same thickness. snap: the feet of further hooks catching the same way.' },
screws: { type: 'boolean', description: 'screw: put the screws in as standard parts; default true.' },
hinge_from: { ...vector, description: 'closure: one end of the hinge line the lid closes about, when it is built open.' },
hinge_to: { ...vector, description: 'closure: the other end of that line; it runs along X or along Y.' },
Expand Down Expand Up @@ -164,6 +165,19 @@ export function lipOf(args: Record<string, unknown>): LipSpec | string {
return lipProblem(spec) ?? spec;
}

/** The snap hooks the model asked for, with PartBuddy's defaults; or what is wrong with the request. */
export function snapOf(args: Record<string, unknown>): SnapSpec | string {
const base = common(args, 0.4, 'Snap hook');
if (typeof base === 'string') return base;
const up = args.up === undefined ? ([0, 0, 1] as Vec3) : vec3(args.up);
if (!up) return 'up must be three numbers';
const more = args.more === undefined ? [] : Array.isArray(args.more) ? args.more.map(vec3) : null;
if (!more || more.some((point) => point === null)) return 'more must be a list of points, each three numbers';
const toward = base.to.map((value, i) => value - (base.from[i] ?? 0)) as Vec3;
const spec: SnapSpec = { fixed: base.fixed, moving: base.moving, from: base.from, up, toward, more: more as Vec3[], wall: typeof args.wall === 'number' ? args.wall : 2, name: base.name };
return snapProblem(spec) ?? spec;
}

/** The screw lid the model asked for, with PartBuddy's defaults; or what is wrong with the request. */
export function lidOf(args: Record<string, unknown>, nozzle: number): LidSpec | string {
const stays = typeof args.fixed === 'string' ? args.fixed.trim() : '';
Expand Down Expand Up @@ -251,8 +265,10 @@ export function activated(steps: Step[], listing: unknown): Step[] {
const out: Step[] = [];
let active = 0;
for (const [i, step] of steps.entries()) {
const target = steps.slice(i).find((later) => Array.isArray(later.params.targets))?.params.targets as unknown[] | undefined;
const component = typeof target?.[0] === 'string' ? componentOf(listing, target[0]) : 0;
// A plastic feature names its body in `body`; every other step in `targets`.
const owner = (later: Step): unknown => (Array.isArray(later.params.targets) ? (later.params.targets as unknown[])[0] : later.command.startsWith('plastic.') ? later.params.body : undefined);
const target = steps.slice(i).map(owner).find((name) => name !== undefined);
const component = typeof target === 'string' ? componentOf(listing, target) : 0;
if (component !== active) out.push({ command: 'component.activate', params: { component } });
active = component;
out.push(step);
Expand Down Expand Up @@ -421,7 +437,18 @@ async function buildMechanism(upstream: UpstreamTools, args: Record<string, unkn
`${spec.thread === 'insert' ? 'Leave at least 2 mm of plastic around each insert (a boss where the wall is thinner).' : ''}${warnings}`
);
}
throw new Error('kind must be hinge, slide, closure, screw or lid');
if (args.kind === 'snap') {
const spec = snapOf(args);
if (typeof spec === 'string') throw new Error(spec);
const warnings = await build(upstream, run, spec, snapSteps(spec), `Give from as a point on a face of ${spec.fixed} at the inner face of the wall of ${spec.moving}, up as that face's normal and to as a point further into that wall.`, callId);
const count = spec.more.length + 1;
return (
`Built ${String(count)} snap hook${count > 1 ? 's' : ''} "${spec.name}" on ${spec.fixed}, each with its window through the ${String(spec.wall)} mm wall of ${spec.moving}: an arm ${String(SNAP.length)} mm long, ${String(SNAP.thickness)} mm thick and ${String(SNAP.width)} mm wide, ${String(SNAP.gap)} mm off the wall, with a catch of ${String(SNAP.catch)} mm that lies over the window's edge. ` +
`Pushed together the hooks snap in by themselves (about 1.4 % strain at the root); to open, press each catch back through its window. It makes no joint. ` +
`An arm printed standing breaks along its layers at about half the strain: where the part allows, lay ${spec.fixed} so that the arms lie flat, and use PETG or ABS rather than PLA for a part opened often. Put hooks on opposite walls so that they hold each other in their windows.${warnings}`
);
}
throw new Error('kind must be hinge, slide, closure, screw, lid or snap');
}

/** The upstream tools plus `partbuddy_mechanism`; `nozzle` is the printer's nozzle in use, for the default gap. */
Expand All @@ -441,4 +468,4 @@ export function withMechanismTool(upstream: UpstreamTools, nozzle: () => number,
}

/** Added to the role prompt of every route, after the joint guidance. */
export const MECHANISM_GUIDANCE = `For a hinge (a lid that folds, a flap, a clamp that opens) or a guide (a drawer, a slider, a carriage on a rail) call ${MECHANISM_TOOL} once both bodies exist instead of modelling knuckles, a pin or a rail yourself: it builds the print-in-place geometry with the right gaps, joins the bodies and sets the limits. For a lid that should hold shut by itself (a box, a case, a container) call it with kind closure instead of modelling a latch, a hook or a clip: a lip with a snap bead inside the lid, nothing standing out and no wall cut through. For parts that are screwed together (a lid on a housing, a bracket on a base, a cover) call it with kind screw instead of drilling the holes yourself: it sizes the clearance hole, the room for the head and the seat of the heat-set insert, and says which screws to buy. For a round container that should screw shut (a jar, a canister, a bottle cap) build the container with a plain cylindrical neck and call it with kind lid: it threads the neck and makes the lid, instead of modelling a thread yourself.`;
export const MECHANISM_GUIDANCE = `For a hinge (a lid that folds, a flap, a clamp that opens) or a guide (a drawer, a slider, a carriage on a rail) call ${MECHANISM_TOOL} once both bodies exist instead of modelling knuckles, a pin or a rail yourself: it builds the print-in-place geometry with the right gaps, joins the bodies and sets the limits. For a lid that should hold shut by itself (a box, a case, a container) call it with kind closure instead of modelling a latch, a hook or a clip: a lip with a snap bead inside the lid, nothing standing out and no wall cut through. For parts that are screwed together (a lid on a housing, a bracket on a base, a cover) call it with kind screw instead of drilling the holes yourself: it sizes the clearance hole, the room for the head and the seat of the heat-set insert, and says which screws to buy. For a round container that should screw shut (a jar, a canister, a bottle cap) build the container with a plain cylindrical neck and call it with kind lid: it threads the neck and makes the lid, instead of modelling a thread yourself. For a cover or a housing half that should clip on and come off again by pressing on catches (an electronics housing, a battery cover) call it with kind snap: hooks on one part, their windows through the other part's wall.`;
Loading