diff --git a/apps/desktop/e2e/solidcraft.contract.spec.ts b/apps/desktop/e2e/solidcraft.contract.spec.ts index ac300b8..7db4296 100644 --- a/apps/desktop/e2e/solidcraft.contract.spec.ts +++ b/apps/desktop/e2e/solidcraft.contract.spec.ts @@ -3327,7 +3327,7 @@ test('the assistant builds a dovetail guide with one call: joined as a slider th } }); -test('the assistant hangs a part on the wall with its blocks: keyhole slots on the back, countersunk holes on the front', async () => { +test('the assistant hangs a part on the wall with its blocks: keyhole slots on the back, countersunk holes and magnet pockets on the front', async () => { test.setTimeout(240_000); const { mkdtempSync, rmSync } = await import('node:fs'); const { tmpdir } = await import('node:os'); @@ -3351,6 +3351,9 @@ test('the assistant hangs a part on the wall with its blocks: keyhole slots on t // Each block is one line of the work log, and no feature failed. await expect(page.getByText('Keyhole slot for a 4 mm screw, to hang on a wall')).toHaveCount(1); await expect(page.getByText('Countersunk hole for a 4 mm wood screw')).toHaveCount(1); + await expect(page.getByText(/Built 2 pockets for 6 × 2 mm disc magnets in Plate: 6\.3 mm wide and 2\.2 mm deep/)).toBeVisible(); + await expect(page.getByText('Pocket for a 6 × 2 mm magnet')).toHaveCount(1); + await expect(page.getByText(/SolidCraft warns:/)).toHaveCount(0); await expect(page.locator('[data-testid="timeline-feature"][data-state="error"]')).toHaveCount(0); await expect(page.getByTestId('print-check')).not.toHaveAttribute('data-status', 'block', { timeout: 30_000 }); await page.screenshot({ path: test.info().outputPath('wall-plate-built.png') }); diff --git a/apps/desktop/src/main/agents/blockTool.ts b/apps/desktop/src/main/agents/blockTool.ts index 32b3bc1..25b1f6a 100644 --- a/apps/desktop/src/main/agents/blockTool.ts +++ b/apps/desktop/src/main/agents/blockTool.ts @@ -1,6 +1,7 @@ import { BLOCK_SIZES, defaultBlock, needsNormal, sizesOf, stepsOf, type BlockSize, type FeatureBlock } from '../../shared/blocks'; import { featureBlockSchema } from '../../shared/contract'; import { KNOB, KNOB_CLEARANCE, KNOB_SHAFTS, knobFlat, knobFlutes, knobInsert, knobProblem, knobSteps, type KnobShaft } from '../../shared/knob'; +import { MAGNET, MAGNET_HOLDS, magnetPocket, magnetProblem, magnetSteps, type MagnetHold } from '../../shared/magnet'; import { KEYHOLE, WALL, WOOD_SCREWS, keyholeDepth, keyholeProblem, keyholeSteps, woodScrewSteps, type WoodScrew } from '../../shared/wallMount'; import { componentOf } from '../solidcraft/actions'; import type { ToolResult, UpstreamTool, UpstreamTools } from './gateProxy'; @@ -18,7 +19,7 @@ export const BLOCK_TOOL = 'partbuddy_block'; export const ASSISTANT_BLOCKS = ['screwHole', 'tappedHole', 'insertSeat', 'nutTrap', 'boss', 'slot', 'text', 'lip', 'snapFit'] as const; type AssistantBlock = (typeof ASSISTANT_BLOCKS)[number]; /** Wall mounting (wallMount.ts): not blocks of the interface yet. */ -export const WALL_BLOCKS = ['keyhole', 'woodScrew'] as const; +export const WALL_BLOCKS = ['keyhole', 'woodScrew', 'magnet'] as const; /** A part of its own (knob.ts): body is the name of the new body. */ export const PART_BLOCKS = ['knob'] as const; @@ -33,6 +34,7 @@ const blockTool: UpstreamTool = { 'slot (a slot for a screw, to adjust), text (embossed or engraved), lip (a lip or a groove on the rim of a shelled body), snapFit (a cantilever snap hook standing on the face). ' + 'To hang a part on a wall: keyhole (a keyhole slot on the face against the wall, over a screw already in the wall; point is where the screw rests, normal points to the wall, up is the way up as the part hangs) ' + 'or woodScrew (a countersunk hole for a wood screw, at a point of the face away from the wall); screw is the wood screw in mm. ' + + 'To hold with magnets: magnet (a pocket for a disc magnet at a point of a face, normal out of that face; diameter and height are the magnet\'s, default 6 × 2 mm; hold glue to drop it in on a drop of glue, press to press it in). ' + "A knob for a shaft is a part of its own: knob (body is the new body's name, point the centre of its underside where the shaft goes in, normal the way the shaft points, up the way its pointer points; shaft d for a potentiometer's or encoder's D shaft, knurl for a split knurled one, round for a plain shaft with a set screw). " + 'Give only what differs from the standard; everything else is filled in. For two parts screwed together use partbuddy_mechanism with kind screw instead, which makes both holes on one line.', inputSchema: { @@ -41,22 +43,23 @@ const blockTool: UpstreamTool = { block: { type: 'string', enum: [...ASSISTANT_BLOCKS, ...WALL_BLOCKS, ...PART_BLOCKS] }, body: { type: 'string', description: 'The body (exact name); for knob the name of the new body.' }, point: { ...vector, description: 'A point on the flat face the block goes at, in mm: the centre of the hole, the foot of the boss, the middle of the text.' }, - normal: { ...vector, description: 'The way out of that face; needed for nutTrap, slot, text, snapFit and keyhole. knob: the way the shaft points, default +Z.' }, + normal: { ...vector, description: 'The way out of that face; needed for nutTrap, slot, text, snapFit, keyhole and magnet. knob: the way the shaft points, default +Z.' }, up: { ...vector, description: 'keyhole: the way up as the part hangs, along the face; default +Z. knob: the way its pointer points, across the shaft; default +Y.' }, screw: { type: 'number', enum: [...WOOD_SCREWS], description: 'keyhole, woodScrew: the wood screw, its thread diameter in mm; default 4.' }, size: { type: 'string', enum: [...BLOCK_SIZES], description: 'The screw; default M3. Ignored for text, lip and snapFit.' }, - more: { type: 'array', items: vector, maxItems: 99, description: 'screwHole, tappedHole, insertSeat, keyhole, woodScrew: further points on the same face, one each.' }, + more: { type: 'array', items: vector, maxItems: 99, description: 'screwHole, tappedHole, insertSeat, keyhole, woodScrew, magnet: further points on the same face, one each.' }, head: { type: 'string', enum: ['none', 'cap', 'countersunk'], description: 'screwHole: the room for the head; default cap (a counterbore).' }, fit: { type: 'string', enum: ['close', 'normal', 'loose'], description: 'screwHole: the clearance by ISO 273; default normal.' }, depth: { type: 'number', description: 'tappedHole, insertSeat, nutTrap, slot, text: depth in mm; default by the block (a slot and a tapped hole go through).' }, - diameter: { type: 'number', description: 'insertSeat: the hole for the insert; boss: its outer diameter; knob: its outer diameter, default 25. Default from the table.' }, - height: { type: 'number', description: 'boss: its height; text: the height of its capitals; lip: its height; knob: its height, default 16. Default by the block.' }, + diameter: { type: 'number', description: 'insertSeat: the hole for the insert; boss: its outer diameter; knob: its outer diameter, default 25; magnet: the diameter of the magnet, default 6. Default from the table.' }, + height: { type: 'number', description: 'boss: its height; text: the height of its capitals; lip: its height; knob: its height, default 16; magnet: the height of the magnet, default 2. Default by the block.' }, length: { type: 'number', description: 'slot: its length; snapFit: the length of the arm. Default 10 and 12 mm.' }, width: { type: 'number', description: 'slot, lip, snapFit: width in mm. Default by the block.' }, along: { type: 'string', enum: ['x', 'y'], description: "slot: along the face's first or second direction." }, text: { type: 'string', maxLength: 80, description: 'text: the words.' }, mode: { type: 'string', enum: ['emboss', 'deboss'], description: 'text: raised or engraved; default engraved.' }, type: { type: 'string', enum: ['lip', 'groove'], description: 'lip: a lip, or the groove that takes one.' }, + hold: { type: 'string', enum: [...MAGNET_HOLDS], description: 'magnet: glued in (0.3 mm of room, the default) or pressed in (0.1 mm).' }, shaft: { type: 'string', enum: [...KNOB_SHAFTS], description: 'knob: the shaft it goes on; default d.' }, shaftDiameter: { type: 'number', description: 'knob: the diameter of the shaft in mm; default 6.' }, flat: { type: 'number', description: 'knob on a D shaft: the thickness of the shaft at its flat in mm; 4.5 on a 6 mm shaft, to be measured on any other.' }, @@ -132,6 +135,34 @@ export function wallOf(args: Record): { body: string; steps: St }; } +/** Magnet pockets the model asked for: their steps and what to say; null when the block is none; or what is wrong. */ +export function magnetOf(args: Record): { body: string; steps: Step[]; text: string } | string | null { + if (args.block !== 'magnet') return null; + const body = typeof args.body === 'string' ? args.body.trim() : ''; + const point = vec3(args.point); + if (!body || !point) return 'body and point (three numbers on a flat face) are needed'; + const normal = vec3(args.normal); + if (!normal) return 'magnet needs normal: the way out of the face the pocket opens in, three numbers'; + const more = args.more === undefined ? [] : Array.isArray(args.more) ? args.more.map(vec3) : null; + if (!more || more.some((entry) => entry === null)) return 'more must be a list of points, each three numbers'; + const hold = (args.hold ?? 'glue') as MagnetHold; + if (!MAGNET_HOLDS.includes(hold)) return `hold must be one of ${MAGNET_HOLDS.join(', ')}`; + const number = (value: unknown, standard: number) => (value === undefined ? standard : typeof value === 'number' ? value : Number.NaN); + const spec = { body, point, normal, more: more as Vec3[], diameter: number(args.diameter, 6), height: number(args.height, 2), hold, name: 'Magnet' }; + const problem = magnetProblem(spec); + if (problem) return problem; + const pocket = magnetPocket(spec); + const count = more.length + 1; + return { + body, + steps: magnetSteps(spec), + text: + `Built ${String(count)} pocket${count > 1 ? 's' : ''} for ${String(spec.diameter)} × ${String(spec.height)} mm disc magnets in ${body}: ${String(pocket.diameter)} mm wide and ${String(pocket.depth)} mm deep, so the magnet lies ${String(MAGNET.sunk)} mm under the face, with a chamfered mouth. ` + + (hold === 'glue' ? 'The magnet drops in and is held by a drop of superglue. ' : `The magnet is pressed in (${String(MAGNET.room.press)} mm of room on the diameter); with more than that missing the plastic splits along its layers, so try one pocket first. `) + + `Keep at least 1.2 mm of plastic round a pocket and 0.6 mm under it. Mark the poles before gluing a pair: the two parts must attract. Neodymium magnets lose strength above 80 °C. No pocket of these sizes has been printed yet.`, + }; +} + /** A knob the model asked for: its steps and what to say; null when the block is none; or what is wrong. */ export function knobOf(args: Record): { body: string; steps: Step[]; text: string; fresh: true } | string | null { if (args.block !== 'knob') return null; @@ -195,7 +226,7 @@ export function withBlockTool(upstream: UpstreamTools, keepFace: (body: string, listTools: async () => [...(await upstream.listTools()), blockTool], callTool: async (name, args, callId): Promise => { if (name !== BLOCK_TOOL) return upstream.callTool(name, args, callId); - const wall = wallOf(args) ?? knobOf(args); + const wall = wallOf(args) ?? knobOf(args) ?? magnetOf(args); if (typeof wall === 'string') return { content: [{ type: 'text', text: `Not built: ${wall}` }], isError: true }; const block = wall ? null : blockOf(args); if (typeof block === 'string') return { content: [{ type: 'text', text: `Not built: ${block}` }], isError: true }; @@ -230,4 +261,4 @@ export function withBlockTool(upstream: UpstreamTools, keepFace: (body: string, } /** Added to the role prompt of every route. */ -export const BLOCK_GUIDANCE = `For a screw hole, a tapped hole, the seat of a heat-set insert, a nut trap, a screw boss, a slot, text, a lid's lip or a snap hook on one body call ${BLOCK_TOOL} instead of drilling or modelling it yourself: it takes clearance, head, nut and insert sizes from the standard tables. For a part that hangs on a wall use its keyhole (hung over screws in the wall) or woodScrew (screwed on through countersunk holes) blocks, two of them level for anything wider than 60 mm. For a knob on a potentiometer, an encoder or any other shaft use its knob block, which makes the knob as a part of its own; ask for the shaft (D, knurled or round, and its diameter) when the user has not said.`; +export const BLOCK_GUIDANCE = `For a screw hole, a tapped hole, the seat of a heat-set insert, a nut trap, a screw boss, a slot, text, a lid's lip or a snap hook on one body call ${BLOCK_TOOL} instead of drilling or modelling it yourself: it takes clearance, head, nut and insert sizes from the standard tables. For a part that hangs on a wall use its keyhole (hung over screws in the wall) or woodScrew (screwed on through countersunk holes) blocks, two of them level for anything wider than 60 mm. For parts held by magnets (a lid, a tool holder, a sign) use its magnet block for each pocket instead of drilling a hole of the magnet's size; ask for the magnet's diameter and height when the user has not said. For a knob on a potentiometer, an encoder or any other shaft use its knob block, which makes the knob as a part of its own; ask for the shaft (D, knurled or round, and its diameter) when the user has not said.`; diff --git a/apps/desktop/src/shared/magnet.ts b/apps/desktop/src/shared/magnet.ts new file mode 100644 index 0000000..112c1f0 --- /dev/null +++ b/apps/desktop/src/shared/magnet.ts @@ -0,0 +1,62 @@ +/** + * Pockets for disc magnets (standards of 2026-11-11). Magnets are made to ±0.1 mm (K&J, supermagnete); + * the room round them is what makers of printed pockets publish, which runs from none to 0.5 mm on + * the diameter (Gridfinity's plain hole), none of it printed here yet. + */ + +type Vec3 = [number, number, number]; +type Step = { command: string; params: Record }; + +export const MAGNET_HOLDS = ['glue', 'press'] as const; +export type MagnetHold = (typeof MAGNET_HOLDS)[number]; + +/** + * In mm: what is added to the magnet's diameter (to drop it in on a drop of glue, or to press it in), + * how far it lies under the face so nothing stands proud, and the chamfer at the pocket's mouth. + */ +export const MAGNET = { room: { glue: 0.3, press: 0.1 }, sunk: 0.2, chamfer: 0.4 }; + +export interface MagnetSpec { + body: string; + /** The middle of the pocket, on the face it opens in. */ + point: Vec3; + /** Out of that face. */ + normal: Vec3; + more: Vec3[]; + /** The magnet: a disc of this diameter and height, in mm. */ + diameter: number; + height: number; + hold: MagnetHold; + name: string; +} + +const round = (value: number) => Math.round(value * 1000) / 1000; + +/** The pocket's diameter and depth. */ +export function magnetPocket(spec: Pick): { diameter: number; depth: number } { + return { diameter: round(spec.diameter + MAGNET.room[spec.hold]), depth: round(spec.height + MAGNET.sunk) }; +} + +/** What is wrong with a magnet pocket before anything is built; null when it can be built. */ +export function magnetProblem(spec: MagnetSpec): string | null { + if (Math.hypot(...spec.normal) < 1e-9) return 'normal must not be zero'; + if (!(spec.diameter >= 2 && spec.diameter <= 40)) return 'diameter must be 2–40 mm (the magnet)'; + if (!(spec.height >= 1 && spec.height <= 20)) return 'height must be 1–20 mm (the magnet)'; + if (spec.more.length > 40) return 'at most 40 further pockets'; + return null; +} + +/** The cuts of each pocket: the hole into the face, and a chamfer at its mouth to start the magnet. */ +export function magnetSteps(spec: MagnetSpec): Step[] { + const length = Math.hypot(...spec.normal); + const into = spec.normal.map((value) => round(-value / length) + 0) as Vec3; + const pocket = magnetPocket(spec); + const mm = (value: number) => `${String(round(value))} mm`; + return [spec.point, ...spec.more].flatMap((point, i): Step[] => { + const label = `${spec.name} ${String(i + 1)}`; + return [ + { command: 'solid.cylinder', params: { diameter: mm(pocket.diameter), height: mm(pocket.depth), base: point.map(round), axis: into, operation: 'cut', targets: [spec.body], name: label } }, + { command: 'solid.cone', params: { diameter: mm(pocket.diameter + 2 * MAGNET.chamfer), top_diameter: mm(pocket.diameter), height: mm(MAGNET.chamfer), base: point.map(round), axis: into, operation: 'cut', targets: [spec.body], name: `${label} ◣` } }, + ]; + }); +} diff --git a/apps/desktop/src/shared/stepPhrases.ts b/apps/desktop/src/shared/stepPhrases.ts index b38982a..c137e27 100644 --- a/apps/desktop/src/shared/stepPhrases.ts +++ b/apps/desktop/src/shared/stepPhrases.ts @@ -83,6 +83,7 @@ export const STEP_PHRASE_KEYS = [ 'keyhole', 'woodScrewHole', 'knob', + 'magnetPocket', 'blockByAssistant', 'command', ] as const; @@ -309,6 +310,7 @@ export function describeToolCall(tool: string, args: Params): StepPhrase[] { if (tool === 'partbuddy_block') { if (args.block === 'keyhole') return [{ key: 'keyhole', values: { screw: (typeof args.screw === 'number' ? args.screw : 4) } }]; if (args.block === 'woodScrew') return [{ key: 'woodScrewHole', values: { screw: (typeof args.screw === 'number' ? args.screw : 4) } }]; + if (args.block === 'magnet') return [{ key: 'magnetPocket', values: { diameter: typeof args.diameter === 'number' ? args.diameter : 6, height: typeof args.height === 'number' ? args.height : 2 } }]; 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) } }]; } @@ -466,6 +468,7 @@ export const stepMessages: Record<'en' | 'de', Record> = 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', + magnetPocket: 'Pocket for a {diameter} × {height} mm magnet', blockByAssistant: 'Function block {block} on {body}', command: 'SolidCraft: {command}', }, @@ -538,6 +541,7 @@ export const stepMessages: Record<'en' | 'de', Record> = 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', + magnetPocket: 'Tasche für einen Magneten {diameter} × {height} mm', blockByAssistant: 'Funktionsbaustein {block} an {body}', command: 'SolidCraft: {command}', }, diff --git a/apps/desktop/test/blockTool.test.ts b/apps/desktop/test/blockTool.test.ts index da16681..b396d1d 100644 --- a/apps/desktop/test/blockTool.test.ts +++ b/apps/desktop/test/blockTool.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { blockOf, knobOf, wallOf, withBlockTool } from '../src/main/agents/blockTool'; +import { blockOf, knobOf, magnetOf, wallOf, withBlockTool } from '../src/main/agents/blockTool'; import type { UpstreamTools } from '../src/main/agents/gateProxy'; import { decide } from '../src/main/agents/policy'; import { KNOB, knobProblem, knobSteps, type KnobSpec } from '../src/shared/knob'; +import { MAGNET, magnetPocket, magnetSteps } from '../src/shared/magnet'; import { describeToolCall } from '../src/shared/stepPhrases'; import { KEYHOLE, WALL, keyholeDepth, keyholeProblem, keyholeSteps, woodScrewSteps, type KeyholeSpec } from '../src/shared/wallMount'; @@ -136,6 +137,35 @@ describe('a knob for a shaft (standards)', () => { }); }); +describe('pockets for disc magnets (standards)', () => { + it('cuts each pocket into the face with a chamfered mouth, sized by the magnet and how it is held', () => { + const steps = magnetSteps({ body: 'Lid', point: [10, 10, 0], normal: [0, 0, -2], more: [[30, 10, 0]], diameter: 6, height: 2, hold: 'glue', name: 'Magnet' }); + expect(steps.map((step) => [step.command, step.params.name])).toEqual([ + ['solid.cylinder', 'Magnet 1'], + ['solid.cone', 'Magnet 1 ◣'], + ['solid.cylinder', 'Magnet 2'], + ['solid.cone', 'Magnet 2 ◣'], + ]); + // On the underside: the pocket goes up into the part, 0.3 mm wider than the magnet and 0.2 mm deeper. + expect(steps[0]?.params).toEqual({ diameter: '6.3 mm', height: '2.2 mm', base: [10, 10, 0], axis: [0, 0, 1], operation: 'cut', targets: ['Lid'], name: 'Magnet 1' }); + expect(steps[1]?.params).toMatchObject({ diameter: '7.1 mm', top_diameter: '6.3 mm', height: '0.4 mm', base: [10, 10, 0], axis: [0, 0, 1], operation: 'cut' }); + expect(steps[2]?.params).toMatchObject({ base: [30, 10, 0] }); + expect(magnetPocket({ diameter: 10, height: 3, hold: 'press' })).toEqual({ diameter: 10.1, depth: 3.2 }); + expect(MAGNET).toEqual({ room: { glue: 0.3, press: 0.1 }, sunk: 0.2, chamfer: 0.4 }); + }); + + it('fills a pocket in from the standard, says how the magnet is held and refuses what is no magnet', () => { + const glued = magnetOf({ block: 'magnet', body: 'Lid', point: [10, 10, 0], normal: [0, 0, -1], more: [[30, 10, 0]] }); + expect((glued as { text: string }).text).toContain('Built 2 pockets for 6 × 2 mm disc magnets in Lid: 6.3 mm wide and 2.2 mm deep, so the magnet lies 0.2 mm under the face, with a chamfered mouth. The magnet drops in and is held by a drop of superglue.'); + expect((magnetOf({ block: 'magnet', body: 'Lid', point: [10, 10, 0], normal: [0, 0, -1], diameter: 8, height: 3, hold: 'press' }) as { text: string }).text).toContain('8.1 mm wide and 3.2 mm deep'); + expect(magnetOf({ block: 'magnet', body: 'Lid', point: [10, 10, 0] })).toBe('magnet needs normal: the way out of the face the pocket opens in, three numbers'); + expect(magnetOf({ block: 'magnet', body: 'Lid', point: [10, 10, 0], normal: [0, 0, 1], diameter: 1 })).toBe('diameter must be 2–40 mm (the magnet)'); + expect(magnetOf({ block: 'magnet', body: 'Lid', point: [10, 10, 0], normal: [0, 0, 1], hold: 'tape' })).toBe('hold must be one of glue, press'); + expect(magnetOf({ block: 'knob', body: 'Lid', point: [0, 0, 0] })).toBeNull(); + expect(describeToolCall('partbuddy_block', { block: 'magnet', diameter: 8, height: 3 })).toEqual([{ key: 'magnetPocket', values: { diameter: 8, height: 3 } }]); + }); +}); + describe('wall mounting (standards)', () => { // The back of a plate, against the wall, is its face y = 0; the part hangs along +Z. const key: KeyholeSpec = { body: 'Plate', point: [20, 0, 80], normal: [0, -1, 0], up: [0, 0, 1], screw: 4, more: [[60, 0, 80]], name: 'Keyhole' }; diff --git a/apps/desktop/test/fakeClaude.mjs b/apps/desktop/test/fakeClaude.mjs index 6acb9bf..e0772e3 100644 --- a/apps/desktop/test/fakeClaude.mjs +++ b/apps/desktop/test/fakeClaude.mjs @@ -336,8 +336,9 @@ if (args[0] === '--version') { await client.callTool({ name: 'execute', arguments: { command: 'solid.box', params: { length: 80, width: 8, height: 100, operation: 'new', body_name: 'Plate' } } }); const hung = await client.callTool({ name: 'partbuddy_block', arguments: { block: 'keyhole', body: 'Plate', point: [20, 0, 80], normal: [0, -1, 0], up: [0, 0, 1], more: [[60, 0, 80]] } }); const screwed = await client.callTool({ name: 'partbuddy_block', arguments: { block: 'woodScrew', body: 'Plate', point: [20, 8, 30], more: [[60, 8, 30]] } }); + const magnets = await client.callTool({ name: 'partbuddy_block', arguments: { block: 'magnet', body: 'Plate', point: [20, 8, 55], normal: [0, 1, 0], more: [[60, 8, 55]] } }); await client.close(); - out({ type: 'assistant', message: { content: [{ type: 'text', text: [hung, screwed].flatMap((built) => (built.content ?? []).map((block) => block.text ?? '')).join(' ') }] } }); + out({ type: 'assistant', message: { content: [{ type: 'text', text: [hung, screwed, magnets].flatMap((built) => (built.content ?? []).map((block) => block.text ?? '')).join(' ') }] } }); out({ type: 'result', subtype: 'success', is_error: false, duration_ms: 12 }); continue; } diff --git a/docs/feature-catalog.md b/docs/feature-catalog.md index 4e737f9..5393661 100644 --- a/docs/feature-catalog.md +++ b/docs/feature-catalog.md @@ -60,6 +60,7 @@ Work runs in three phases, one pull request per step, in this order. | 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. | | 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. | | 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. |