diff --git a/apps/desktop/e2e/solidcraft.contract.spec.ts b/apps/desktop/e2e/solidcraft.contract.spec.ts index 398a1aad..340f4e8f 100644 --- a/apps/desktop/e2e/solidcraft.contract.spec.ts +++ b/apps/desktop/e2e/solidcraft.contract.spec.ts @@ -2181,6 +2181,10 @@ test('a template from the start page becomes a project whose sizes are its param await page.keyboard.press('End'); await expect(page.getByTestId('joint-panel').getByRole('slider', { name: 'Hinge' })).toHaveValue('180', { timeout: 30_000 }); await page.screenshot({ path: test.info().outputPath('template-hingedBox-closed.png') }); + // While SolidCraft drives the joint the slider takes no keys: back once it is free again. + const closed = page.getByTestId('joint-panel').getByRole('slider', { name: 'Hinge' }); + await expect(closed).toBeEnabled({ timeout: 30_000 }); + await closed.focus(); await page.keyboard.press('Home'); await expect(page.getByTestId('joint-panel').getByRole('slider', { name: 'Hinge' })).toHaveValue('0', { timeout: 30_000 }); } @@ -3318,6 +3322,45 @@ test('the assistant builds a dovetail guide with one call: joined as a slider th } }); +test('the assistant builds a screw joint with one call: holes on one line in both parts and the screws as standard parts', 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-screw-')); + const env = { + SOLIDCRAFT_CONTROL_DIR: join(work, 'control'), + PARTBUDDY_SOLIDCRAFT_PORT: '7954', + 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 screwed plate'); + await page.getByRole('button', { name: 'Send' }).click(); + await expect(page.getByText(/Built 2 screw joints "Plate screws" that hold Plate on Block: .* To buy: 2 × M3×8 socket head cap screws \(ISO 4762\) and 2 M3 heat-set inserts 5\.7 mm long/)).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText(/Warning:/)).toHaveCount(0); + // Block, plate and two screws, which the tree knows as standard parts. + await expect(page.getByTestId('mesh-summary')).toContainText('4 bodies', { timeout: 30_000 }); + await expect(page.getByText('standard part', { exact: true })).toHaveCount(2); + await expect(page.getByText('Screw joint “Plate screws” between two parts')).toHaveCount(1); + await expect(page.locator('[data-testid="timeline-feature"][data-state="error"]')).toHaveCount(0); + // The screws overlap nothing: every hole is where its screw is. + 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('screw-joint-built.png') }); + } finally { + await launched.close(); + rmSync(work, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 }); + } +}); + test('the assistant builds a snap closure with one call: a lip in the lid, its bead in a rille of the box, no overlap (G3)', 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 6f27c910..a381f525 100644 --- a/apps/desktop/src/main/agents/mechanismTool.ts +++ b/apps/desktop/src/main/agents/mechanismTool.ts @@ -1,4 +1,5 @@ -import { LIP, hingeProblem, hingeSteps, lipProblem, lipSteps, pinDiameter, slideProblem, slideSteps, type HingeSpec, type LipSpec, type SlideSpec } from '../../shared/mechanisms'; +import { LIP, hingeProblem, hingeSteps, lipProblem, lipSteps, pinDiameter, screwLength, screwProblem, screwSteps, slideProblem, slideSteps, type HingeSpec, type LipSpec, type ScrewSpec, type SlideSpec } from '../../shared/mechanisms'; +import { BLOCK_SIZES, INSERT, type BlockSize } from '../../shared/screwSizes'; import { componentOf } from '../solidcraft/actions'; import type { ToolResult, UpstreamTool, UpstreamTools } from './gateProxy'; @@ -24,11 +25,13 @@ const mechanismTool: UpstreamTool = { 'closure: a snap closure between a lid and its box, nothing standing out: a lip inside the lid that goes into the box along its wall and centres the lid, a bead that snaps into a rille of the box\'s wall, and a notch for a fingernail. ' + 'Give from and to as opposite corners of the lid\'s inner room at its rim (both at the rim\'s height), up as the way the lip stands out of the lid (+Z when the lid lies open side up, -Z when it sits on the box), wall and radius of the lid, and the side with the catch (a long wall, opposite the hinge). ' + 'When the lid is built open beside the box and closes on a hinge, give the hinge line too (hinge_from, hinge_to), so the rille lands where the bead closes. It makes no joint. ' + - 'Use this instead of modelling a hinge, a guide or a snap lid yourself.', + 'screw: one or more screws that hold the moving body on the fixed one: a clearance hole with the room for the head in the moving body, and on the same line the seat of a heat-set insert (or a modelled thread from M5) in the fixed body; the screws go in as standard parts. ' + + 'Give from where the screw goes in (on the outer face of the moving body) and to where its line passes into the fixed body (on the face the two meet at); more lists where further screws of the same kind go in. ' + + 'Use this instead of modelling a hinge, a guide, a snap lid or the holes of a screw joint yourself.', inputSchema: { type: 'object', properties: { - kind: { type: 'string', enum: ['hinge', 'slide', 'closure'], description: 'hinge: turns about a line; slide: moves along a line; closure: a lid snaps shut on its box.' }, + kind: { type: 'string', enum: ['hinge', 'slide', 'closure', 'screw'], description: 'hinge: turns about a line; slide: moves along a line; closure: a lid snaps shut on its box; screw: one body screwed onto the other.' }, 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.' }, @@ -37,6 +40,11 @@ const mechanismTool: UpstreamTool = { 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.' }, radius: { type: 'number', minimum: 0, maximum: 100, description: 'closure: the radius of the inner room\'s corners in mm; default 0 (sharp).' }, + size: { type: 'string', enum: [...BLOCK_SIZES], description: 'screw: the screw; default M3.' }, + head: { type: 'string', enum: ['cap', 'countersunk', 'none'], description: 'screw: socket head sunk in a counterbore (default), countersunk head, or a head lying on the face.' }, + thread: { type: 'string', enum: ['insert', 'tapped'], description: 'screw: a heat-set insert (default, M2 to M6) or a thread modelled into the plastic (M5 and larger).' }, + more: { type: 'array', items: vector, maxItems: 40, description: 'screw: where further screws like this one go in; they run the same way through the same thickness.' }, + screws: { type: 'boolean', description: 'screw: put the screws in as standard parts; default true.' }, hinge_from: { ...vector, description: 'closure: one end of the hinge line the lid closes about, when it is built open.' }, hinge_to: { ...vector, description: 'closure: the other end of that line; it runs along X or along Y.' }, diameter: { type: 'number', minimum: 4, maximum: 40, description: 'hinge: outer diameter of the barrel in mm; default 6. The pin is half of it.' }, @@ -153,6 +161,22 @@ export function lipOf(args: Record): LipSpec | string { return lipProblem(spec) ?? spec; } +/** The screw joint the model asked for, with PartBuddy's defaults; or what is wrong with the request. */ +export function screwOf(args: Record): ScrewSpec | string { + const base = common(args, 0.4, 'Screw'); + if (typeof base === 'string') return base; + const size = args.size === undefined ? 'M3' : args.size; + if (typeof size !== 'string' || !(BLOCK_SIZES as readonly string[]).includes(size)) return `size must be one of ${BLOCK_SIZES.join(', ')}`; + const head = args.head === undefined ? 'cap' : args.head; + if (head !== 'cap' && head !== 'countersunk' && head !== 'none') return 'head must be cap, countersunk or none'; + const thread = args.thread === undefined ? 'insert' : args.thread; + if (thread !== 'insert' && thread !== 'tapped') return 'thread must be insert or tapped'; + const more = args.more === undefined ? [] : Array.isArray(args.more) ? args.more.map(vec3) : null; + if (!more || more.some((point) => point === null)) return 'more must be a list of points, each three numbers'; + const spec: ScrewSpec = { fixed: base.fixed, moving: base.moving, from: base.from, to: base.to, more: more as Vec3[], size: size as BlockSize, head, thread, screws: args.screws !== false, name: base.name }; + return screwProblem(spec) ?? spec; +} + /** The occurrence that places a body, from `component.list`; null while the body is still in the design itself. */ export function occurrenceOf(listing: unknown, body: string): string | null { const components = (listing as { components?: unknown } | undefined)?.components; @@ -336,7 +360,23 @@ async function buildMechanism(upstream: UpstreamTools, args: Record 1 ? 's' : ''} "${spec.name}" that hold${count > 1 ? '' : 's'} ${spec.moving} on ${spec.fixed}: in ${spec.moving} a clearance hole for ${spec.size} (ISO 273, normal)` + + `${spec.head === 'cap' ? ' with a counterbore for the head' : spec.head === 'countersunk' ? ' with a 90° countersink' : ''}, in ${spec.fixed} ` + + `${spec.thread === 'insert' && insert ? `the seat of a heat-set insert (${String(insert.diameter)} mm wide, ${String(insert.length + 1)} mm deep)` : 'a modelled thread'}. ` + + `${length === null ? 'No standard screw length ends inside the thread at this thickness, so no screws were put in: choose the length yourself.' : `To buy: ${String(count)} × ${spec.size}×${String(length)} ${screw}${spec.thread === 'insert' && insert ? ` and ${String(count)} ${spec.size} heat-set inserts ${String(insert.length)} mm long` : ''}${spec.screws ? '; the screws are in the design as standard parts' : ''}.`} ` + + `${spec.thread === 'insert' ? 'Leave at least 2 mm of plastic around each insert (a boss where the wall is thinner).' : ''}${warnings}` + ); + } + throw new Error('kind must be hinge, slide, closure or screw'); } /** The upstream tools plus `partbuddy_mechanism`; `nozzle` is the printer's nozzle in use, for the default gap. */ @@ -356,4 +396,4 @@ export function withMechanismTool(upstream: UpstreamTools, nozzle: () => number) } /** Added to the role prompt of every route, after the joint guidance. */ -export const MECHANISM_GUIDANCE = `For a hinge (a lid that folds, a flap, a clamp that opens) or a guide (a drawer, a slider, a carriage on a rail) call ${MECHANISM_TOOL} once both bodies exist instead of modelling knuckles, a pin or a rail yourself: it builds the print-in-place geometry with the right gaps, joins the bodies and sets the limits. For a lid that should hold shut by itself (a box, a case, a container) call it with kind closure instead of modelling a latch, a hook or a clip: a lip with a snap bead inside the lid, nothing standing out and no wall cut through.`; +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.`; diff --git a/apps/desktop/src/shared/blocks.ts b/apps/desktop/src/shared/blocks.ts index cc70c762..9650394c 100644 --- a/apps/desktop/src/shared/blocks.ts +++ b/apps/desktop/src/shared/blocks.ts @@ -9,54 +9,9 @@ import { logoBase, TEXT_PREFIX, uniqueLogoName } from './drawingPlacement'; import { drawingColours, drawingPieces, drawingSize, drawingSvg, naturalWidth, parseDrawing, shapesOverlap, shapesSvg, type DrawingProblem, type Shape } from './svgDrawing'; -export const BLOCK_SIZES = ['M2', 'M2.5', 'M3', 'M4', 'M5', 'M6', 'M8', 'M10'] as const; -export type BlockSize = (typeof BLOCK_SIZES)[number]; +import { CAP_HEAD, CLEARANCE_NORMAL, COUNTERSUNK_HEAD, HEAD_ALLOWANCE, INSERT, NUT, NUT_ALLOWANCE, BLOCK_SIZES, type BlockSize } from './screwSizes'; -/** Socket head cap screws, ISO 4762: head diameter and height in mm. */ -const CAP_HEAD: Record = { - M2: [3.8, 2], - 'M2.5': [4.5, 2.5], - M3: [5.5, 3], - M4: [7, 4], - M5: [8.5, 5], - M6: [10, 6], - M8: [13, 8], - M10: [16, 10], -}; - -/** Countersunk screws, ISO 10642 (from M3): theoretical head diameter in mm, 90°. */ -const COUNTERSUNK_HEAD: Partial> = { M3: 6.72, M4: 8.96, M5: 11.2, M6: 13.44, M8: 17.92, M10: 22.4 }; - -/** - * Heat-set threaded inserts: typical hole diameter and insert length in mm (values of the common - * ruthex inserts). The seat is one millimetre deeper than the insert for the displaced plastic. - */ -export const INSERT: Partial> = { - M2: { diameter: 3.2, length: 4 }, - 'M2.5': { diameter: 3.6, length: 5.7 }, - M3: { diameter: 4, length: 5.7 }, - M4: { diameter: 5.6, length: 8.1 }, - M5: { diameter: 6.4, length: 9.5 }, - M6: { diameter: 8, length: 12.7 }, -}; - -/** Hex nuts, ISO 4032: width across flats and height in mm. */ -const NUT: Record = { - M2: [4, 1.6], - 'M2.5': [5, 2], - M3: [5.5, 2.4], - M4: [7, 3.2], - M5: [8, 4.7], - M6: [10, 5.2], - M8: [13, 6.8], - M10: [16, 8.4], -}; - -/** Extra room around a printed nut: across flats and depth. */ -export const NUT_ALLOWANCE = { flats: 0.3, depth: 0.4 }; - -/** Extra room around a printed screw head: holes print smaller than drawn. */ -export const HEAD_ALLOWANCE = { diameter: 0.6, depth: 0.4 }; +export { BLOCK_SIZES, HEAD_ALLOWANCE, INSERT, NUT_ALLOWANCE, type BlockSize }; type Point = [number, number, number]; @@ -159,8 +114,6 @@ export function hookVector(point: Point, normal: Point, hook: HookDirection): Po return (hook.endsWith('-') ? axis.map((value) => (value === 0 ? 0 : -value)) : axis) as Point; } -/** Normal clearance holes, ISO 273 medium series, in mm. */ -const CLEARANCE_NORMAL: Record = { M2: 2.4, 'M2.5': 2.9, M3: 3.4, M4: 4.5, M5: 5.5, M6: 6.6, M8: 9, M10: 11 }; /** * A sketch plane at the point, flat on the face: x runs level where it can (text reads from the diff --git a/apps/desktop/src/shared/mechanisms.ts b/apps/desktop/src/shared/mechanisms.ts index 85f33f58..28554e24 100644 --- a/apps/desktop/src/shared/mechanisms.ts +++ b/apps/desktop/src/shared/mechanisms.ts @@ -5,6 +5,8 @@ * in the tool (`mechanismTool.ts`). */ +import { CAP_HEAD, COUNTERSUNK_HEAD, HEAD_ALLOWANCE, INSERT, type BlockSize } from './screwSizes'; + type Vec3 = [number, number, number]; type Step = { command: string; params: Record }; @@ -337,3 +339,111 @@ export function lipSteps(spec: LipSpec): Step[] { }, ]; } + +export interface ScrewSpec { + /** The body the screw is screwed into: it gets the seat for the insert, or the thread. */ + fixed: string; + /** The body the screw holds down: it gets the clearance hole and the room for the head. */ + moving: string; + /** Where the screw goes in: on the outer face of the moving body. */ + from: Vec3; + /** Where its line passes from the moving body into the fixed one: on the face the two meet at. */ + to: Vec3; + /** Further screws like this one: where each goes in. They run the same way and hold the same thickness. */ + more: Vec3[]; + size: BlockSize; + /** The head: sunk in a counterbore (socket head), in a countersink, or lying on the face. */ + head: 'cap' | 'countersunk' | 'none'; + /** What holds the screw: a heat-set insert (the usual for printed parts), or a thread modelled into the plastic. */ + thread: 'insert' | 'tapped'; + /** Whether the screws go in as standard parts, for the parts list and the assembly. */ + screws: boolean; + name: string; +} + +/** Lengths screws are sold in, ISO 4762 and ISO 10642, in mm. */ +const SCREW_LENGTHS = [4, 5, 6, 8, 10, 12, 16, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80]; +/** A modelled thread holds from about M5 in print; smaller ones strip. */ +const TAPPED_FROM = 5; +/** Plastic left under a sunk head, in mm: three layers and more. */ +const UNDER_HEAD = 1.2; + +const grip = (spec: ScrewSpec) => Math.hypot(...spec.to.map((value, i) => value - (spec.from[i] ?? 0))); +const metric = (size: BlockSize) => Number(size.slice(1)); +/** How deep the head sits in the moving body. */ +const headDepth = (spec: ScrewSpec) => (spec.head === 'cap' ? CAP_HEAD[spec.size][1] + HEAD_ALLOWANCE.depth : spec.head === 'countersunk' ? metric(spec.size) * 0.6 : 0); +/** How deep the screw may reach into the fixed body: the insert, or 2.5 diameters of thread. */ +const hold = (spec: ScrewSpec) => (spec.thread === 'insert' ? (INSERT[spec.size]?.length ?? 0) : metric(spec.size) * 2.5); + +/** The screw to buy: the longest standard length that ends inside the insert or thread, and holds at least two thirds of it. */ +export function screwLength(spec: ScrewSpec): number | null { + // A socket head's length counts from under the head, a countersunk one's from its top. + const through = grip(spec) - (spec.head === 'cap' ? headDepth(spec) : 0); + const fits = SCREW_LENGTHS.filter((length) => length <= through + hold(spec) && length >= through + (hold(spec) * 2) / 3); + return fits.at(-1) ?? null; +} + +/** What is wrong with a screw joint before anything is built; null when it can be built. */ +export function screwProblem(spec: ScrewSpec): string | null { + if (spec.fixed === spec.moving) return 'fixed and moving must be two bodies'; + if (grip(spec) < 1) return 'from and to must be at least 1 mm apart: from where the screw goes in, to where it passes into the fixed body'; + if (spec.thread === 'insert' && !INSERT[spec.size]) return `there is no heat-set insert for ${spec.size} in the table (M2 to M6); use thread tapped or another size`; + if (spec.thread === 'tapped' && metric(spec.size) < TAPPED_FROM) return `a printed ${spec.size} thread strips; use thread insert, or M${String(TAPPED_FROM)} and larger`; + if (spec.head === 'countersunk' && metric(spec.size) < 3) return 'countersunk screws start at M3'; + const left = grip(spec) - headDepth(spec); + if (left < UNDER_HEAD) return `the moving body is ${String(round(grip(spec)))} mm thick there, which leaves ${String(round(left))} mm under the ${spec.head === 'cap' ? 'counterbore' : 'countersink'}; make it at least ${String(round(headDepth(spec) + UNDER_HEAD))} mm thick, or use head none`; + if (spec.more.length > 40) return 'at most 40 further screws'; + return null; +} + +/** + * The holes of a screw joint, one set per screw: in the moving body a clearance hole (ISO 273, normal) with the + * room for the head, drilled only as deep as that body is thick; in the fixed body, on the same line, the seat of a + * heat-set insert (a millimetre deeper than the insert, for the plastic it pushes down) or a modelled thread. With + * `screws` the screws themselves follow as standard parts, each seated in its hole. + */ +export function screwSteps(spec: ScrewSpec): Step[] { + const delta = spec.to.map((value, i) => value - (spec.from[i] ?? 0)) as Vec3; + const direction = delta.map((value) => round(value / grip(spec))) as Vec3; + const mm = (value: number) => `${String(round(value))} mm`; + const insert = INSERT[spec.size]; + const length = screwLength(spec); + const family = spec.head === 'countersunk' ? 'countersunk_socket_screw' : 'socket_head_cap_screw'; + const entries = [spec.from, ...spec.more].map((entry) => entry.map(round) as Vec3); + const holes = entries.flatMap((entry, i): Step[] => { + const meet = entry.map((value, k) => round(value + (delta[k] ?? 0))) as Vec3; + const label = `${spec.name} ${String(i + 1)}`; + return [ + { command: 'solid.hole', params: { position: entry, clearance: spec.size, fit: 'normal', ...headRoom(spec), direction, depth: mm(grip(spec)), targets: [spec.moving], name: label } }, + { + command: 'solid.hole', + params: + spec.thread === 'insert' && insert + ? { position: meet, direction, diameter: mm(insert.diameter), depth: mm(insert.length + 1), targets: [spec.fixed], name: `${label} ○` } + : { position: meet, direction, thread: spec.size, modeled: true, depth: mm(hold(spec) + 1), targets: [spec.fixed], name: `${label} ○` }, + }, + ]; + }); + if (!spec.screws || length === null) return holes; + // Each put where it sits, head outwards: a socket head on the floor of its counterbore, a countersunk head flush with + // the face. (Seated by a pick on the hole, SolidCraft 0.40.0-rc.4 turns the screw in from the far side.) The screws + // come after every hole, so they belong to the design and not to a body's component. + // Plus zero: no negative zero in what goes to SolidCraft. + const outwards = direction.map((value) => round(-value) + 0); + return [ + ...holes, + ...entries.map((entry) => ({ + command: 'parts.insert', + params: { family, size: spec.size, length, point: entry.map((value, k) => round(value + (direction[k] ?? 0) * (spec.head === 'cap' ? headDepth(spec) : 0))), direction: outwards }, + })), + ]; +} + +/** The room for the head, as the function block for a screw hole makes it: a counterbore or a countersink with the allowance of a printed hole. */ +function headRoom(spec: ScrewSpec): Record { + const mm = (value: number) => `${String(round(value))} mm`; + if (spec.head === 'cap') return { type: 'counterbore', cb_diameter: mm(CAP_HEAD[spec.size][0] + 2 * HEAD_ALLOWANCE.diameter), cb_depth: mm(CAP_HEAD[spec.size][1] + HEAD_ALLOWANCE.depth) }; + if (spec.head === 'countersunk') return { type: 'countersink', cs_diameter: mm((COUNTERSUNK_HEAD[spec.size] ?? CAP_HEAD[spec.size][0]) + HEAD_ALLOWANCE.diameter), cs_angle: '90 deg' }; + return {}; +} + diff --git a/apps/desktop/src/shared/screwSizes.ts b/apps/desktop/src/shared/screwSizes.ts new file mode 100644 index 00000000..27f0068c --- /dev/null +++ b/apps/desktop/src/shared/screwSizes.ts @@ -0,0 +1,57 @@ +/** + * Sizes of screws, nuts and heat-set inserts from the standard tables, and the room a printed part + * leaves around them. On their own, without the function blocks' other needs, so the mechanisms and + * the templates can use them too. + */ + +export const BLOCK_SIZES = ['M2', 'M2.5', 'M3', 'M4', 'M5', 'M6', 'M8', 'M10'] as const; +export type BlockSize = (typeof BLOCK_SIZES)[number]; + +/** Socket head cap screws, ISO 4762: head diameter and height in mm. */ +export const CAP_HEAD: Record = { + M2: [3.8, 2], + 'M2.5': [4.5, 2.5], + M3: [5.5, 3], + M4: [7, 4], + M5: [8.5, 5], + M6: [10, 6], + M8: [13, 8], + M10: [16, 10], +}; + +/** Countersunk screws, ISO 10642 (from M3): theoretical head diameter in mm, 90°. */ +export const COUNTERSUNK_HEAD: Partial> = { M3: 6.72, M4: 8.96, M5: 11.2, M6: 13.44, M8: 17.92, M10: 22.4 }; + +/** + * Heat-set threaded inserts: typical hole diameter and insert length in mm (values of the common + * ruthex inserts). The seat is one millimetre deeper than the insert for the displaced plastic. + */ +export const INSERT: Partial> = { + M2: { diameter: 3.2, length: 4 }, + 'M2.5': { diameter: 3.6, length: 5.7 }, + M3: { diameter: 4, length: 5.7 }, + M4: { diameter: 5.6, length: 8.1 }, + M5: { diameter: 6.4, length: 9.5 }, + M6: { diameter: 8, length: 12.7 }, +}; + +/** Hex nuts, ISO 4032: width across flats and height in mm. */ +export const NUT: Record = { + M2: [4, 1.6], + 'M2.5': [5, 2], + M3: [5.5, 2.4], + M4: [7, 3.2], + M5: [8, 4.7], + M6: [10, 5.2], + M8: [13, 6.8], + M10: [16, 8.4], +}; + +/** Extra room around a printed nut: across flats and depth. */ +export const NUT_ALLOWANCE = { flats: 0.3, depth: 0.4 }; + +/** Extra room around a printed screw head: holes print smaller than drawn. */ +export const HEAD_ALLOWANCE = { diameter: 0.6, depth: 0.4 }; + +/** Normal clearance holes, ISO 273 medium series, in mm. */ +export const CLEARANCE_NORMAL: Record = { M2: 2.4, 'M2.5': 2.9, M3: 3.4, M4: 4.5, M5: 5.5, M6: 6.6, M8: 9, M10: 11 }; diff --git a/apps/desktop/src/shared/stepPhrases.ts b/apps/desktop/src/shared/stepPhrases.ts index 4323e0bf..af7389d0 100644 --- a/apps/desktop/src/shared/stepPhrases.ts +++ b/apps/desktop/src/shared/stepPhrases.ts @@ -76,6 +76,7 @@ export const STEP_PHRASE_KEYS = [ 'hinge', 'guide', 'closure', + 'screwJoint', 'command', ] as const; @@ -297,6 +298,7 @@ export function describeCommands(steps: Step[]): StepPhrase[] { /** What one tool call of the assistant did; a call PartBuddy cannot read gets one generic line. */ export function describeToolCall(tool: string, args: Params): StepPhrase[] { // PartBuddy's own tool builds a mechanism from many commands: one line names it. + if (tool === 'partbuddy_mechanism' && args.kind === 'screw') return [{ key: 'screwJoint', values: { name: text(args.name, 'Screw').slice(0, 60) } }]; if (tool === 'partbuddy_mechanism' && args.kind === 'closure') return [{ key: 'closure', values: { name: text(args.name, 'Closure').slice(0, 60) } }]; if (tool === 'partbuddy_mechanism') return [args.kind === 'slide' ? { key: 'guide', values: { name: text(args.name, 'Guide').slice(0, 60) } } : { key: 'hinge', values: { name: text(args.name, 'Hinge').slice(0, 60) } }]; const steps = commandsOf(tool, args); @@ -440,6 +442,7 @@ export const stepMessages: Record<'en' | 'de', Record> = hinge: 'Hinge “{name}” between two parts, with its joint', guide: 'Guide “{name}” between two parts, with its joint', closure: 'Snap closure “{name}” between lid and box', + screwJoint: 'Screw joint “{name}” between two parts', command: 'SolidCraft: {command}', }, de: { @@ -504,6 +507,7 @@ export const stepMessages: Record<'en' | 'de', Record> = hinge: 'Scharnier „{name}“ zwischen zwei Teilen, mit Gelenk', guide: 'Führung „{name}“ zwischen zwei Teilen, mit Gelenk', closure: 'Rastverschluss „{name}“ zwischen Deckel und Dose', + screwJoint: 'Schraubverbindung „{name}“ zwischen zwei Teilen', command: 'SolidCraft: {command}', }, }; diff --git a/apps/desktop/test/fakeClaude.mjs b/apps/desktop/test/fakeClaude.mjs index 67594a82..2b840cb8 100644 --- a/apps/desktop/test/fakeClaude.mjs +++ b/apps/desktop/test/fakeClaude.mjs @@ -323,6 +323,16 @@ if (args[0] === '--version') { out({ type: 'result', subtype: 'success', is_error: false, duration_ms: 12 }); continue; } + // "a screwed plate": a block and a plate on it, then PartBuddy's own screw joint through both, twice. + if (content.includes('a screwed plate')) { + await client.callTool({ name: 'execute', arguments: { command: 'solid.box', params: { length: 60, width: 40, height: 20, operation: 'new', body_name: 'Block' } } }); + await client.callTool({ name: 'execute', arguments: { command: 'solid.box', params: { length: 60, width: 40, height: 6, corner: [0, 0, 20], operation: 'new', body_name: 'Plate' } } }); + const built = await client.callTool({ name: 'partbuddy_mechanism', arguments: { kind: 'screw', fixed: 'Block', moving: 'Plate', from: [10, 10, 26], to: [10, 10, 20], more: [[50, 30, 26]], name: 'Plate screws' } }); + 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 snap lid" (G3): a box and its lid sitting on it, both hollow, then PartBuddy's own closure between them. if (content.includes('a snap lid')) { await client.callTool({ name: 'execute', arguments: { command: 'solid.box', params: { length: 60, width: 40, height: 20, operation: 'new', body_name: 'Box' } } }); diff --git a/apps/desktop/test/mechanism.test.ts b/apps/desktop/test/mechanism.test.ts index 82d74f7f..ca72738f 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, lipOf, occurrenceOf, railTravel, slideOf, withMechanismTool } from '../src/main/agents/mechanismTool'; +import { activated, hingeOf, lipOf, occurrenceOf, railTravel, screwOf, slideOf, withMechanismTool } from '../src/main/agents/mechanismTool'; import { decide } from '../src/main/agents/policy'; import { describeToolCall } from '../src/shared/stepPhrases'; -import { hingeProblem, hingeSteps, lipProblem, lipSteps, slideProblem, slideSteps, type HingeSpec, type LipSpec, type SlideSpec } from '../src/shared/mechanisms'; +import { hingeProblem, hingeSteps, lipProblem, lipSteps, screwLength, screwProblem, screwSteps, slideProblem, slideSteps, type HingeSpec, 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' }; @@ -333,3 +333,63 @@ describe('the snap closure of a lid (G3)', () => { expect(batches.join(' ')).not.toContain('joint.as_built'); }); }); + +describe('the screw joint (standards)', () => { + // A plate 6 mm thick on a block: the screws go in from above at z 26 and pass into the block at z 20. + const joint: ScrewSpec = { fixed: 'Block', moving: 'Plate', from: [10, 10, 26], to: [10, 10, 20], more: [[50, 30, 26]], size: 'M3', head: 'cap', thread: 'insert', screws: true, name: 'Screw' }; + + it('drills the clearance hole with its counterbore only through the plate, seats the insert on the same line, and puts the screws in last', () => { + const steps = screwSteps(joint); + expect(steps.map((step) => [step.command, step.params.name ?? ''])).toEqual([ + ['solid.hole', 'Screw 1'], + ['solid.hole', 'Screw 1 ○'], + ['solid.hole', 'Screw 2'], + ['solid.hole', 'Screw 2 ○'], + ['parts.insert', ''], + ['parts.insert', ''], + ]); + expect(steps[0]?.params).toEqual({ position: [10, 10, 26], clearance: 'M3', fit: 'normal', type: 'counterbore', cb_diameter: '6.7 mm', cb_depth: '3.4 mm', direction: [0, 0, -1], depth: '6 mm', targets: ['Plate'], name: 'Screw 1' }); + // The seat is a millimetre deeper than the 5.7 mm insert. + expect(steps[1]?.params).toEqual({ position: [10, 10, 20], direction: [0, 0, -1], diameter: '4 mm', depth: '6.7 mm', targets: ['Block'], name: 'Screw 1 ○' }); + expect(steps[3]?.params.position).toEqual([50, 30, 20]); + // M3×8: 2.6 mm of plate under the head and 5.4 mm of the 5.7 mm insert. It sits on the counterbore's floor, head up. + expect(screwLength(joint)).toBe(8); + expect(steps[4]?.params).toEqual({ family: 'socket_head_cap_screw', size: 'M3', length: 8, point: [10, 10, 22.6], direction: [0, 0, 1] }); + // A countersunk screw is measured from its top, which lies flush; a thread from M5 is modelled. + const sunk = screwSteps({ ...joint, head: 'countersunk', size: 'M4', more: [] }); + expect(sunk[0]?.params).toMatchObject({ type: 'countersink', cs_diameter: '9.56 mm', cs_angle: '90 deg' }); + expect(sunk[2]?.params).toMatchObject({ family: 'countersunk_socket_screw', length: 12, point: [10, 10, 26] }); + expect(screwSteps({ ...joint, size: 'M6', thread: 'tapped', head: 'none', more: [] })[1]?.params).toMatchObject({ thread: 'M6', modeled: true, depth: '16 mm' }); + expect(screwSteps({ ...joint, screws: false })).toHaveLength(4); + }); + + it('says what is wrong before anything is built', () => { + expect(screwProblem(joint)).toBeNull(); + expect(screwProblem({ ...joint, size: 'M8' })).toContain('there is no heat-set insert for M8'); + expect(screwProblem({ ...joint, thread: 'tapped' })).toContain('a printed M3 thread strips'); + expect(screwProblem({ ...joint, to: [10, 10, 23] })).toContain('make it at least 4.6 mm thick, or use head none'); + expect(screwProblem({ ...joint, head: 'countersunk', size: 'M2' })).toBe('countersunk screws start at M3'); + const args = { kind: 'screw', fixed: 'Block', moving: 'Plate', from: [10, 10, 26], to: [10, 10, 20] }; + expect(screwOf(args)).toMatchObject({ size: 'M3', head: 'cap', thread: 'insert', more: [], screws: true, name: 'Screw' }); + expect(screwOf({ ...args, size: 'M7' })).toContain('size must be one of M2'); + expect(screwOf({ ...args, more: [[1, 2]] })).toBe('more must be a list of points, each three numbers'); + expect(describeToolCall('partbuddy_mechanism', { kind: 'screw', name: 'Lid screws' })).toEqual([{ key: 'screwJoint', values: { name: 'Lid screws' } }]); + }); + + it('is built by the tool with one call, which says what to buy', async () => { + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve([]), + callTool: (name, args) => { + if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Block' }, { name: 'Plate' }] }) }] }); + const commands = args.commands as { command: string }[]; + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results: commands.map((step) => (step.command === 'inspect.interference' ? { count: 0 } : step.command === 'component.list' ? { components: [] } : {})) }) }] }); + }, + }; + const built = await withMechanismTool(upstream, () => 0.4).callTool('partbuddy_mechanism', { kind: 'screw', fixed: 'Block', moving: 'Plate', from: [10, 10, 26], to: [10, 10, 20], more: [[50, 30, 26]], name: 'Plate screws' }); + expect(built.isError).toBeUndefined(); + const text = String(built.content[0]?.text); + expect(text).toContain('Built 2 screw joints "Plate screws" that hold Plate on Block: in Plate a clearance hole for M3 (ISO 273, normal) with a counterbore for the head, in Block the seat of a heat-set insert (4 mm wide, 6.7 mm deep).'); + expect(text).toContain('To buy: 2 × M3×8 socket head cap screws (ISO 4762) and 2 M3 heat-set inserts 5.7 mm long; the screws are in the design as standard parts.'); + }); +}); diff --git a/docs/feature-catalog.md b/docs/feature-catalog.md index 7abce405..865750c7 100644 --- a/docs/feature-catalog.md +++ b/docs/feature-catalog.md @@ -82,7 +82,7 @@ printed. SolidCraft's joints carry it ([ADR 0027](adr/0027-joints-for-motion.md) | --- | --- | | G1 Joints that move (done) | The assistant makes moving bodies into components and joins them with SolidCraft's joints (`component.from_bodies`, `joint.as_built`, `joint.limits`), told so in its instructions. The mirror reads `joint.list`; a tool "Moving parts" in the view toolbar, there once the design has a joint, shows one slider per joint value between its limits. Letting go drives the joint (`joint.drive`), a step in the work log, and the view shows where the parts stand; "As designed" drives every joint back to 0. | | G2 Collisions along the way (done) | The ready-to-print check runs every joint value from where it stands to its limits on a copy of the design (`joint.sweep`, a few milliseconds per joint). Parts that touch before a limit are a warning that names the joint, the two bodies, the value they meet at and the limit, and points at the place; a sweep that ends within 0.5° or 0.1 mm of the limit reached it (a flap folding flat onto its base). A snap fit is no stop either: parts that meet within 6° or 4 mm of the limit and are free of each other at the limit press past one another there (a lid's bead over the rim of its box); the limit's pose is tried and the joint put back where it stood. A joint left away from "as designed" is a warning too, since the design is exported as it stands. Both go to the assistant with the check, with what to do about them. | -| G3 Mechanisms the assistant applies (done: hinge, guide, closure) | Decided with the product owner on 2026-11-10: no gallery and no block at a face; the assistant uses them by itself when a design calls for one. `partbuddy_mechanism {kind: hinge, fixed, moving, from, to}` builds a print-in-place hinge between two bodies from SolidCraft's solids: knuckles in turn on both (3 to 9 by the length), a pin of half the barrel through them on the fixed body, the same gap everywhere (the nozzle in use, at least 0.3 mm), each knuckle clearing its room in the other body first. PartBuddy then makes the bodies components, joins them (`joint.as_built`), sweeps both ways and sets the limits a whole degree clear of where the bodies meet; bodies that still overlap are said in the answer. A piece that does not touch its body (SolidCraft's warning on the join) means the line is out of reach: everything is undone and the answer says where the line goes (practice run 2026-11-10: the real assistant took the tool by itself for a box with a hinged lid, but kept a gap between the hinge line and the walls and mended the loose knuckles with webs, whose warnings stayed in the timeline). A second run showed that what is built from the design level onto a body that is a component stays behind when the joint moves it (the lips of the lid hung in the air once it closed): the tool builds each step with its body's component active and asks for the joints at 0, the instructions tell the assistant to do the same for its own changes, and the check's note says that SolidCraft's "separate pieces" warning is real and how to mend it. One step in the work log. Since 2026-11-11, after a look at how printed hinges are usually made: lying (its line level), the bore gets a roof, two flanks at 45° and a short flat 1.2 radii above the axis, since the round top of a hole sags and only the gap is left above the pin; and the knuckles are at most 10 mm long where the line allows (3 to 9 of them), so the pin bridges no further inside each one. `kind: slide` builds a dovetail guide the same way: a rail on the fixed body that widens upwards at 45° (6 mm at its foot and 3 mm high unless said), its groove in the moving body a gap wider all round, joined as a slider whose limits keep the moving body on its rail (from its box, `inspect.measure`) and clear of contact. `kind: closure` (2026-11-11, after the product owner asked for standards the assistant cannot get wrong) builds the snap closure of a lid: a lip ring inside the lid, 1.2 mm thick and 4 mm high and 0.25 mm clear, on a foot with a 45° slope, a bead 0.5 mm proud with 45° flanks in the middle of one wall that snaps into a rille of the box's wall, and a notch for a fingernail; nothing stands out of the box and no wall is cut through. The lid is given by the corners of its inner room at the rim, the way the lip stands out (the lid lying open or sitting on the box) and, when it closes on a hinge, the hinge line, so the rille lands where the bead closes. It makes no joint. The hinged box template takes its closure from the same function (`lipSteps`). The sizes are those of printed snap lids in design guides, not yet confirmed by a print of ours. A pair of gears waits until SolidCraft makes teeth (`joint.gear` only couples two joints). | +| G3 Mechanisms the assistant applies (done: hinge, guide, closure, screw joint) | Decided with the product owner on 2026-11-10: no gallery and no block at a face; the assistant uses them by itself when a design calls for one. `partbuddy_mechanism {kind: hinge, fixed, moving, from, to}` builds a print-in-place hinge between two bodies from SolidCraft's solids: knuckles in turn on both (3 to 9 by the length), a pin of half the barrel through them on the fixed body, the same gap everywhere (the nozzle in use, at least 0.3 mm), each knuckle clearing its room in the other body first. PartBuddy then makes the bodies components, joins them (`joint.as_built`), sweeps both ways and sets the limits a whole degree clear of where the bodies meet; bodies that still overlap are said in the answer. A piece that does not touch its body (SolidCraft's warning on the join) means the line is out of reach: everything is undone and the answer says where the line goes (practice run 2026-11-10: the real assistant took the tool by itself for a box with a hinged lid, but kept a gap between the hinge line and the walls and mended the loose knuckles with webs, whose warnings stayed in the timeline). A second run showed that what is built from the design level onto a body that is a component stays behind when the joint moves it (the lips of the lid hung in the air once it closed): the tool builds each step with its body's component active and asks for the joints at 0, the instructions tell the assistant to do the same for its own changes, and the check's note says that SolidCraft's "separate pieces" warning is real and how to mend it. One step in the work log. Since 2026-11-11, after a look at how printed hinges are usually made: lying (its line level), the bore gets a roof, two flanks at 45° and a short flat 1.2 radii above the axis, since the round top of a hole sags and only the gap is left above the pin; and the knuckles are at most 10 mm long where the line allows (3 to 9 of them), so the pin bridges no further inside each one. `kind: slide` builds a dovetail guide the same way: a rail on the fixed body that widens upwards at 45° (6 mm at its foot and 3 mm high unless said), its groove in the moving body a gap wider all round, joined as a slider whose limits keep the moving body on its rail (from its box, `inspect.measure`) and clear of contact. `kind: closure` (2026-11-11, after the product owner asked for standards the assistant cannot get wrong) builds the snap closure of a lid: a lip ring inside the lid, 1.2 mm thick and 4 mm high and 0.25 mm clear, on a foot with a 45° slope, a bead 0.5 mm proud with 45° flanks in the middle of one wall that snaps into a rille of the box's wall, and a notch for a fingernail; nothing stands out of the box and no wall is cut through. The lid is given by the corners of its inner room at the rim, the way the lip stands out (the lid lying open or sitting on the box) and, when it closes on a hinge, the hinge line, so the rille lands where the bead closes. It makes no joint. The hinged box template takes its closure from the same function (`lipSteps`). The sizes are those of printed snap lids in design guides, not yet confirmed by a print of ours. `kind: screw` (2026-11-11, first of the standards the product owner chose) makes a screw joint between two bodies in one call: in the body held down a clearance hole (ISO 273, normal) with a counterbore or countersink for the head, drilled only as deep as that body is thick, and on the same line in the other body the seat of a heat-set insert (M2 to M6, a millimetre deeper than the insert) or a modelled thread (from M5); the screws go in as standard parts, each placed on its seat, and the answer says which screws and inserts to buy (the longest standard length that ends inside the insert). The sizes are those of the function blocks, now in `screwSizes.ts`. A pair of gears waits until SolidCraft makes teeth (`joint.gear` only couples two joints). | | G4 Moving while dragging (slider done) | While a joint's slider is dragged, its bodies follow in the view at once: the mirror gives each joint a point on its axis, the axis and the bodies it moves (its own and those joined on beyond it, from `joint.list` and `component.list`), and the view turns them about the axis, right-handed as SolidCraft drives the joint, or shifts them along it by the difference to the mirrored value. Letting go drives SolidCraft once; when the mirror shows the bodies there the move is none, taken at once so nothing swings back. A closed loop follows only when the slider is let go. Open: dragging at the part itself (`joint.reach`). | When convenient, between steps: Renovate, once its hosted GitHub app is installed for this