From 91d4bcb92893f6237cd8baba008894c7f6d02710 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:13:47 +0100 Subject: [PATCH] feat(desktop): the assistant holds a lid down with a snap latch outside the box partbuddy_mechanism gets the kind latch: an arm that grows out of the lid's outer wall, hangs over the joint and snaps with its hook under a bead on the box. No hinge: its play is larger than any catch. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/e2e/solidcraft.contract.spec.ts | 37 +++++++ apps/desktop/src/main/agents/mechanismTool.ts | 38 +++++-- apps/desktop/src/shared/mechanisms.ts | 99 +++++++++++++++++++ apps/desktop/src/shared/stepPhrases.ts | 4 + apps/desktop/test/fakeClaude.mjs | 11 +++ apps/desktop/test/mechanism.test.ts | 49 ++++++++- docs/feature-catalog.md | 1 + 7 files changed, 229 insertions(+), 10 deletions(-) diff --git a/apps/desktop/e2e/solidcraft.contract.spec.ts b/apps/desktop/e2e/solidcraft.contract.spec.ts index 7db4296..18fa302 100644 --- a/apps/desktop/e2e/solidcraft.contract.spec.ts +++ b/apps/desktop/e2e/solidcraft.contract.spec.ts @@ -3395,6 +3395,43 @@ test('the assistant makes a knob for a D shaft with one call: a part of its own } }); +test('the assistant latches a lid onto its box: the arm grows out of the lid and its hook lies under the bead, no shared 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-latch-')); + const env = { + SOLIDCRAFT_CONTROL_DIR: join(work, 'control'), + PARTBUDDY_SOLIDCRAFT_PORT: '7960', + 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 latched box'); + await page.getByRole('button', { name: 'Send' }).click(); + await expect(page.getByText(/Built the latch "Front latch": an arm 10 mm wide and 1\.6 mm thick grows out of the wall of Lid \(the lid\), hangs 1\.5 mm off the wall over the joint and snaps with its hook under a bead 1\.2 mm high on Box/)).toBeVisible({ timeout: 90_000 }); + await expect(page.getByText(/Warning:/)).toHaveCount(0); + await expect(page.getByText(/stand apart/)).toHaveCount(0); + await expect(page.getByTestId('mesh-summary')).toContainText('2 bodies', { timeout: 30_000 }); + await expect(page.getByText('Snap latch “Front latch” outside, between lid and box')).toHaveCount(1); + await expect(page.locator('[data-testid="timeline-feature"][data-state="error"]')).toHaveCount(0); + 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('latch-built.png') }); + } finally { + await launched.close(); + rmSync(work, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 }); + } +}); + test('the assistant plugs the halves of a split block together: pegs in their pockets, no shared volume', 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 a87d1af..e427275 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, snapProblem, snapSteps, pegLength, pegProblem, pegSteps, PEG, SNAP, type HingeSpec, type LidSpec, type LipSpec, type PegSpec, type ScrewSpec, type SlideSpec, type SnapSpec } from '../../shared/mechanisms'; +import { LID, LIP, hingeProblem, hingeSteps, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, pinDiameter, screwLength, screwProblem, screwSteps, slideProblem, slideSteps, snapProblem, snapSteps, latchProblem, latchReach, latchSteps, LATCH, pegLength, pegProblem, pegSteps, PEG, SNAP, type HingeSpec, type LatchSpec, type LidSpec, type LipSpec, type PegSpec, 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'; @@ -30,18 +30,19 @@ const mechanismTool: UpstreamTool = { '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. ' + 'plug: a plugged connection that makes no joint, to align and glue the halves of a split part or to push one part onto another. With shape peg (the default): round pegs on the fixed body and their pockets in the moving one; give from as the foot of the peg on the face the two meet at, to as a point further into the moving body, more for further pegs (two or more, far apart, keep the parts from turning), diameter (default 5) and gap per side (default 0.2 to slide and glue, 0.1 to press). ' + 'With shape dovetail: a dovetail rail on the fixed body and its groove in the moving one, given as for slide (from, to, up, width, height) but a close fit and without a joint; the moving body is pushed on from the end at from and stops at to where the groove ends inside it. ' + + 'latch: a snap latch on the outside of a box, where the outer walls of lid and box lie in one plane: an arm grows out of the lid\'s wall, hangs down over the joint and snaps with a hook under a bead on the box; pulled at its lower end it lets go. Give fixed as the box and moving as the lid, from as the middle of the latch on the line where the two meet on that wall, to as a point straight out of the wall, up as the way from box to lid, and width; the walls must be flat 8.1 mm above the joint and 14.1 mm below it. Opposite the lid\'s hinge, or one on each of two opposite walls. 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', 'snap', 'plug'], description: 'plug: two parts are plugged together, by pegs in pockets or pushed onto a dovetail; 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.' }, + kind: { type: 'string', enum: ['hinge', 'slide', 'closure', 'screw', 'lid', 'snap', 'plug', 'latch'], description: 'latch: a snap latch outside a box holds its lid down; plug: two parts are plugged together, by pegs in pockets or pushed onto a dovetail; 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. 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.' }, + 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. latch: the middle of the latch on the line where lid and box meet, on their outer wall.' }, + 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. latch: a point straight out of the wall.' }, + 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. latch: the way from the box to the lid; default +Z.' }, shape: { type: 'string', enum: ['peg', 'dovetail'], description: 'plug: pegs in pockets, or a dovetail to push together; default peg.' }, 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. snap: the thickness of the wall the window goes through; default 2.' }, @@ -56,7 +57,7 @@ const mechanismTool: UpstreamTool = { diameter: { type: 'number', minimum: 3, maximum: 200, description: 'plug: the diameter of the pegs (3 to 20); default 5. hinge: outer diameter of the barrel in mm (4 to 40); default 6. The pin is half of it. lid: the outer diameter of the neck (16 to 200).' }, starts: { type: 'integer', enum: [1, 2, 3], description: 'lid: threads side by side; default 1. With 2 or 3 the lid closes in less than a turn.' }, knuckles: { type: 'integer', enum: [3, 5, 7, 9], description: 'hinge: how many knuckles; default by the length of the line.' }, - width: { type: 'number', minimum: 2, maximum: 60, description: 'slide: width of the rail at its foot in mm; default 6. It widens by its height on each side.' }, + width: { type: 'number', minimum: 2, maximum: 60, description: 'slide: width of the rail at its foot in mm; default 6. It widens by its height on each side. latch: its width along the wall (5 to 40); default 10.' }, height: { type: 'number', minimum: 1.5, maximum: 20, description: 'slide: height of the rail in mm; default 3.' }, gap: { type: 'number', minimum: 0.05, maximum: 1, description: "The gap between the parts in mm; default the printer's nozzle, at least 0.3 (hinge, slide and lid: 0.2 at the least). plug: the room per side, default 0.2." }, name: { type: 'string', maxLength: 60, description: "The mechanism's name in the user's language, e.g. \"Lid hinge\"." }, @@ -178,6 +179,16 @@ export function pegOf(args: Record): PegSpec | string { return pegProblem(spec) ?? spec; } +/** The latch the model asked for, with PartBuddy's defaults; or what is wrong with the request. */ +export function latchOf(args: Record): LatchSpec | string { + const base = common(args, 0.4, 'Latch'); + 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 spec: LatchSpec = { fixed: base.fixed, moving: base.moving, from: base.from, up, out: base.to.map((value, i) => value - (base.from[i] ?? 0)) as Vec3, width: typeof args.width === 'number' ? args.width : 10, name: base.name }; + return latchProblem(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'); @@ -461,6 +472,17 @@ async function buildMechanism(upstream: UpstreamTools, args: Record 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. 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. For a part split in two to fit the printer, or two parts that are plugged or glued together, call it with kind plug: pegs with their pockets (two or more), or shape dovetail to push one part onto the other.`; +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. For a lid that should be held down by a latch one sees and opens with a finger (a tool box, a case with a hinged lid) call it with kind latch: a snap latch outside, on the wall opposite the hinge. For a part split in two to fit the printer, or two parts that are plugged or glued together, call it with kind plug: pegs with their pockets (two or more), or shape dovetail to push one part onto the other.`; diff --git a/apps/desktop/src/shared/mechanisms.ts b/apps/desktop/src/shared/mechanisms.ts index 6b6892d..df85923 100644 --- a/apps/desktop/src/shared/mechanisms.ts +++ b/apps/desktop/src/shared/mechanisms.ts @@ -678,3 +678,102 @@ export function pegSteps(spec: PegSpec): Step[] { ]; }); } + +export interface LatchSpec { + /** The box: it gets the bead. */ + fixed: string; + /** The lid: the latch grows out of its outer wall. */ + moving: string; + /** The middle of the latch, on the line where lid and box meet, on their outer wall. */ + from: Vec3; + /** The way from the box to the lid. */ + up: Vec3; + /** The way out of the wall; what of it runs along `up` is dropped. */ + out: Vec3; + /** The latch's width along the wall in mm. */ + width: number; + name: string; +} + +/** + * A snap latch outside a box, in mm (decided 2026-11-11: no hinge, whose play is larger than any catch). An arm + * `thick` stands `off` the wall, rooted in the lid between `foot` and `head` above the joint, where a 45° slope + * carries it; `lever` below its root a hook reaches to `clear` off the wall, under a bead that stands `bead` out of + * the box, the two 45° faces `seat` apart; `tab` of arm below the hook is for the finger. Snapping over the bead bends + * the root by 1.5 · thick · (bead − clear) / lever² = 1.3 %. + */ +export const LATCH = { thick: 1.6, off: 1.5, foot: 1, head: 5, lever: 12, bead: 1.2, clear: 0.4, seat: 0.2, tab: 2, into: 0.5, beside: 1 }; + +/** How much flat wall the latch needs: on the lid above the joint, and on the box below it. */ +export function latchReach(): { above: number; below: number } { + const hook = LATCH.off - LATCH.clear; + return { above: round(LATCH.head + LATCH.off + LATCH.thick), below: round(LATCH.lever - LATCH.foot + hook + LATCH.tab) }; +} + +/** What is wrong with a latch before anything is built; null when it can be built. */ +export function latchProblem(spec: LatchSpec): 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.out)) < 1e-6) return 'to must lie straight out of the wall from from: not along up'; + if (!(spec.width >= 5 && spec.width <= 40)) return 'width must be 5–40 mm'; + return null; +} + +/** + * The latch on the lid and its bead on the box, each one profile in the plane square to the wall, extruded along the + * wall. Sketch x runs out of the wall and y up, from the joint. With the lid printed on its top the arm grows out of + * it without support, and so does the bead with the box standing. + */ +export function latchSteps(spec: LatchSpec): Step[] { + const length = Math.hypot(...spec.up); + const up = spec.up.map((value) => value / length) as Vec3; + const along = spec.out.reduce((sum, value, i) => sum + value * (up[i] ?? 0), 0); + const flat = spec.out.map((value, i) => value - along * (up[i] ?? 0)) as Vec3; + const out = flat.map((value) => value / Math.hypot(...flat)) as Vec3; + // x × y of the sketch: the profiles are extruded this way along the wall. + const run = cross(out, up); + const tidy = (vector: Vec3) => vector.map((value) => round(value) + 0) as Vec3; + const { thick, off, foot, head, lever, bead, clear, seat, tab, into, beside } = LATCH; + const hook = off - clear; + const crest = foot - lever; + const outer = off + thick; + // The bead's crest lies above the hook's so that their faces are `seat` apart, measured upright. + const ridge = crest + bead - clear + seat; + const profile = (points: [number, number][], half: number, target: string, name: string): Step[] => [ + { command: 'sketch.create', params: { plane: { origin: spec.from.map((value, i) => round(value - (run[i] ?? 0) * half)), x_dir: tidy(out), y_dir: tidy(up) }, project_edges: false, name } }, + { command: 'sketch.line', params: { points: points.map(([x, y]) => [round(x), round(y)]), closed: true } }, + { command: 'solid.extrude', params: { distance: `${String(round(2 * half))} mm`, operation: 'join', targets: [target], name } }, + ]; + return [ + ...profile( + [ + [-into, ridge + bead], + [0, ridge + bead], + [bead, ridge], + [0, ridge - bead], + [-into, ridge - bead], + ], + spec.width / 2 + beside, + spec.fixed, + `${spec.name} ◁`, + ), + ...profile( + [ + [-into, head + outer], + [0, head + outer], + [outer, head], + [outer, crest - hook - tab], + [off, crest - hook - tab], + [off, crest - hook], + [clear, crest], + [off, crest + hook], + [off, foot], + [-into, foot], + ], + spec.width / 2, + spec.moving, + spec.name, + ), + ]; +} diff --git a/apps/desktop/src/shared/stepPhrases.ts b/apps/desktop/src/shared/stepPhrases.ts index c137e27..e125664 100644 --- a/apps/desktop/src/shared/stepPhrases.ts +++ b/apps/desktop/src/shared/stepPhrases.ts @@ -80,6 +80,7 @@ export const STEP_PHRASE_KEYS = [ 'screwLid', 'snapHooks', 'plug', + 'latch', 'keyhole', 'woodScrewHole', 'knob', @@ -314,6 +315,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 === 'latch') return [{ key: 'latch', values: { name: text(args.name, 'Latch').slice(0, 60) } }]; if (tool === 'partbuddy_mechanism' && args.kind === 'plug') return [{ key: 'plug', values: { name: text(args.name, args.shape === 'dovetail' ? 'Dovetail' : 'Peg').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) } }]; @@ -465,6 +467,7 @@ export const stepMessages: Record<'en' | 'de', Record> = screwLid: 'Screw lid “{name}” with its thread on the container', snapHooks: 'Snap hooks “{name}” with their windows', plug: 'Plugged connection “{name}” between two parts', + latch: 'Snap latch “{name}” outside, between lid and box', 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', @@ -538,6 +541,7 @@ export const stepMessages: Record<'en' | 'de', Record> = screwLid: 'Schraubdeckel „{name}“ mit Gewinde am Behälter', snapHooks: 'Schnapphaken „{name}“ mit ihren Fenstern', plug: 'Steckverbindung „{name}“ zwischen zwei Teilen', + latch: 'Schnapplasche „{name}“ außen, zwischen Deckel und Dose', 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 e0772e3..24a18d0 100644 --- a/apps/desktop/test/fakeClaude.mjs +++ b/apps/desktop/test/fakeClaude.mjs @@ -342,6 +342,17 @@ if (args[0] === '--version') { out({ type: 'result', subtype: 'success', is_error: false, duration_ms: 12 }); continue; } + // "a latched box": a hollow box and its lid on it, then PartBuddy's own snap latch on the front wall. + if (content.includes('a latched box')) { + await client.callTool({ name: 'execute', arguments: { command: 'solid.box', params: { length: 40, width: 30, height: 20, operation: 'new', body_name: 'Box' } } }); + await client.callTool({ name: 'execute', arguments: { command: 'solid.box', params: { length: 36, width: 26, height: 18, corner: [2, 2, 2], operation: 'cut', targets: ['Box'] } } }); + await client.callTool({ name: 'execute', arguments: { command: 'solid.box', params: { length: 40, width: 30, height: 10, corner: [0, 0, 20], operation: 'new', body_name: 'Lid' } } }); + const built = await client.callTool({ name: 'partbuddy_mechanism', arguments: { kind: 'latch', fixed: 'Box', moving: 'Lid', from: [20, 0, 20], to: [20, -5, 20], name: 'Front latch' } }); + await client.close(); + out({ type: 'assistant', message: { content: [{ type: 'text', text: (built.content ?? []).map((block) => block.text ?? '').join(' ') }] } }); + out({ type: 'result', subtype: 'success', is_error: false, duration_ms: 12 }); + continue; + } // "a split block": two halves of a block side by side, then PartBuddy's own pegs across the cut. if (content.includes('a split block')) { await client.callTool({ name: 'execute', arguments: { command: 'solid.box', params: { length: 30, width: 30, height: 20, operation: 'new', body_name: 'Left' } } }); diff --git a/apps/desktop/test/mechanism.test.ts b/apps/desktop/test/mechanism.test.ts index 7f06c43..e82277c 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, pegOf, railTravel, screwOf, slideOf, snapOf, withMechanismTool } from '../src/main/agents/mechanismTool'; +import { activated, hingeOf, latchOf, lidOf, lipOf, occurrenceOf, pegOf, 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, snapProblem, snapSteps, pegLength, pegSteps, PEG, SNAP, 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, latchReach, latchSteps, LATCH, pegLength, pegSteps, PEG, 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' }; @@ -576,3 +576,48 @@ describe('a plugged connection (standards)', () => { expect(calls.flat().filter((command) => command === 'solid.extrude')).toHaveLength(2); }); }); + +describe('a snap latch outside a box (standards)', () => { + // A box 20 mm high with its lid on it; their front walls lie in the plane y = 0, which faces −Y. + const args = { kind: 'latch', fixed: 'Box', moving: 'Lid', from: [20, 0, 20], to: [20, -5, 20] }; + + it('grows the bead out of the box and the arm with its hook out of the lid, 45° faces a seat apart', () => { + const spec = latchOf(args); + if (typeof spec === 'string') throw new Error(spec); + expect(spec).toMatchObject({ up: [0, 0, 1], out: [0, -5, 0], width: 10, name: 'Latch' }); + const steps = latchSteps(spec); + expect(steps.map((step) => [step.command, step.params.name ?? '', (step.params.targets as string[] | undefined)?.[0] ?? ''])).toEqual([ + ['sketch.create', 'Latch ◁', ''], + ['sketch.line', '', ''], + ['solid.extrude', 'Latch ◁', 'Box'], + ['sketch.create', 'Latch', ''], + ['sketch.line', '', ''], + ['solid.extrude', 'Latch', 'Lid'], + ]); + // Sketch x runs out of the wall and y up; the profiles run along the wall from one edge of the latch to the other. + expect(steps[0]?.params.plane).toEqual({ origin: [26, 0, 20], x_dir: [0, -1, 0], y_dir: [0, 0, 1] }); + expect(steps[2]?.params).toMatchObject({ distance: '12 mm', operation: 'join' }); + // The bead: a ridge 1.2 mm high with 45° faces, its crest 10 mm under the joint, rooted 0.5 mm in the wall. + expect(steps[1]?.params.points).toEqual([[-0.5, -8.8], [0, -8.8], [1.2, -10], [0, -11.2], [-0.5, -11.2]]); + expect(steps[3]?.params.plane).toMatchObject({ origin: [25, 0, 20] }); + expect(steps[5]?.params).toMatchObject({ distance: '10 mm', operation: 'join' }); + // The arm: a 45° slope out of the lid's wall, 1.6 mm thick and 1.5 mm off the wall, the hook's crest 0.4 mm off it + // and 1 mm under the bead's, then 2 mm of tab. + expect(steps[4]?.params.points).toEqual([[-0.5, 8.1], [0, 8.1], [3.1, 5], [3.1, -14.1], [1.5, -14.1], [1.5, -12.1], [0.4, -11], [1.5, -9.9], [1.5, 1], [-0.5, 1]]); + expect(latchReach()).toEqual({ above: 8.1, below: 14.1 }); + // The strain at the root as the hook passes the bead: 1.5 · thickness · overlap / lever². + expect((150 * LATCH.thick * (LATCH.bead - LATCH.clear)) / LATCH.lever ** 2).toBeCloseTo(1.33, 2); + }); + + it('refuses what is no wall, and is allowed and named in the work log', () => { + expect(latchOf({ ...args, to: [20, 0, 25] })).toContain('to must lie straight out of the wall'); + expect(latchOf({ ...args, width: 3 })).toBe('width must be 5–40 mm'); + expect(latchOf({ ...args, moving: 'Box' })).toBe('fixed and moving must be two bodies'); + // On a side wall, and with a point that also lies higher: only the way out of the wall counts. + const side = latchOf({ ...args, from: [40, 15, 20], to: [45, 15, 23] }); + if (typeof side === 'string') throw new Error(side); + expect(latchSteps(side)[0]?.params.plane).toEqual({ origin: [40, 21, 20], x_dir: [1, 0, 0], y_dir: [0, 0, 1] }); + expect(decide('partbuddy_mechanism', { kind: 'latch' })).toEqual({ allow: true }); + expect(describeToolCall('partbuddy_mechanism', { kind: 'latch', name: 'Front latch' })).toEqual([{ key: 'latch', values: { name: 'Front latch' } }]); + }); +}); diff --git a/docs/feature-catalog.md b/docs/feature-catalog.md index 5393661..bb3cfb7 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. | +| G3g Snap latch outside a box (done) | Standards, 2026-11-11; the product owner chose a latch that swings over the joint, and then, when the numbers showed a hinged one cannot catch (a print-in-place hinge has more play than any catch overlaps, and its arm starts in mid-air), this one without a hinge. `partbuddy_mechanism {kind: latch}` (`latchSteps`): an arm 1.6 mm thick grows out of the lid's outer wall on a 45° slope, hangs 1.5 mm off the wall over the joint and snaps with a hook under a bead 1.2 mm high on the box, 0.8 mm of overlap on 45° faces 0.2 mm apart, 1.3 % strain at the root over a 12 mm lever, 2 mm of tab below the hook; 10 mm wide by default. `from` on the joint line on the wall, `to` straight out of it, `up` from box to lid; no joint. It needs 8.1 mm of flat wall on the lid and 14.1 mm on the box, and prints without support with the lid on its top. Not printed yet: the holding force shows on the first print. Not built: a hinged hasp, a sliding bolt for doors. | | G3f Plugged connection (done) | Standards, 2026-11-11. `partbuddy_mechanism {kind: plug}` makes no joint. Shape `peg` (`pegSteps`): round pegs on one body and pockets in the other along `from`–`to`, 5 mm thick by default (Hubs: thinner ones printed standing are weak) and 1.5 diameters long, 0.2 mm of room per side (the slicers' cut connectors use 0 to 0.1, guides 0.05 to 0.3; `gap` is the lever, 0.1 to press), the pocket 1 mm deeper for air and glue, tip and mouth chamfered by 0.5 mm; `more` for further pegs, and a single one is answered with a warning that the parts can turn. Shape `dovetail`: the guide's rail and groove (45° flanks) with 0.2 mm all round and no joint, pushed on from `from` and stopped where the groove ends in the body. Not printed yet. Not built: separate dowels with a pocket in both parts, square or teardrop pegs for lying pockets, a tapered dovetail that clamps. | | 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). | | F1d Magnet pockets (done) | Standards, 2026-11-11. `partbuddy_block {block: magnet, body, point, normal}` cuts pockets for disc magnets (`magnet.ts`), 6 × 2 mm by default (the magnet of Gridfinity): 0.3 mm wider than the magnet to glue it in (the default; magnets are made to ±0.1 mm) or 0.1 mm to press it in (`hold`), 0.2 mm deeper so it lies under the face, the mouth chamfered by 0.4 mm; `more` for further pockets. The answer reminds of the walls (1.2 mm round, 0.6 mm under), the poles of a pair and the 80 °C magnets bear. Published room runs from none to 0.5 mm (Gridfinity's plain hole); nothing is printed yet. Not built: crush ribs, a slot to push the magnet out, magnets slid in from the side or printed over at a pause, the bridged hole top for pockets that open downwards. |