diff --git a/apps/desktop/e2e/solidcraft.contract.spec.ts b/apps/desktop/e2e/solidcraft.contract.spec.ts index 15d7f22..623b009 100644 --- a/apps/desktop/e2e/solidcraft.contract.spec.ts +++ b/apps/desktop/e2e/solidcraft.contract.spec.ts @@ -3360,6 +3360,38 @@ test('the assistant hangs a part on the wall with its blocks: keyhole slots on t } }); +test('the assistant makes a knob for a D shaft with one call: a part of its own that prints on its top', 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-knob-')); + const env = { + SOLIDCRAFT_CONTROL_DIR: join(work, 'control'), + PARTBUDDY_SOLIDCRAFT_PORT: '7957', + 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 volume knob'); + await page.getByRole('button', { name: 'Send' }).click(); + await expect(page.getByText(/Built the knob "Volume" for a 6 mm D shaft \(flat at 4\.5 mm\): the bore is 6\.2 mm wide and 11 mm deep, flat over its last 7 mm .* with 11 flutes to grip/)).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText(/SolidCraft warns:/)).toHaveCount(0); + await expect(page.getByTestId('mesh-summary')).toContainText('1 body', { timeout: 30_000 }); + await expect(page.getByText('Knob “Volume” for a shaft')).toHaveCount(1); + 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('knob-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/blockTool.ts b/apps/desktop/src/main/agents/blockTool.ts index 059f5b6..32b3bc1 100644 --- a/apps/desktop/src/main/agents/blockTool.ts +++ b/apps/desktop/src/main/agents/blockTool.ts @@ -1,5 +1,6 @@ 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 { 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,6 +19,8 @@ export const ASSISTANT_BLOCKS = ['screwHole', 'tappedHole', 'insertSeat', 'nutTr type AssistantBlock = (typeof ASSISTANT_BLOCKS)[number]; /** Wall mounting (wallMount.ts): not blocks of the interface yet. */ export const WALL_BLOCKS = ['keyhole', 'woodScrew'] as const; +/** A part of its own (knob.ts): body is the name of the new body. */ +export const PART_BLOCKS = ['knob'] as const; const vector = { type: 'array', items: { type: 'number' }, minItems: 3, maxItems: 3 }; @@ -30,29 +33,34 @@ 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. ' + + "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: { type: 'object', properties: { - block: { type: 'string', enum: [...ASSISTANT_BLOCKS, ...WALL_BLOCKS] }, - body: { type: 'string', description: 'The body (exact name).' }, + 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.' }, - up: { ...vector, description: 'keyhole: the way up as the part hangs, along the face; default +Z.' }, + 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.' }, + 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.' }, 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. Default from the table.' }, - height: { type: 'number', description: 'boss: its height; text: the height of its capitals; lip: its height. Default by the block.' }, + 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.' }, 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.' }, + 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.' }, + clearance: { type: 'number', description: "knob: added to the shaft's diameter in mm; default 0.2 (0 for knurl). More when the printed knob sits too tight." }, hook: { type: 'string', enum: ['x+', 'x-', 'y+', 'y-'], description: "snapFit: the way the catch points, along the face's directions." }, }, required: ['block', 'body', 'point'], @@ -124,6 +132,45 @@ export function wallOf(args: Record): { body: string; steps: St }; } +/** 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; + const body = typeof args.body === 'string' ? args.body.trim() : ''; + const point = vec3(args.point); + if (!body || !point) return 'body (the name of the new knob) and point (three numbers, the centre of its underside) are needed'; + const axis = args.normal === undefined ? ([0, 0, 1] as Vec3) : vec3(args.normal); + if (!axis) return 'normal must be three numbers'; + // The pointer's usual way, or another when the shaft itself points that way. + const usual: Vec3 = Math.abs(axis[0]) < 1e-9 && Math.abs(axis[2]) < 1e-9 ? [0, 0, 1] : [0, 1, 0]; + const toward = args.up === undefined ? usual : vec3(args.up); + if (!toward) return 'up must be three numbers'; + const shaft = (args.shaft ?? 'd') as KnobShaft; + if (!KNOB_SHAFTS.includes(shaft)) return `shaft must be one of ${KNOB_SHAFTS.join(', ')}`; + const number = (value: unknown, standard: number) => (value === undefined ? standard : typeof value === 'number' ? value : Number.NaN); + const shaftDiameter = number(args.shaftDiameter, 6); + const flat = number(args.flat, knobFlat(shaftDiameter) ?? Number.NaN); + if (shaft === 'd' && args.flat === undefined && Number.isNaN(flat)) return `a D shaft of ${String(shaftDiameter)} mm has no one flat: give flat, the shaft's thickness there, as measured`; + const spec = { body, point, axis, toward, shaft, shaftDiameter, flat, diameter: number(args.diameter, 25), height: number(args.height, 16), clearance: number(args.clearance, KNOB_CLEARANCE[shaft]) }; + const problem = knobProblem(spec); + if (problem) return problem; + const bore = Math.round((shaftDiameter + spec.clearance) * 100) / 100; + const insert = knobInsert(spec); + const hold = + shaft === 'd' + ? `for a ${String(shaftDiameter)} mm D shaft (flat at ${String(flat)} mm): the bore is ${String(bore)} mm wide and ${String(insert)} mm deep, flat over its last ${String(Math.min(KNOB.flat, insert))} mm on the side away from the pointer, to push on` + : shaft === 'knurl' + ? `for a split knurled shaft of ${String(shaftDiameter)} mm: a plain bore ${String(bore)} mm wide and ${String(insert)} mm deep, which the shaft springs into` + : `for a round ${String(shaftDiameter)} mm shaft: the bore is ${String(bore)} mm wide and ${String(insert)} mm deep, and an M3 set screw cuts its own thread in the ${String(KNOB.setScrew)} mm hole on the side away from the pointer`; + return { + body, + fresh: true, + steps: knobSteps(spec), + text: + `Built the knob "${body}" ${hold}. It is ${String(spec.diameter)} mm wide and ${String(spec.height)} mm high, with ${String(knobFlutes(spec.diameter))} flutes to grip and a groove on its top as the pointer. ` + + `It prints standing on its top, the bore open upwards, without support (its print face is set). No knob of these sizes has been printed yet: when it sits too tight or too loose, build it again with another clearance.`, + }; +} + function jsonOf(result: ToolResult): unknown { const text = result.content.find((part) => part.type === 'text')?.text; if (result.isError || typeof text !== 'string') throw new Error(typeof text === 'string' ? text.slice(0, 300) : 'SolidCraft gave no answer'); @@ -142,13 +189,13 @@ function built(block: FeatureBlock): string { } /** The upstream tools plus `partbuddy_block`. */ -export function withBlockTool(upstream: UpstreamTools): UpstreamTools { +export function withBlockTool(upstream: UpstreamTools, keepFace: (body: string, face: string) => void = () => undefined): UpstreamTools { return { instructions: () => upstream.instructions(), 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); + const wall = wallOf(args) ?? knobOf(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 }; @@ -163,7 +210,13 @@ export function withBlockTool(upstream: UpstreamTools): UpstreamTools { const body = wall ? wall.body : (block?.body ?? ''); const [listing] = steps.some((step) => step.command === 'sketch.create') ? await run([{ command: 'component.list', params: {} }]) : [undefined]; const component = listing === undefined ? 0 : componentOf(listing, body); + // A new part must not take the name of a body there is: its cuts would go into that one. + const taken = ((listing as { components?: { bodies?: unknown }[] } | undefined)?.components ?? []).some((entry) => Array.isArray(entry.bodies) && entry.bodies.includes(body)); + if (wall && 'fresh' in wall && taken) return { content: [{ type: 'text', text: `Not built: there is a body named ${body} already; give the knob another name` }], isError: true }; const results = await run(component === 0 ? steps : [{ command: 'component.activate', params: { component } }, ...steps, { command: 'component.activate', params: { component: 0 } }]); + // The plate view lays the part on the face SolidCraft names (print-faces.json), as the slicer will. + const face = results.map((result) => (result as { face?: unknown } | undefined)?.face).find((entry) => typeof entry === 'string'); + if (face) keepFace(body, face); const warnings = results.flatMap((result) => { const warning = (result as { warning?: unknown } | undefined)?.warning; return typeof warning === 'string' ? [warning.slice(0, 200)] : []; @@ -177,4 +230,4 @@ export function withBlockTool(upstream: UpstreamTools): UpstreamTools { } /** 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.`; +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.`; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 9a3daba..ad1292d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -583,7 +583,7 @@ if (!app.requestSingleInstanceLock()) { steps.tools( withDesignTool( withAssemblyTool( - withViewTool(guardNotes(withBlockTool(withMechanismTool(withDrawings(upstream, drawings), () => printProfiles.part().printer.nozzle, keepMechanismFace)), (trial) => supervisor.trialSteps(trial)), () => viewState, () => notesService.forView(), checkForView), + withViewTool(guardNotes(withBlockTool(withMechanismTool(withDrawings(upstream, drawings), () => printProfiles.part().printer.nozzle, keepMechanismFace), keepMechanismFace), (trial) => supervisor.trialSteps(trial)), () => viewState, () => notesService.forView(), checkForView), () => viewState?.bodies.map((body) => body.name) ?? [], saveAssembly, ), diff --git a/apps/desktop/src/shared/knob.ts b/apps/desktop/src/shared/knob.ts new file mode 100644 index 0000000..8734abb --- /dev/null +++ b/apps/desktop/src/shared/knob.ts @@ -0,0 +1,134 @@ +/** + * A knob for a shaft (standards of 2026-11-11): a potentiometer's or encoder's D shaft, a split + * knurled shaft, or a round shaft held by a set screw. The shafts are those of the data sheets + * (Bourns PEC11R, Alps RK09: 6 mm, flat at 4.5 mm, 7 mm of flat at the least); the rest is what + * makers of printed knobs publish, none of it printed here yet. + */ + +type Vec3 = [number, number, number]; +type Step = { command: string; params: Record }; + +export const KNOB_SHAFTS = ['d', 'round', 'knurl'] as const; +export type KnobShaft = (typeof KNOB_SHAFTS)[number]; + +/** + * In mm: the plastic over the shaft's end, the wall left round the bore under the flutes, how far + * the shaft goes in at the most, how much of that is flat on a D shaft (the shortest flat sold), + * the flutes (as a screw lid's), the plain band and its chamfer at the top, the pointer's groove, + * and the pilot hole an M3 set screw cuts its own thread in. + */ +export const KNOB = { roof: 3, wall: 2.4, insert: 11, flat: 7, flute: 3, band: 2, chamfer: 0.6, pointer: { width: 1.2, depth: 0.6 }, setScrew: 2.6 }; + +/** The room added to the shaft's diameter: a D and a round shaft push in snugly; a split knurled shaft springs into a plain bore of its own size. */ +export const KNOB_CLEARANCE: Record = { d: 0.2, round: 0.2, knurl: 0 }; + +export interface KnobSpec { + /** The new body's name. */ + body: string; + /** The centre of the knob's underside, where the shaft goes in. */ + point: Vec3; + /** The way the shaft points: from the underside to the top. */ + axis: Vec3; + /** The way the pointer points, across the axis; what of it runs along the axis is dropped. */ + toward: Vec3; + shaft: KnobShaft; + shaftDiameter: number; + /** A D shaft's thickness at its flat, measured through the middle. */ + flat: number; + diameter: number; + height: number; + /** Added to the shaft's diameter and to its flat. */ + clearance: number; +} + +const round = (value: number) => Math.round(value * 1000) / 1000; +const unit = (vector: Vec3): Vec3 => { + const length = Math.hypot(...vector); + return vector.map((value) => value / length) as Vec3; +}; +const cross = (a: Vec3, b: Vec3): Vec3 => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +const dot = (a: Vec3, b: Vec3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + +/** The flat of a D shaft where the data sheets agree on one; elsewhere it must be measured. */ +export function knobFlat(shaftDiameter: number): number | null { + return shaftDiameter === 6 ? 4.5 : null; +} + +/** How far the shaft goes into the knob. */ +export function knobInsert(spec: Pick): number { + return round(Math.min(KNOB.insert, spec.height - KNOB.roof)); +} + +/** How many flutes go round a knob: one every 7 mm, as round a screw lid. */ +export function knobFlutes(diameter: number): number { + return Math.max(8, Math.round((Math.PI * diameter) / 7)); +} + +/** What is wrong with a knob before anything is built; null when it can be built. */ +export function knobProblem(spec: KnobSpec): string | null { + if (Math.hypot(...spec.axis) < 1e-9) return 'normal must not be zero'; + const axis = unit(spec.axis); + if (Math.hypot(...cross(axis, spec.toward)) < 1e-6) return 'up (the way the pointer points) must not run along normal (the shaft)'; + if (!(spec.shaftDiameter >= 3 && spec.shaftDiameter <= 10)) return 'shaftDiameter must be 3–10 mm'; + if (!(spec.clearance >= 0 && spec.clearance <= 0.6)) return 'clearance must be 0–0.6 mm'; + if (spec.shaft === 'd' && !(spec.flat > spec.shaftDiameter / 2 && spec.flat < spec.shaftDiameter)) return `flat must be the D shaft's thickness at its flat, between ${String(spec.shaftDiameter / 2)} and ${String(spec.shaftDiameter)} mm (4.5 on a 6 mm shaft)`; + const least = Math.ceil(spec.shaftDiameter + spec.clearance + 2 * KNOB.wall + KNOB.flute / 2); + if (!(spec.diameter >= least && spec.diameter <= 80)) return `diameter must be ${String(least)}–80 mm for a ${String(spec.shaftDiameter)} mm shaft`; + if (!(spec.height >= 6 + KNOB.roof && spec.height <= 60)) return `height must be ${String(6 + KNOB.roof)}–60 mm: the shaft goes in 6 mm at the least, under ${String(KNOB.roof)} mm of roof`; + return null; +} + +/** + * The solids of a knob standing on its underside at `point`: a cylinder with a chamfered plain band + * at the top, flutes round it for the fingers, a bore for the shaft from below, in it the flat of a + * D shaft or beside it the pilot hole of a set screw, and a groove on the top as the pointer. It + * prints on its top, the bore open upwards, without support; the print face is set so. + */ +export function knobSteps(spec: KnobSpec): Step[] { + const axis = unit(spec.axis); + const along = dot(spec.toward, axis); + const toward = unit(spec.toward.map((value, i) => value - along * (axis[i] ?? 0)) as Vec3); + const side = cross(axis, toward); + const mm = (value: number) => `${String(round(value))} mm`; + const tidy = (vector: Vec3) => vector.map((value) => round(value) + 0) as Vec3; + /** A point `up` above the underside, `out` towards the pointer and `across` square to both. */ + const at = (up: number, out = 0, across = 0) => spec.point.map((value, i) => round(value + (axis[i] ?? 0) * up + (toward[i] ?? 0) * out + (side[i] ?? 0) * across)) as Vec3; + const radius = spec.diameter / 2; + const bore = spec.shaftDiameter + spec.clearance; + const insert = knobInsert(spec); + const flutes = knobFlutes(spec.diameter); + const half = Math.PI / flutes; + const name = spec.body; + const steps: Step[] = [ + { command: 'solid.cylinder', params: { diameter: mm(spec.diameter), height: mm(spec.height - KNOB.chamfer), base: at(0), axis: tidy(axis), operation: 'new', body_name: name, name } }, + { command: 'solid.cone', params: { diameter: mm(spec.diameter), top_diameter: mm(spec.diameter - 2 * KNOB.chamfer), height: mm(KNOB.chamfer), base: at(spec.height - KNOB.chamfer), axis: tidy(axis), operation: 'join', targets: [name], name: `${name} ◢` } }, + // The flutes stop under the plain band, and the pointer lies between two of them. + { command: 'solid.cylinder', params: { diameter: mm(KNOB.flute), height: mm(spec.height - KNOB.band), base: at(0, (radius + KNOB.flute / 4) * Math.cos(half), (radius + KNOB.flute / 4) * Math.sin(half)), axis: tidy(axis), operation: 'cut', targets: [name], name: `${name} ◆` } }, + { command: 'solid.pattern.circular', params: { features: [`${name} ◆`], axis: { origin: at(0), dir: tidy(axis) }, count: flutes } }, + { command: 'solid.cylinder', params: { diameter: mm(bore), height: mm(insert), base: at(0), axis: tidy(axis), operation: 'cut', targets: [name], name: `${name} ○` } }, + ]; + if (spec.shaft === 'd') { + // The flat fills the bore's end again, on the side away from the pointer; the entry stays round, as the shaft is below its flat. + const length = Math.min(KNOB.flat, insert); + const edge = spec.flat - spec.shaftDiameter / 2 + spec.clearance / 2; + const [near, far, wide] = [round(-edge), round(-(bore / 2 + 0.5)), round(bore / 2)]; + steps.push( + // Sketch x towards the pointer, y across: x × y is the shaft's direction, so the flat grows into the bore. + { command: 'sketch.create', params: { plane: { origin: at(insert - length), x_dir: tidy(toward), y_dir: tidy(side) }, project_edges: false, name: `${name} ▬` } }, + { command: 'sketch.line', params: { points: [[near, -wide], [near, wide], [far, wide], [far, -wide]], closed: true } }, + { command: 'solid.extrude', params: { distance: mm(length), operation: 'join', targets: [name], name: `${name} ▬` } }, + ); + } + if (spec.shaft === 'round') { + // The set screw comes in from the side away from the pointer, half way up the bore. + steps.push({ command: 'solid.cylinder', params: { diameter: mm(KNOB.setScrew), height: mm(radius + 1), base: at(insert / 2, -(radius + 1)), axis: tidy(toward), operation: 'cut', targets: [name], name: `${name} ⊙` } }); + } + steps.push( + // Sketch x across, y towards the pointer: x × y points down into the knob, so a positive distance cuts. + { command: 'sketch.create', params: { plane: { origin: at(spec.height), x_dir: tidy(side), y_dir: tidy(toward) }, project_edges: false, name: `${name} ▸` } }, + { command: 'sketch.slot.center_to_center', params: { p0: [0, round(radius * 0.35)], p1: [0, round(radius)], width: KNOB.pointer.width } }, + { command: 'solid.extrude', params: { distance: mm(KNOB.pointer.depth), operation: 'cut', targets: [name], name: `${name} ▸` } }, + { command: 'body.print_face', params: { body: name, point: at(spec.height) } }, + ); + return steps; +} diff --git a/apps/desktop/src/shared/stepPhrases.ts b/apps/desktop/src/shared/stepPhrases.ts index a93638b..bf582aa 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', 'keyhole', 'woodScrewHole', + 'knob', 'blockByAssistant', 'command', ] as const; @@ -306,6 +307,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 === '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 === 'lid') return [{ key: 'screwLid', values: { name: text(args.moving, 'Lid').slice(0, 60) } }]; @@ -457,6 +459,7 @@ export const stepMessages: Record<'en' | 'de', Record> = screwLid: 'Screw lid “{name}” with its thread on the container', 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', blockByAssistant: 'Function block {block} on {body}', command: 'SolidCraft: {command}', }, @@ -526,6 +529,7 @@ export const stepMessages: Record<'en' | 'de', Record> = screwLid: 'Schraubdeckel „{name}“ mit Gewinde am Behälter', 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', 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 3c3b027..da16681 100644 --- a/apps/desktop/test/blockTool.test.ts +++ b/apps/desktop/test/blockTool.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { blockOf, wallOf, withBlockTool } from '../src/main/agents/blockTool'; +import { blockOf, knobOf, 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 { describeToolCall } from '../src/shared/stepPhrases'; import { KEYHOLE, WALL, keyholeDepth, keyholeProblem, keyholeSteps, woodScrewSteps, type KeyholeSpec } from '../src/shared/wallMount'; @@ -53,6 +54,88 @@ describe('the function blocks for the assistant (standards)', () => { }); }); +describe('a knob for a shaft (standards)', () => { + const knob: KnobSpec = { body: 'Knob', point: [0, 0, 0], axis: [0, 0, 1], toward: [0, 1, 0], shaft: 'd', shaftDiameter: 6, flat: 4.5, diameter: 25, height: 16, clearance: 0.2 }; + + it('builds the fluted body, the bore with the flat of a D shaft away from the pointer, the pointer and the print face', () => { + const steps = knobSteps(knob); + expect(steps.map((step) => [step.command, step.params.name ?? ''])).toEqual([ + ['solid.cylinder', 'Knob'], + ['solid.cone', 'Knob ◢'], + ['solid.cylinder', 'Knob ◆'], + ['solid.pattern.circular', ''], + ['solid.cylinder', 'Knob ○'], + ['sketch.create', 'Knob ▬'], + ['sketch.line', ''], + ['solid.extrude', 'Knob ▬'], + ['sketch.create', 'Knob ▸'], + ['sketch.slot.center_to_center', ''], + ['solid.extrude', 'Knob ▸'], + ['body.print_face', ''], + ]); + expect(steps[0]?.params).toMatchObject({ diameter: '25 mm', height: '15.4 mm', operation: 'new', body_name: 'Knob' }); + // Eleven flutes, one every 7 mm, stopping 2 mm under the top. + expect(steps[2]?.params).toMatchObject({ diameter: '3 mm', height: '14 mm', operation: 'cut' }); + expect(steps[3]?.params).toMatchObject({ count: 11, axis: { origin: [0, 0, 0], dir: [0, 0, 1] } }); + // The bore: the shaft and 0.2 mm, 11 mm deep under 5 mm of roof. + expect(steps[4]?.params).toMatchObject({ diameter: '6.2 mm', height: '11 mm', base: [0, 0, 0], operation: 'cut' }); + // The flat over the bore's last 7 mm: sketch x runs to the pointer (+Y), and the flat lies at x = −(4.5 − 3 + 0.1). + expect(steps[5]?.params.plane).toEqual({ origin: [0, 0, 4], x_dir: [0, 1, 0], y_dir: [-1, 0, 0] }); + expect(steps[6]?.params).toEqual({ points: [[-1.6, -3.1], [-1.6, 3.1], [-3.6, 3.1], [-3.6, -3.1]], closed: true }); + expect(steps[7]?.params).toMatchObject({ distance: '7 mm', operation: 'join' }); + // The pointer: a groove on the top from a third of the radius to the rim, towards +Y. + expect(steps[8]?.params.plane).toEqual({ origin: [0, 0, 16], x_dir: [-1, 0, 0], y_dir: [0, 1, 0] }); + expect(steps[9]?.params).toEqual({ p0: [0, 4.375], p1: [0, 12.5], width: 1.2 }); + expect(steps[10]?.params).toMatchObject({ distance: '0.6 mm', operation: 'cut' }); + expect(steps[11]?.params).toEqual({ body: 'Knob', point: [0, 0, 16] }); + expect(KNOB).toMatchObject({ roof: 3, wall: 2.4, insert: 11, flat: 7, setScrew: 2.6 }); + }); + + it('gives a round shaft a set screw from the side away from the pointer, and a knurled one a plain bore', () => { + // A shaft along +X, the pointer up: the screw's hole starts outside the knob below it and runs up to the bore. + const round = knobSteps({ ...knob, point: [10, 5, 3], axis: [1, 0, 0], toward: [0, 0, 1], shaft: 'round', diameter: 20, height: 14 }); + expect(round.map((step) => step.params.name ?? '')).toContain('Knob ⊙'); + expect(round.find((step) => step.params.name === 'Knob ⊙')?.params).toMatchObject({ diameter: '2.6 mm', height: '11 mm', base: [15.5, 5, -8], axis: [0, 0, 1], operation: 'cut' }); + const knurl = knobSteps({ ...knob, shaft: 'knurl', clearance: 0 }); + expect(knurl.map((step) => step.command)).not.toContain('sketch.line'); + expect(knurl[4]?.params).toMatchObject({ diameter: '6 mm' }); + }); + + it('fills a knob in from the standard and refuses what cannot hold a shaft', () => { + const built = knobOf({ block: 'knob', body: 'Volume', point: [0, 0, 0] }); + expect(built).toMatchObject({ body: 'Volume', fresh: true }); + expect((built as { text: string }).text).toContain('Built the knob "Volume" for a 6 mm D shaft (flat at 4.5 mm): the bore is 6.2 mm wide and 11 mm deep, flat over its last 7 mm on the side away from the pointer, to push on. It is 25 mm wide and 16 mm high, with 11 flutes to grip'); + expect((knobOf({ block: 'knob', body: 'Volume', point: [0, 0, 0], shaft: 'knurl' }) as { text: string }).text).toContain('a plain bore 6 mm wide and 11 mm deep'); + // A shaft along Y takes its pointer up. + expect((knobOf({ block: 'knob', body: 'Volume', point: [0, 0, 0], normal: [0, 1, 0] }) as { steps: { params: { plane?: { y_dir: number[] } } }[] }).steps[8]?.params.plane?.y_dir).toEqual([0, 0, 1]); + expect(knobOf({ block: 'knob', body: 'Volume', point: [0, 0, 0], shaftDiameter: 6.35 })).toBe("a D shaft of 6.35 mm has no one flat: give flat, the shaft's thickness there, as measured"); + expect(knobOf({ block: 'knob', body: 'Volume', point: [0, 0, 0], shaftDiameter: 6.35, flat: 5.54 })).toMatchObject({ body: 'Volume' }); + expect(knobOf({ block: 'knob', body: 'Volume', point: [0, 0, 0], diameter: 10 })).toBe('diameter must be 13–80 mm for a 6 mm shaft'); + expect(knobOf({ block: 'knob', body: 'Volume', point: [0, 0, 0], height: 6 })).toContain('height must be 9–60 mm'); + expect(knobProblem({ ...knob, toward: [0, 0, 2] })).toContain('must not run along normal'); + expect(knobProblem({ ...knob, flat: 2 })).toContain('flat must be'); + expect(knobOf({ block: 'keyhole', body: 'Plate', point: [0, 0, 0] })).toBeNull(); + }); + + it('builds the knob as a new body, keeps its print face for the plate and refuses a name that is taken', async () => { + const kept: string[] = []; + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve([]), + callTool: (_name, args) => { + const commands = args.commands as { command: string }[]; + const reply = (step: { command: string }) => (step.command === 'component.list' ? { components: [{ id: 0, parent: null, bodies: ['Panel'] }] } : step.command === 'body.print_face' ? { body: 'Volume', face: 'face-7' } : {}); + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results: commands.map(reply) }) }] }); + }, + }; + const tools = withBlockTool(upstream, (body, face) => kept.push(`${body}:${face}`)); + expect(String((await tools.callTool('partbuddy_block', { block: 'knob', body: 'Volume', point: [0, 0, 0] })).content[0]?.text)).toContain('Built the knob "Volume"'); + expect(kept).toEqual(['Volume:face-7']); + expect(await tools.callTool('partbuddy_block', { block: 'knob', body: 'Panel', point: [0, 0, 0] })).toMatchObject({ isError: true, content: [{ text: 'Not built: there is a body named Panel already; give the knob another name' }] }); + expect(describeToolCall('partbuddy_block', { block: 'knob', body: 'Volume' })).toEqual([{ key: 'knob', values: { name: 'Volume' } }]); + }); +}); + 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 3349019..0473dfe 100644 --- a/apps/desktop/test/fakeClaude.mjs +++ b/apps/desktop/test/fakeClaude.mjs @@ -323,6 +323,14 @@ if (args[0] === '--version') { out({ type: 'result', subtype: 'success', is_error: false, duration_ms: 12 }); continue; } + // "a volume knob": a knob for a 6 mm D shaft, a part of its own, through PartBuddy's own block. + if (content.includes('a volume knob')) { + const built = await client.callTool({ name: 'partbuddy_block', arguments: { block: 'knob', body: 'Volume', point: [0, 0, 0] } }); + 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 wall plate": a plate standing upright, two keyholes on its back and two countersunk holes on its front, through PartBuddy's own blocks. if (content.includes('a wall plate')) { await client.callTool({ name: 'execute', arguments: { command: 'solid.box', params: { length: 80, width: 8, height: 100, operation: 'new', body_name: 'Plate' } } }); diff --git a/docs/feature-catalog.md b/docs/feature-catalog.md index 6a2e613..6b556ee 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. | +| 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. | | F3 Templates (done) | A gallery of parametric starting points on the start page, renewed on 2026-11-11 to show what the app does now: a hinged box (print-in-place hinge of the mechanism tool; a lip inside the lid with a bead that snaps into a rille of the box's front wall, nothing standing out; joined so it closes under "Moving parts"), an adjustable stop on a dovetail guide (a slider joint, a clamp screw in a heat-set insert), a housing for heat-set inserts with its lid assembled and four M3 screws as standard parts (`parts.insert`; assembly playback and plate view apply), a sign whose words are a text parameter, and a wall holder with countersunk holes. Every template is a part one would print and use: two wheels coupled by `joint.gear` were taken out again, since without teeth (SolidCraft 0.40.0-rc.4 makes none) nothing drives the second wheel. Each is a SolidCraft script whose sizes are user parameters with a comment (`parameters.add`), built into a new project with `solidcraft-cli run` and changed in the parameter panel without the assistant; joints are part of the script and follow the parameters. The pictures are rendered by SolidCraft (`tools/templates/render-previews.mjs`). |