From 6c17eb43e58af73f68212fc59e6ea82d158aa7e1 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:25:36 +0100 Subject: [PATCH] feat(desktop): the assistant clips two parts together with snap hooks and their windows partbuddy_mechanism gets the kind snap: cantilever hooks on one body, each catching in a window cut through the other body's wall, with the clearances of the standard. A plastic feature is now built with its own body's component active. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/e2e/solidcraft.contract.spec.ts | 37 +++++++++ apps/desktop/src/main/agents/mechanismTool.ts | 49 +++++++++--- apps/desktop/src/shared/mechanisms.ts | 79 +++++++++++++++++++ apps/desktop/src/shared/stepPhrases.ts | 4 + apps/desktop/test/fakeClaude.mjs | 12 +++ apps/desktop/test/mechanism.test.ts | 40 +++++++++- docs/feature-catalog.md | 1 + 7 files changed, 209 insertions(+), 13 deletions(-) diff --git a/apps/desktop/e2e/solidcraft.contract.spec.ts b/apps/desktop/e2e/solidcraft.contract.spec.ts index 623b0097..684e6728 100644 --- a/apps/desktop/e2e/solidcraft.contract.spec.ts +++ b/apps/desktop/e2e/solidcraft.contract.spec.ts @@ -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'); diff --git a/apps/desktop/src/main/agents/mechanismTool.ts b/apps/desktop/src/main/agents/mechanismTool.ts index 008b4523..5ce4b9d7 100644 --- a/apps/desktop/src/main/agents/mechanismTool.ts +++ b/apps/desktop/src/main/agents/mechanismTool.ts @@ -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'; @@ -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.' }, @@ -164,6 +165,19 @@ export function lipOf(args: Record): 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): 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, nozzle: number): LidSpec | string { const stays = typeof args.fixed === 'string' ? args.fixed.trim() : ''; @@ -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); @@ -421,7 +437,18 @@ async function buildMechanism(upstream: UpstreamTools, args: Record 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. */ @@ -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.`; diff --git a/apps/desktop/src/shared/mechanisms.ts b/apps/desktop/src/shared/mechanisms.ts index ea5e613a..04f8f680 100644 --- a/apps/desktop/src/shared/mechanisms.ts +++ b/apps/desktop/src/shared/mechanisms.ts @@ -538,3 +538,82 @@ export function lidSteps(spec: LidSpec): Step[] { { command: 'body.print_face', params: { body: spec.moving, point: [cx, cy, round(rim + LID.top)] } }, ]; } + +export interface SnapSpec { + /** The body the hook stands on. */ + fixed: string; + /** The body whose wall the hook catches in; it gets the window. */ + moving: string; + /** The foot of the hook: on the face of the fixed body, at the inner face of the other body's wall. */ + from: Vec3; + /** The way the hook stands out of that face, which is the way the two parts come apart. */ + up: Vec3; + /** The way the catch points: from the foot into the wall; what of it runs along `up` is dropped. */ + toward: Vec3; + /** Further hooks catching the same way: the foot of each. */ + more: Vec3[]; + /** The wall's thickness in mm: the window goes that deep, so through it. */ + wall: number; + name: string; +} + +/** + * One cantilever hook, in mm: an arm 12 long, 1.5 thick and 5 wide (Hubs: no narrower) with a catch of 0.8 on its last + * 1.5, which bends the root by 1.4 % as it snaps in (Bayer's and BASF's 0.67 · strain · length² / thickness, turned + * round). The arm stands `gap` off the wall, the window is `side` wider on each side, its edge lies `seat` under the + * catch and it leaves `air` over the tip. Clearances are those of guides for printed parts, which run from 0.1 to 0.5. + */ +export const SNAP = { length: 12, thickness: 1.5, width: 5, catch: 0.8, catchLength: 1.5, gap: 0.2, side: 0.3, seat: 0.2, air: 0.5 }; + +/** What is wrong with a snap hook before anything is built; null when it can be built. */ +export function snapProblem(spec: SnapSpec): string | null { + if (spec.fixed === spec.moving) return 'fixed and moving must be two bodies'; + if (Math.hypot(...spec.up) < 1e-9) return 'up must not be zero'; + const up = spec.up.map((value) => value / Math.hypot(...spec.up)) as Vec3; + if (Math.hypot(...cross(up, spec.toward)) < 1e-6) return 'to must lie beside from, in the wall the catch goes into: not straight along up'; + if (!(spec.wall >= 1.6 && spec.wall <= 10)) return 'wall must be 1.6–10 mm'; + if (spec.more.length > 20) return 'at most 20 further hooks'; + return null; +} + +/** + * A snap hook on the fixed body and its window in the wall of the other: the catch's flat underside lies over the + * window's edge and holds against pulling the parts apart; pressed back through the window it lets go. The window is + * cut first and the hook built last, each with its own body's component active. + */ +export function snapSteps(spec: SnapSpec): Step[] { + const length = Math.hypot(...spec.up); + const up = spec.up.map((value) => value / length) as Vec3; + const along = spec.toward.reduce((sum, value, i) => sum + value * (up[i] ?? 0), 0); + const flat = spec.toward.map((value, i) => value - along * (up[i] ?? 0)) as Vec3; + const toward = flat.map((value) => value / Math.hypot(...flat)) as Vec3; + const tidy = (vector: Vec3) => vector.map((value) => round(value) + 0) as Vec3; + const mm = (value: number) => `${String(round(value))} mm`; + const feet = [spec.from, ...spec.more]; + const half = round(SNAP.width / 2 + SNAP.side); + const high = round(SNAP.catchLength + SNAP.seat + SNAP.air); + const windows = feet.flatMap((foot, i): Step[] => { + const name = `${spec.name} ${String(i + 1)} □`; + return [ + // Sketch x across the hook, y up it: x × y points into the wall, so a positive distance cuts. + { command: 'sketch.create', params: { plane: { origin: foot.map((value, k) => round(value + (up[k] ?? 0) * (SNAP.length - SNAP.catchLength - SNAP.seat))), x_dir: tidy(cross(up, toward)), y_dir: tidy(up) }, project_edges: false, name } }, + { command: 'sketch.line', params: { points: [[-half, 0], [half, 0], [half, high], [-half, high]], closed: true } }, + { command: 'solid.extrude', params: { distance: mm(spec.wall), operation: 'cut', targets: [spec.moving], name } }, + ]; + }); + const hooks = feet.map((foot): Step => ({ + command: 'plastic.snap_fit', + params: { + position: foot.map((value, k) => round(value - (toward[k] ?? 0) * SNAP.gap)), + direction: tidy(up), + hook: tidy(toward), + length: mm(SNAP.length), + thickness: mm(SNAP.thickness), + width: mm(SNAP.width), + catch_depth: mm(SNAP.catch), + catch_length: mm(SNAP.catchLength), + body: spec.fixed, + }, + })); + return [...windows, ...hooks]; +} diff --git a/apps/desktop/src/shared/stepPhrases.ts b/apps/desktop/src/shared/stepPhrases.ts index bf582aa3..3d121a62 100644 --- a/apps/desktop/src/shared/stepPhrases.ts +++ b/apps/desktop/src/shared/stepPhrases.ts @@ -78,6 +78,7 @@ export const STEP_PHRASE_KEYS = [ 'closure', 'screwJoint', 'screwLid', + 'snapHooks', 'keyhole', 'woodScrewHole', 'knob', @@ -310,6 +311,7 @@ export function describeToolCall(tool: string, args: Params): StepPhrase[] { if (args.block === 'knob') return [{ key: 'knob', values: { name: text(args.body, 'Knob').slice(0, 60) } }]; return [{ key: 'blockByAssistant', values: { block: text(args.block, 'block').slice(0, 20), body: text(args.body, '').slice(0, 60) } }]; } + if (tool === 'partbuddy_mechanism' && args.kind === 'snap') return [{ key: 'snapHooks', values: { name: text(args.name, 'Snap hook').slice(0, 60) } }]; if (tool === 'partbuddy_mechanism' && args.kind === 'lid') return [{ key: 'screwLid', values: { name: text(args.moving, 'Lid').slice(0, 60) } }]; if (tool === 'partbuddy_mechanism' && args.kind === 'screw') return [{ key: 'screwJoint', values: { name: text(args.name, 'Screw').slice(0, 60) } }]; if (tool === 'partbuddy_mechanism' && args.kind === 'closure') return [{ key: 'closure', values: { name: text(args.name, 'Closure').slice(0, 60) } }]; @@ -457,6 +459,7 @@ export const stepMessages: Record<'en' | 'de', Record> = closure: 'Snap closure “{name}” between lid and box', screwJoint: 'Screw joint “{name}” between two parts', screwLid: 'Screw lid “{name}” with its thread on the container', + snapHooks: 'Snap hooks “{name}” with their windows', keyhole: 'Keyhole slot for a {screw} mm screw, to hang on a wall', woodScrewHole: 'Countersunk hole for a {screw} mm wood screw', knob: 'Knob “{name}” for a shaft', @@ -527,6 +530,7 @@ export const stepMessages: Record<'en' | 'de', Record> = closure: 'Rastverschluss „{name}“ zwischen Deckel und Dose', screwJoint: 'Schraubverbindung „{name}“ zwischen zwei Teilen', screwLid: 'Schraubdeckel „{name}“ mit Gewinde am Behälter', + snapHooks: 'Schnapphaken „{name}“ mit ihren Fenstern', keyhole: 'Schlüsselloch für eine {screw}-mm-Schraube, zum Aufhängen', woodScrewHole: 'Senkbohrung für eine {screw}-mm-Holzschraube', knob: 'Drehknopf „{name}“ für eine Welle', diff --git a/apps/desktop/test/fakeClaude.mjs b/apps/desktop/test/fakeClaude.mjs index 0473dfe1..ddbc0f23 100644 --- a/apps/desktop/test/fakeClaude.mjs +++ b/apps/desktop/test/fakeClaude.mjs @@ -341,6 +341,18 @@ if (args[0] === '--version') { out({ type: 'result', subtype: 'success', is_error: false, duration_ms: 12 }); continue; } + // "a clipped cover": a base and a hollow cover over it, then PartBuddy's own snap hooks into two opposite walls. + if (content.includes('a clipped cover')) { + await client.callTool({ name: 'execute', arguments: { command: 'solid.box', params: { length: 40, width: 30, height: 4, operation: 'new', body_name: 'Base' } } }); + await client.callTool({ name: 'execute', arguments: { command: 'solid.box', params: { length: 40, width: 30, height: 20, corner: [0, 0, 4], operation: 'new', body_name: 'Cover' } } }); + await client.callTool({ name: 'execute', arguments: { command: 'solid.box', params: { length: 36, width: 26, height: 18, corner: [2, 2, 4], operation: 'cut', targets: ['Cover'] } } }); + const back = await client.callTool({ name: 'partbuddy_mechanism', arguments: { kind: 'snap', fixed: 'Base', moving: 'Cover', from: [20, 28, 4], to: [20, 30, 4], name: 'Back clip' } }); + const front = await client.callTool({ name: 'partbuddy_mechanism', arguments: { kind: 'snap', fixed: 'Base', moving: 'Cover', from: [20, 2, 4], to: [20, 0, 4], name: 'Front clip' } }); + await client.close(); + out({ type: 'assistant', message: { content: [{ type: 'text', text: [back, front].flatMap((built) => (built.content ?? []).map((block) => block.text ?? '')).join(' ') }] } }); + out({ type: 'result', subtype: 'success', is_error: false, duration_ms: 12 }); + continue; + } // "a jar with a screw lid": a hollow round jar, then PartBuddy's own screw lid on its neck. if (content.includes('a jar with a screw lid')) { await client.callTool({ name: 'execute', arguments: { command: 'solid.cylinder', params: { diameter: 60, height: 50, operation: 'new', body_name: 'Jar' } } }); diff --git a/apps/desktop/test/mechanism.test.ts b/apps/desktop/test/mechanism.test.ts index abb2ed49..aa03bc13 100644 --- a/apps/desktop/test/mechanism.test.ts +++ b/apps/desktop/test/mechanism.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; import type { UpstreamTools } from '../src/main/agents/gateProxy'; -import { activated, hingeOf, lidOf, lipOf, occurrenceOf, railTravel, screwOf, slideOf, withMechanismTool } from '../src/main/agents/mechanismTool'; +import { activated, hingeOf, lidOf, lipOf, occurrenceOf, railTravel, screwOf, slideOf, snapOf, withMechanismTool } from '../src/main/agents/mechanismTool'; import { decide } from '../src/main/agents/policy'; import { describeToolCall } from '../src/shared/stepPhrases'; -import { hingeProblem, hingeSteps, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, screwLength, screwProblem, screwSteps, slideProblem, slideSteps, type HingeSpec, type LidSpec, type LipSpec, type ScrewSpec, type SlideSpec } from '../src/shared/mechanisms'; +import { hingeProblem, hingeSteps, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, screwLength, screwProblem, screwSteps, slideProblem, slideSteps, snapProblem, snapSteps, SNAP, type HingeSpec, type LidSpec, type LipSpec, type ScrewSpec, type SlideSpec } from '../src/shared/mechanisms'; const spec: HingeSpec = { fixed: 'Base', moving: 'Flap', from: [42, 0, 3], to: [42, 30, 3], diameter: 6, gap: 0.4, knuckles: 3, name: 'Hinge' }; @@ -475,3 +475,39 @@ describe('the screw lid (standards)', () => { expect(taken).toMatchObject({ isError: true, content: [{ text: 'Not built: a body "Lid" exists already; give the lid another name, or delete that body first' }] }); }); }); + +describe('snap hooks with their windows (standards)', () => { + // A base with a cover over it: the cover's wall stands from y = 20 to y = 22, the hooks stand on the base's top at z = 4. + const args = { kind: 'snap', fixed: 'Base', moving: 'Cover', from: [20, 20, 4], to: [20, 22, 4], more: [[32, 20, 4]] }; + + it('cuts each window through the wall first and stands each hook a gap off it, the catch over the window\'s edge', () => { + const spec = snapOf(args); + if (typeof spec === 'string') throw new Error(spec); + expect(spec).toMatchObject({ up: [0, 0, 1], toward: [0, 2, 0], wall: 2, name: 'Snap hook' }); + const steps = snapSteps(spec); + expect(steps.map((step) => step.command)).toEqual(['sketch.create', 'sketch.line', 'solid.extrude', 'sketch.create', 'sketch.line', 'solid.extrude', 'plastic.snap_fit', 'plastic.snap_fit']); + // The window's lower edge lies 0.2 mm under the catch (12 − 1.5 above the face); x × y points into the wall. + expect(steps[0]?.params.plane).toEqual({ origin: [20, 20, 14.3], x_dir: [-1, 0, 0], y_dir: [0, 0, 1] }); + expect(steps[1]?.params).toEqual({ points: [[-2.8, 0], [2.8, 0], [2.8, 2.2], [-2.8, 2.2]], closed: true }); + expect(steps[2]?.params).toMatchObject({ distance: '2 mm', operation: 'cut', targets: ['Cover'], name: 'Snap hook 1 □' }); + expect(steps[3]?.params.plane).toMatchObject({ origin: [32, 20, 14.3] }); + expect(steps[6]?.params).toEqual({ position: [20, 19.8, 4], direction: [0, 0, 1], hook: [0, 1, 0], length: '12 mm', thickness: '1.5 mm', width: '5 mm', catch_depth: '0.8 mm', catch_length: '1.5 mm', body: 'Base' }); + expect(steps[7]?.params).toMatchObject({ position: [32, 19.8, 4] }); + expect(SNAP).toEqual({ length: 12, thickness: 1.5, width: 5, catch: 0.8, catchLength: 1.5, gap: 0.2, side: 0.3, seat: 0.2, air: 0.5 }); + }); + + it('refuses what cannot catch, and builds the hook with its own body\'s component active', () => { + expect(snapOf({ ...args, to: [20, 20, 9] })).toContain('to must lie beside from'); + expect(snapOf({ ...args, wall: 1 })).toBe('wall must be 1.6–10 mm'); + expect(snapOf({ ...args, moving: 'Base' })).toBe('fixed and moving must be two bodies'); + const spec = snapOf(args); + if (typeof spec === 'string') throw new Error(spec); + expect(snapProblem(spec)).toBeNull(); + // A to that also runs up the hook only names the wall's side. + expect(snapSteps({ ...spec, toward: [0, 1, 5] })[6]?.params).toMatchObject({ hook: [0, 1, 0] }); + const listing = { components: [{ id: 0, parent: null, bodies: [] }, { id: 3, parent: 0, bodies: ['Base'], occurrences: [{ name: 'Base:1' }] }, { id: 4, parent: 0, bodies: ['Cover'], occurrences: [{ name: 'Cover:1' }] }] }; + expect(activated(snapSteps({ ...spec, more: [] }), listing).map((step) => (step.command === 'component.activate' ? `activate ${String(step.params.component)}` : step.command))).toEqual(['activate 4', 'sketch.create', 'sketch.line', 'solid.extrude', 'activate 3', 'plastic.snap_fit', 'activate 0']); + expect(decide('partbuddy_mechanism', { kind: 'snap' })).toEqual({ allow: true }); + expect(describeToolCall('partbuddy_mechanism', { kind: 'snap', name: 'Cover clips' })).toEqual([{ key: 'snapHooks', values: { name: 'Cover clips' } }]); + }); +}); diff --git a/docs/feature-catalog.md b/docs/feature-catalog.md index 6b556ee5..d6f42798 100644 --- a/docs/feature-catalog.md +++ b/docs/feature-catalog.md @@ -58,6 +58,7 @@ Work runs in three phases, one pull request per step, in this order. | Step | Content | | --- | --- | | F1 Function blocks (done) | Inserted at a picked face, built on SolidCraft's commands: modelled threads and tapped holes (`solid.thread`, `solid.hole`), countersunk holes, snap fits, screw bosses and lids with lip or groove (`plastic.snap_fit`, `plastic.boss`, `plastic.lip`), text and logos embossed or engraved (`sketch.text`, `sketch.insert_svg`, `solid.emboss`), plus heat-set insert seats, nut traps and slots where SolidCraft has no command. | +| G3e Snap hooks with their windows (done) | Standards, 2026-11-11. `partbuddy_mechanism {kind: snap}` stands cantilever hooks on one body (`plastic.snap_fit`: arm 12 × 1.5 × 5 mm, catch 0.8 mm on its last 1.5 mm, 1.4 % strain at the root by the Bayer/BASF formula) and cuts each one's window through the other body's wall: 0.2 mm between arm and wall, 0.3 mm beside the arm, the edge 0.2 mm under the catch, 0.5 mm of air over the tip (guides for printed parts run from 0.1 to 0.5 mm). The catch's underside is square, so it opens only by pressing the catch back through its window. `from` is the hook's foot at the wall's inner face, `up` the way it stands, `to` a point in the wall, `more` further feet; no joint. Not printed yet. Not built: a lead-in chamfer on the wall's edge, tapered arms, other sizes of hook, a strain limit by material (no primary figures for PETG and ASA). | | F1c Knob for a shaft (done) | Standards, 2026-11-11. `partbuddy_block {block: knob, body, point}` makes a knob as a part of its own (`knob.ts`): 25 × 16 mm with flutes every 7 mm, a chamfered plain band at the top and a groove as the pointer; a bore 11 mm deep for a 6 mm shaft with 0.2 mm of room, and by `shaft` the flat of a D shaft over its last 7 mm (4.5 mm on 6 mm, Bourns PEC11R and Alps RK09; any other shaft needs `flat` as measured, the sources disagree on 1/4"), a plain bore for a split knurled shaft, or a 2.6 mm pilot hole for an M3 set screw on a round one. Flat and set screw lie on the side away from the pointer (own choice). It prints on its top without support and sets its print face. Nothing of it is printed yet; `clearance` is the lever. Not built: star and clamping knobs (DIN 6336), modelled knurl teeth, a nut for the set screw. | | F1b Function blocks for the assistant (done) | Decided 2026-11-11 with the standards the product owner asked for: the assistant takes sizes from tables, not from memory. `partbuddy_block {block, body, point}` builds the blocks of F1 with the same standard values the form starts from (clearance by ISO 273, heads by ISO 4762 and ISO 10642, nuts by ISO 4032, common heat-set inserts): screw hole, tapped hole, insert seat, nut trap, boss, slot, text, lip, snap fit; the model gives only what differs. Two blocks for hanging a part on a wall come with it (`wallMount.ts`; not in the form yet): `keyhole`, a keyhole slot on the face against the wall for a 3.5, 4, 4.5 or 5 mm wood screw (entry for the head, a slot of the thread's width running 8 mm up through a 2 mm lip, a pocket for the head behind it; printed with that face on the bed the pocket's roof is a bridge), and `woodScrew`, a countersunk hole after DIN 74 with 0.2 mm more for print. The lip's 2 mm are PartBuddy's own choice: no source gives a rule. | | F2 Clearance between parts (done) | For prints of several parts, the indicator checks clearance and overlap between bodies with SolidCraft's exact measure (`inspect.clearance`): bodies that share volume, and gaps narrower than the material's push fit that are no contact, both as warnings that point at the place. Standard parts and mesh bodies are not measured; touching bodies stay the mesh check's "separate" finding. |