diff --git a/apps/desktop/e2e/solidcraft.contract.spec.ts b/apps/desktop/e2e/solidcraft.contract.spec.ts index 340f4e8f..14e7748e 100644 --- a/apps/desktop/e2e/solidcraft.contract.spec.ts +++ b/apps/desktop/e2e/solidcraft.contract.spec.ts @@ -2165,6 +2165,11 @@ test('a template from the start page becomes a project whose sizes are its param await expect(page.getByTestId('print-check-panel').locator('[data-kind="blocked"]')).toHaveCount(0); await page.screenshot({ path: test.info().outputPath('template-slideStop-check.png') }); await page.keyboard.press('Escape'); + // On the plate the stop lies on its top, as its print face says: taller than wide it would stand otherwise. + await page.getByRole('radio', { name: 'Plate' }).click(); + await page.waitForTimeout(1500); + await page.screenshot({ path: test.info().outputPath('template-slideStop-plate.png') }); + await page.getByRole('radio', { name: 'Assembled' }).click(); } if (id !== 'hingedBox') continue; // The hinge came joined and closes all the way: the bead of the closure pressing past the box's rim is a snap fit, no stop. @@ -3322,6 +3327,51 @@ test('the assistant builds a dovetail guide with one call: joined as a slider th } }); +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'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const work = mkdtempSync(join(tmpdir(), 'partbuddy-lid-')); + const env = { + SOLIDCRAFT_CONTROL_DIR: join(work, 'control'), + PARTBUDDY_SOLIDCRAFT_PORT: '7955', + 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 jar with a screw lid'); + await page.getByRole('button', { name: 'Send' }).click(); + await expect(page.getByText(/Built the screw lid "Jar lid" for Jar: a thread with 45° flanks on the neck, pitch 4 mm, 1\.2 mm deep, 1 start, and the same inside the lid, 0\.3 mm clear; it closes in 1\.75 turns/)).toBeVisible({ timeout: 90_000 }); + await expect(page.getByText(/Warning:/)).toHaveCount(0); + await expect(page.getByTestId('mesh-summary')).toContainText('2 bodies', { timeout: 30_000 }); + await expect(page.getByText('Screw lid “Jar lid” with its thread on the container')).toHaveCount(1); + await expect(page.locator('[data-testid="timeline-feature"][data-state="error"]')).toHaveCount(0); + // The two threads share no volume: they were built to mesh. + 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-lid-built.png') }); + await expect(async () => { + await page.keyboard.press('Escape'); + await expect(findings).toBeHidden({ timeout: 1000 }); + }).toPass({ timeout: 15_000 }); + // On the plate the lid lies on its top, the jar stands: two parts side by side. + await page.getByRole('radio', { name: 'Plate' }).click(); + await page.waitForTimeout(1500); + await page.screenshot({ path: test.info().outputPath('screw-lid-plate.png') }); + } finally { + await launched.close(); + rmSync(work, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 }); + } +}); + 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'); diff --git a/apps/desktop/src/main/agents/mechanismTool.ts b/apps/desktop/src/main/agents/mechanismTool.ts index a381f525..008b4523 100644 --- a/apps/desktop/src/main/agents/mechanismTool.ts +++ b/apps/desktop/src/main/agents/mechanismTool.ts @@ -1,4 +1,4 @@ -import { LIP, hingeProblem, hingeSteps, lipProblem, lipSteps, pinDiameter, screwLength, screwProblem, screwSteps, slideProblem, slideSteps, type HingeSpec, type LipSpec, type ScrewSpec, type SlideSpec } from '../../shared/mechanisms'; +import { LID, LIP, hingeProblem, hingeSteps, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, pinDiameter, screwLength, screwProblem, screwSteps, slideProblem, slideSteps, type HingeSpec, type LidSpec, 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'; @@ -27,15 +27,17 @@ const mechanismTool: UpstreamTool = { 'When the lid is built open beside the box and closes on a hinge, give the hinge line too (hinge_from, hinge_to), so the rille lands where the bead closes. It makes no joint. ' + 'screw: one or more screws that hold the moving body on the fixed one: a clearance hole with the room for the head in the moving body, and on the same line the seat of a heat-set insert (or a modelled thread from M5) in the fixed body; the screws go in as standard parts. ' + 'Give from where the screw goes in (on the outer face of the moving body) and to where its line passes into the fixed body (on the face the two meet at); more lists where further screws of the same kind go in. ' + - 'Use this instead of modelling a hinge, a guide, a snap lid or the holes of a screw joint yourself.', + 'lid: a screw lid for a round container. It coils a thread with 45° flanks onto the container\'s neck and makes the lid itself, with the matching thread, flutes to grip and its top as print face; the two mesh as built. ' + + 'Give the container as fixed, the name the lid shall have as moving (no such body yet), from as the centre of the rim at its height, and diameter as the neck\'s outer diameter; the neck must be a plain upright cylinder of that diameter below the rim (the answer says how far). ' + + 'Use this instead of modelling a hinge, a guide, a snap lid, a threaded lid or the holes of a screw joint yourself.', inputSchema: { type: 'object', properties: { - kind: { type: 'string', enum: ['hinge', 'slide', 'closure', 'screw'], 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.' }, + kind: { type: 'string', enum: ['hinge', 'slide', 'closure', 'screw', 'lid'], description: 'hinge: turns about a line; slide: moves along a line; closure: a lid snaps shut on its box; screw: one body screwed onto the other; lid: a screw lid is made for a round container.' }, 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.' }, - to: { ...vector, description: 'The other end of the line in mm. closure: the opposite corner, at the same height.' }, + from: { ...vector, description: 'One end of the line in mm. closure: one corner of the lid\'s inner room at its rim. lid: the centre of the container\'s rim.' }, + to: { ...vector, description: 'The other end of the line in mm. closure: the opposite corner, at the same height. lid: not needed.' }, up: { ...vector, description: 'slide: the normal of the face of the fixed body the rail stands on; default +Z. closure: the way the lip stands out of the lid, +Z or -Z; default +Z.' }, 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.' }, @@ -47,14 +49,15 @@ const mechanismTool: UpstreamTool = { 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.' }, + diameter: { type: 'number', minimum: 4, maximum: 200, description: 'hinge: outer diameter of the barrel in mm (4 to 40); default 6. The pin is half of it. lid: the outer diameter of the neck (16 to 200).' }, + starts: { type: 'integer', enum: [1, 2, 3], description: 'lid: threads side by side; default 1. With 2 or 3 the lid closes in less than a turn.' }, knuckles: { type: 'integer', enum: [3, 5, 7, 9], description: 'hinge: how many knuckles; default by the length of the line.' }, width: { type: 'number', minimum: 2, maximum: 60, description: 'slide: width of the rail at its foot in mm; default 6. It widens by its height on each side.' }, height: { type: 'number', minimum: 1.5, maximum: 20, description: 'slide: height of the rail in mm; default 3.' }, gap: { type: 'number', minimum: 0.2, maximum: 1, description: "The gap between the parts in mm; default the printer's nozzle, at least 0.3." }, name: { type: 'string', maxLength: 60, description: "The mechanism's name in the user's language, e.g. \"Lid hinge\"." }, }, - required: ['kind', 'fixed', 'moving', 'from', 'to'], + required: ['kind', 'fixed', 'moving', 'from'], additionalProperties: false, }, }; @@ -161,6 +164,25 @@ export function lipOf(args: Record): LipSpec | string { return lipProblem(spec) ?? spec; } +/** The screw lid the model asked for, with PartBuddy's defaults; or what is wrong with the request. */ +export function lidOf(args: Record, nozzle: number): LidSpec | string { + const stays = typeof args.fixed === 'string' ? args.fixed.trim() : ''; + const lid = typeof args.moving === 'string' ? args.moving.trim().slice(0, 60) : ''; + const centre = vec3(args.from); + if (!stays || !lid || !centre || typeof args.diameter !== 'number') return 'fixed (the container), moving (the name of the lid to make), from (the centre of the rim) and diameter (of the neck) are needed'; + const spec: LidSpec = { + fixed: stays, + moving: lid, + centre, + diameter: args.diameter, + starts: typeof args.starts === 'number' ? args.starts : 1, + // A nozzle's three quarters, at least 0.3 mm: lids of printed jars run from 0.2 to 0.6 mm, and a tight one binds on a wide neck. + gap: typeof args.gap === 'number' ? args.gap : Math.min(0.6, Math.max(0.3, Math.round(nozzle * 75) / 100)), + name: typeof args.name === 'string' && args.name.trim() !== '' ? args.name.trim().slice(0, 60) : 'Thread', + }; + return lidProblem(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'); @@ -214,6 +236,9 @@ function reach(sweep: unknown, to: number, step: number): number { } type Run = (commands: Step[]) => Promise; +/** Keeps the face a mechanism laid its part on, by the face's name, for the plate view. */ +type KeepFace = (body: string, face: string) => void; +const round2 = (value: number) => Math.round(value * 100) / 100; /** * The steps with each one's component made active first. Geometry built from the design level @@ -243,10 +268,12 @@ export function activated(steps: Step[], listing: unknown): Step[] { * hinge line and the walls, mended the loose knuckles with webs, and their warnings stayed in the * timeline), and `where` tells the model where the line goes. Volume the two still share is a warning. */ -async function build(upstream: UpstreamTools, run: Run, spec: { fixed: string; moving: string }, steps: Step[], where: string, callId: string | undefined): Promise { +async function build(upstream: UpstreamTools, run: Run, spec: { fixed: string; moving: string }, steps: Step[], where: string, callId: string | undefined, makes = false): Promise { const before = jsonOf(await upstream.callTool('inspect_design', {}, callId)) as { bodies?: { name?: unknown }[]; undo?: unknown }; const bodies = (before.bodies ?? []).map((body) => body.name); - const missing = [spec.fixed, spec.moving].filter((name) => !bodies.includes(name)); + // A mechanism that makes its moving body (a lid) needs the name free. + if (makes && bodies.includes(spec.moving)) throw new Error(`a body ${JSON.stringify(spec.moving)} exists already; give the lid another name, or delete that body first`); + const missing = (makes ? [spec.fixed] : [spec.fixed, spec.moving]).filter((name) => !bodies.includes(name)); if (missing.length > 0) throw new Error(`no body ${missing.map((name) => JSON.stringify(name)).join(' or ')}; the bodies are ${bodies.map((name) => JSON.stringify(name)).join(', ') || 'none'}`); // The line is given in the world as designed: with a joint away from 0 its bodies stand elsewhere. const [listing, joints] = await run([ @@ -308,7 +335,7 @@ const MOVE = 'The user moves it with the slider "Moving parts" in the view.'; const unswept = (reason: string) => `SolidCraft could not run it through its range (${reason.slice(0, 160)}), so no limits are set. The two bodies probably lie against each other somewhere: look with inspect.clearance, make room, then set joint.limits yourself after joint.sweep.`; -async function buildMechanism(upstream: UpstreamTools, args: Record, nozzle: number, callId: string | undefined): Promise { +async function buildMechanism(upstream: UpstreamTools, args: Record, nozzle: number, callId: string | undefined, keepFace: KeepFace): Promise { const run: Run = async (commands) => { const reply = jsonOf(await upstream.callTool('batch', { commands }, callId)) as { results?: unknown[]; failed?: { command?: unknown; error?: unknown } }; if (reply.failed) throw new Error(`SolidCraft refused ${String(reply.failed.command)}: ${String(reply.failed.error).slice(0, 200)}`); @@ -360,6 +387,24 @@ async function buildMechanism(upstream: UpstreamTools, args: Record []); + const face = (laid as { face?: unknown } | undefined)?.face; + if (typeof face === 'string') keepFace(spec.moving, face); + return ( + `Built the screw lid "${spec.moving}" for ${spec.fixed}: a thread with 45° flanks on the neck, pitch ${String(thread.pitch)} mm, ${String(thread.depth)} mm deep, ${String(spec.starts)} start${spec.starts > 1 ? 's' : ''}, ` + + `and the same inside the lid, ${String(spec.gap)} mm clear; it closes in ${String(round2(thread.turns))} turn${thread.turns > 1 ? 's' : ''} and stops with its inner top on the rim. ` + + `The lid is ${String(round2(spec.diameter + 2 * (thread.depth + spec.gap + LID.wall)))} mm wide and covers the top ${String(skirt)} mm of the neck, has flutes to grip and prints standing on its top (its print face is set); both parts print without support. ` + + `Keep the neck a plain cylinder of ${String(spec.diameter)} mm there, with a wall of at least 2 mm.${warnings}` + ); + } if (args.kind === 'screw') { const spec = screwOf(args); if (typeof spec === 'string') throw new Error(spec); @@ -376,18 +421,18 @@ async function buildMechanism(upstream: UpstreamTools, args: Record number): UpstreamTools { +export function withMechanismTool(upstream: UpstreamTools, nozzle: () => number, keepFace: KeepFace = () => undefined): UpstreamTools { return { instructions: () => upstream.instructions(), listTools: async () => [...(await upstream.listTools()), mechanismTool], callTool: async (name, args, callId): Promise => { if (name !== MECHANISM_TOOL) return upstream.callTool(name, args, callId); try { - return { content: [{ type: 'text', text: await buildMechanism(upstream, args, nozzle(), callId) }] }; + return { content: [{ type: 'text', text: await buildMechanism(upstream, args, nozzle(), callId, keepFace) }] }; } catch (cause) { return { content: [{ type: 'text', text: `Not built: ${(cause as Error).message}` }], isError: true }; } @@ -396,4 +441,4 @@ export function withMechanismTool(upstream: UpstreamTools, nozzle: () => number) } /** Added to the role prompt of every route, after the joint guidance. */ -export const MECHANISM_GUIDANCE = `For a hinge (a lid that folds, a flap, a clamp that opens) or a guide (a drawer, a slider, a carriage on a rail) call ${MECHANISM_TOOL} once both bodies exist instead of modelling knuckles, a pin or a rail yourself: it builds the print-in-place geometry with the right gaps, joins the bodies and sets the limits. For a lid that should hold shut by itself (a box, a case, a container) call it with kind closure instead of modelling a latch, a hook or a clip: a lip with a snap bead inside the lid, nothing standing out and no wall cut through. For parts that are screwed together (a lid on a housing, a bracket on a base, a cover) call it with kind screw instead of drilling the holes yourself: it sizes the clearance hole, the room for the head and the seat of the heat-set insert, and says which screws to buy.`; +export const MECHANISM_GUIDANCE = `For a hinge (a lid that folds, a flap, a clamp that opens) or a guide (a drawer, a slider, a carriage on a rail) call ${MECHANISM_TOOL} once both bodies exist instead of modelling knuckles, a pin or a rail yourself: it builds the print-in-place geometry with the right gaps, joins the bodies and sets the limits. For a lid that should hold shut by itself (a box, a case, a container) call it with kind closure instead of modelling a latch, a hook or a clip: a lip with a snap bead inside the lid, nothing standing out and no wall cut through. For parts that are screwed together (a lid on a housing, a bracket on a base, a cover) call it with kind screw instead of drilling the holes yourself: it sizes the clearance hole, the room for the head and the seat of the heat-set insert, and says which screws to buy. For a round container that should screw shut (a jar, a canister, a bottle cap) build the container with a plain cylindrical neck and call it with kind lid: it threads the neck and makes the lid, instead of modelling a thread yourself.`; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 3d761c95..921f7d6b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -55,7 +55,7 @@ import { safeDesignFileName } from './solidcraft/headless'; import { longPath, SolidCraftSupervisor } from './solidcraft/supervisor'; import { ChatArchive } from './chat/chatArchive'; import { printCheckNote } from './print/printCheckNote'; -import { templateSteps } from '../shared/templates'; +import { templatePrintFaces, templateSteps } from '../shared/templates'; import { testPieceSteps } from '../shared/testPieces'; import { sketchNote } from './agents/sketchNote'; import { photoFile, photoNote } from './agents/photoNote'; @@ -567,6 +567,13 @@ if (!app.requestSingleInstanceLock()) { log(`kept the assembly order: ${String(plan.steps.length)} steps`); bridge.emit(mainWindow?.webContents ?? null, eventChannels.assemblyChanged, plan); }; + // A mechanism that lays its part down (a screw lid on its top) says so here, so the plate view turns it as the slicer will. + const keepMechanismFace = (body: string, face: string) => { + const folder = assemblyFolder(); + if (!folder) return; + keepPrintFace(folder, body, face); + bridge.emit(mainWindow?.webContents ?? null, eventChannels.printFacesChanged, readPrintFaces(folder)); + }; // SVG drawings sent along; the model names them and PartBuddy puts the exact contours in (drawingTool.ts). const drawings = new Drawings(); // The model reads notes only through PartBuddy; results lose the note data, undo may not take a note (ADR 0020). @@ -575,7 +582,7 @@ if (!app.requestSingleInstanceLock()) { steps.tools( withDesignTool( withAssemblyTool( - withViewTool(guardNotes(withMechanismTool(withDrawings(upstream, drawings), () => printProfiles.part().printer.nozzle), (trial) => supervisor.trialSteps(trial)), () => viewState, () => notesService.forView(), checkForView), + withViewTool(guardNotes(withMechanismTool(withDrawings(upstream, drawings), () => printProfiles.part().printer.nozzle, keepMechanismFace), (trial) => supervisor.trialSteps(trial)), () => viewState, () => notesService.forView(), checkForView), () => viewState?.bodies.map((body) => body.name) ?? [], saveAssembly, ), @@ -1180,6 +1187,15 @@ if (!app.requestSingleInstanceLock()) { throw error; } await switchProject(() => Promise.resolve(target)); + // A part the template lays down for printing (the stop on its top) lies so on the plate as well. + for (const { body, point } of templatePrintFaces(template, german)) { + const face = await supervisor.printFaceName(body, point).catch((error: unknown) => { + log(`print face of ${body} not read: ${(error as Error).message}`); + return null; + }); + if (face) keepMechanismFace(body, face); + else log(`the template's print face of ${body} is not kept for the plate view`); + } log(`made a new project from the template ${template}`); return { opened: true, imported: false }; }); diff --git a/apps/desktop/src/main/projects/assembly.ts b/apps/desktop/src/main/projects/assembly.ts index 4c606d75..17d254f6 100644 --- a/apps/desktop/src/main/projects/assembly.ts +++ b/apps/desktop/src/main/projects/assembly.ts @@ -39,8 +39,9 @@ export function writeAssembly(folder: string, plan: AssemblyPlan): void { /** * Print faces as PartBuddy set them (B8), by body, kept as `print-faces.json` so the plate view can * lay each part on its face; SolidCraft keeps the face itself but does not report it back. - * shortcut: a print face the assistant sets through SolidCraft is not seen here; the part then lies - * as designed. Upgrade when SolidCraft reports print faces. + * A mechanism (a screw lid) and a template say theirs when they are built. + * shortcut: a print face the assistant sets itself with body.print_face is not seen here; the part + * then lies as designed. Upgrade when SolidCraft reports print faces. */ export const PRINT_FACES_FILE = 'print-faces.json'; diff --git a/apps/desktop/src/main/solidcraft/supervisor.ts b/apps/desktop/src/main/solidcraft/supervisor.ts index 190b4370..bc71797d 100644 --- a/apps/desktop/src/main/solidcraft/supervisor.ts +++ b/apps/desktop/src/main/solidcraft/supervisor.ts @@ -468,6 +468,20 @@ export class SolidCraftSupervisor { return found.name; } + /** + * The name of the face a body's print face lies on: SolidCraft keeps print faces but does not list them, so the face + * is marked once more and the answer read. For print faces a script set (a template), which the plate view needs too. + */ + async printFaceName(body: string, point: [number, number, number]): Promise { + await this.probing; + // Right after a project switch the engine is still connecting: wait for it, five seconds at most. + for (let tries = 0; tries < 20 && this.actor.getSnapshot().value !== 'connected'; tries += 1) await new Promise((resolve) => setTimeout(resolve, 250)); + const reply = await this.requester().request('engine.batch', { commands: [{ command: 'body.print_face', params: { body, point } }], rollback: false }, 60_000); + if (reply.failed) throw new Error(reply.failed.error); + const face = (reply.results[0] as { face?: unknown } | undefined)?.face; + return typeof face === 'string' ? face : null; + } + /** Marks or clears the body's print face (B8, `body.print_face`), which the slicer export uses. */ setPrintFace(body: string, point: [number, number, number] | null): Promise { return this.run({ command: 'body.print_face', params: { body, ...(point ? { point } : {}) } }); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 1e781ff7..42101794 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -98,6 +98,7 @@ const api: PartBuddyApi = { sendToSlicer: (slicer) => ipcRenderer.invoke(invokeChannels.designSendToSlicer, { slicer }), setPrintFace: (body, point, face) => ipcRenderer.invoke(invokeChannels.designPrintFace, { body, point, ...(face ? { face } : {}) }), printFaces: () => ipcRenderer.invoke(invokeChannels.designPrintFaces), + onPrintFaces: (listener) => subscribe(eventChannels.printFacesChanged, listener), insertPart: (part) => ipcRenderer.invoke(invokeChannels.designInsertPart, part), measure: (items) => ipcRenderer.invoke(invokeChannels.designMeasure, { items }), clearance: (bodies, gap) => ipcRenderer.invoke(invokeChannels.designClearance, { bodies, gap }), diff --git a/apps/desktop/src/renderer/src/viewer/motion.tsx b/apps/desktop/src/renderer/src/viewer/motion.tsx index 8206b8c7..1d6ac3d1 100644 --- a/apps/desktop/src/renderer/src/viewer/motion.tsx +++ b/apps/desktop/src/renderer/src/viewer/motion.tsx @@ -104,6 +104,13 @@ export function useProjectViewData(): void { }), [], ); + useEffect( + () => + platform().design.onPrintFaces((faces) => { + useStudio.getState().setPrintFaces(faces); + }), + [], + ); } /** How each body is turned to lie on its print face (or as designed), with its footprint and height that way. */ diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts index 3b074526..b0e4e824 100644 --- a/apps/desktop/src/shared/api.ts +++ b/apps/desktop/src/shared/api.ts @@ -218,6 +218,8 @@ export interface PartBuddyApi { setPrintFace(body: string, point: [number, number, number] | null, face?: string): Promise; /** The print faces PartBuddy set in the open project, by body (for the plate view). */ printFaces(): Promise>; + /** The print faces changed without the window doing it: a mechanism of the assistant laid its part down. */ + onPrintFaces(listener: (faces: Record) => void): () => void; /** Builds a function block (F1) on trial for the ghost preview. */ previewBlock(block: FeatureBlock): Promise; /** Builds a function block for real; one step of the work log. */ diff --git a/apps/desktop/src/shared/channels.ts b/apps/desktop/src/shared/channels.ts index 8545528b..b36e37d6 100644 --- a/apps/desktop/src/shared/channels.ts +++ b/apps/desktop/src/shared/channels.ts @@ -129,6 +129,7 @@ export const eventChannels = { notesChanged: 'notes:changed', printChanged: 'print:changed', assemblyChanged: 'assembly:changed', + printFacesChanged: 'design:print-faces-changed', } as const; export type InvokeChannel = (typeof invokeChannels)[keyof typeof invokeChannels]; diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index 97a3d4c6..c73ec653 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -1291,6 +1291,7 @@ export const eventContract = { [eventChannels.notesChanged]: notesStateSchema, [eventChannels.printChanged]: partPrintSchema, [eventChannels.assemblyChanged]: assemblyPlanSchema.nullable(), + [eventChannels.printFacesChanged]: printFacesSchema, } as const satisfies Record; export type FaceTableMessage = z.infer; diff --git a/apps/desktop/src/shared/mechanisms.ts b/apps/desktop/src/shared/mechanisms.ts index 28554e24..ea5e613a 100644 --- a/apps/desktop/src/shared/mechanisms.ts +++ b/apps/desktop/src/shared/mechanisms.ts @@ -447,3 +447,94 @@ function headRoom(spec: ScrewSpec): Record { return {}; } + +export interface LidSpec { + /** The container: its round neck gets the thread. */ + fixed: string; + /** The lid, which is made: no body of that name may exist. */ + moving: string; + /** The centre of the container's rim, at the rim's height; the neck stands upright (along Z). */ + centre: Vec3; + /** The neck's outer diameter at the rim, in mm. */ + diameter: number; + /** How many threads run side by side: 1, or 2 to 3 for a lid that closes in less than a turn. */ + starts: number; + /** The room between the two threads, radially; all of it on the lid. */ + gap: number; + name: string; +} + +/** + * The thread of a printed screw lid, after the neck finishes of bottles and the usual printed jars: flat crests + * and roots with flanks at 45°, which print without support on the neck standing and in the lid lying on its top; + * pitch and depth by the neck's diameter; the threads engage over 1.75 turns of one start (at least three quarters + * of a turn each with several). + */ +export function lidThread(spec: Pick): { pitch: number; depth: number; lead: number; turns: number; length: number; crest: number; foot: number } { + const [pitch, depth] = spec.diameter <= 24 ? [3, 1.1] : spec.diameter <= 77 ? [4, 1.2] : [5, 1.5]; + const lead = pitch * spec.starts; + const turns = Math.max(0.75, 1.75 / spec.starts); + return { pitch, depth, lead, turns, length: round(turns * lead), crest: round(pitch / 2 - depth), foot: round(pitch / 2 + depth) }; +} + +/** Plain neck above the thread, the lid's wall and top, and the room below the lid's thread, in mm. */ +export const LID = { plain: 1, wall: 2.4, top: 2.4, mouth: 1, flute: 3 }; + +/** How far below the rim the neck must be a plain cylinder of the given diameter: the lid's skirt covers it. */ +export function lidSkirt(spec: Pick): number { + const thread = lidThread(spec); + return round(LID.plain + thread.foot / 2 + thread.length + thread.pitch / 2 + thread.foot / 2 + LID.mouth); +} + +/** What is wrong with a screw lid before anything is built; null when it can be built. */ +export function lidProblem(spec: LidSpec): string | null { + if (spec.fixed === spec.moving) return 'moving is the name of the lid to make, not the container'; + if (!(spec.diameter >= 16 && spec.diameter <= 200)) return 'diameter must be 16–200 mm: the outer diameter of the neck'; + if (!Number.isInteger(spec.starts) || spec.starts < 1 || spec.starts > 3) return 'starts must be 1, 2 or 3'; + if (!(spec.gap >= 0.2 && spec.gap <= 0.6)) return 'gap must be 0.2–0.6 mm'; + return null; +} + +/** + * The solids of a screw lid on a round neck: the thread coiled onto the neck, and a lid made for it, a cap whose + * inner top lands on the rim, with the same thread inside, half a pitch lower and the gap further out, so the two + * mesh as they are built; flutes round the lid for the fingers; the lid's print face is its top. + */ +export function lidSteps(spec: LidSpec): Step[] { + const thread = lidThread(spec); + const [cx, cy, rim] = spec.centre.map(round) as Vec3; + const radius = spec.diameter / 2; + const mm = (value: number) => `${String(round(value))} mm`; + // The middle of the neck's thread at its lower end, the lid's half a pitch below, and the lid's mouth. + const start = rim - LID.plain - thread.foot / 2 - thread.length; + const lidStart = start - thread.pitch / 2; + const mouth = rim - lidSkirt(spec); + const bore = radius + thread.depth + spec.gap; + const outer = bore + LID.wall; + // The section reaches 0.2 mm into its body, along its flanks, but never to the next turn. + const into = Math.min(thread.foot / 2 + 0.2, thread.pitch / 2 - 0.05); + const coil = (label: string, target: string, z: number, foot: number, crest: number, k: number): Step[] => { + const angle = (2 * Math.PI * k) / spec.starts; + const name = `${label} ${String(k + 1)}`; + return [ + // The section's plane holds the axis, turned by the start: both threads begin at the same angles. + { command: 'sketch.create', params: { plane: { origin: [cx, cy, round(z)], x_dir: [round(Math.cos(angle)), round(Math.sin(angle)), 0], y_dir: [0, 0, 1] }, project_edges: false, name } }, + { command: 'sketch.line', params: { points: [[foot, -into], [crest, -thread.crest / 2], [crest, thread.crest / 2], [foot, into]].map(([x, y]) => [round(x ?? 0), round(y ?? 0)]), closed: true } }, + { command: 'solid.coil', params: { sketch: name, pitch: mm(thread.lead), height: mm(thread.length), base: [cx, cy, round(z)], axis: 'Z', operation: 'join', targets: [target], name } }, + ]; + }; + const flutes = Math.max(8, Math.round((2 * Math.PI * outer) / 7)); + return [ + ...Array.from({ length: spec.starts }, (_, k) => coil(spec.name, spec.fixed, start, radius - 0.2, radius + thread.depth, k)).flat(), + // The lid's top edge lies on the bed when it prints, so its last 0.6 mm are a cone, not a chamfer: SolidCraft + // 0.40.0-rc.4 refuses to chamfer the top edge of a cylinder from about 60 mm of diameter. + { command: 'solid.cylinder', params: { diameter: mm(2 * outer), height: mm(rim + LID.top - 0.6 - mouth), base: [cx, cy, round(mouth)], operation: 'new', body_name: spec.moving, name: spec.moving } }, + { command: 'solid.cone', params: { diameter: mm(2 * outer), top_diameter: mm(2 * outer - 1.2), height: '0.6 mm', base: [cx, cy, round(rim + LID.top - 0.6)], operation: 'join', targets: [spec.moving], name: `${spec.name} ◢` } }, + { command: 'solid.cylinder', params: { diameter: mm(2 * bore), height: mm(rim - mouth), base: [cx, cy, round(mouth)], operation: 'cut', targets: [spec.moving], name: `${spec.name} ○` } }, + ...Array.from({ length: spec.starts }, (_, k) => coil(`${spec.name} ·`, spec.moving, lidStart, bore + 0.2, radius + spec.gap, k)).flat(), + // Flutes for the fingers, ending below the top so its edge stays whole for the bed. + { command: 'solid.cylinder', params: { diameter: mm(LID.flute), height: mm(rim - mouth), base: [round(cx + outer + LID.flute / 4), cy, round(mouth)], operation: 'cut', targets: [spec.moving], name: `${spec.name} ◆` } }, + { command: 'solid.pattern.circular', params: { features: [`${spec.name} ◆`], axis: { origin: [cx, cy, 0], dir: [0, 0, 1] }, count: flutes } }, + { command: 'body.print_face', params: { body: spec.moving, point: [cx, cy, round(rim + LID.top)] } }, + ]; +} diff --git a/apps/desktop/src/shared/stepPhrases.ts b/apps/desktop/src/shared/stepPhrases.ts index af7389d0..82f9b73f 100644 --- a/apps/desktop/src/shared/stepPhrases.ts +++ b/apps/desktop/src/shared/stepPhrases.ts @@ -77,6 +77,7 @@ export const STEP_PHRASE_KEYS = [ 'guide', 'closure', 'screwJoint', + 'screwLid', 'command', ] as const; @@ -298,6 +299,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 === 'lid') return [{ key: 'screwLid', values: { name: text(args.moving, 'Lid').slice(0, 60) } }]; if (tool === 'partbuddy_mechanism' && args.kind === 'screw') return [{ key: 'screwJoint', values: { name: text(args.name, 'Screw').slice(0, 60) } }]; if (tool === 'partbuddy_mechanism' && args.kind === 'closure') return [{ key: 'closure', values: { name: text(args.name, 'Closure').slice(0, 60) } }]; 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) } }]; @@ -443,6 +445,7 @@ export const stepMessages: Record<'en' | 'de', Record> = guide: 'Guide “{name}” between two parts, with its joint', closure: 'Snap closure “{name}” between lid and box', screwJoint: 'Screw joint “{name}” between two parts', + screwLid: 'Screw lid “{name}” with its thread on the container', command: 'SolidCraft: {command}', }, de: { @@ -508,6 +511,7 @@ export const stepMessages: Record<'en' | 'de', Record> = guide: 'Führung „{name}“ zwischen zwei Teilen, mit Gelenk', closure: 'Rastverschluss „{name}“ zwischen Deckel und Dose', screwJoint: 'Schraubverbindung „{name}“ zwischen zwei Teilen', + screwLid: 'Schraubdeckel „{name}“ mit Gewinde am Behälter', command: 'SolidCraft: {command}', }, }; diff --git a/apps/desktop/src/shared/templates.ts b/apps/desktop/src/shared/templates.ts index 66b145ea..bcb72fa5 100644 --- a/apps/desktop/src/shared/templates.ts +++ b/apps/desktop/src/shared/templates.ts @@ -416,3 +416,8 @@ export function templateSteps(id: TemplateId, german = false): Step[] { const steps = BUILDERS[id](); return german ? steps.map(inGerman) : steps; } + +/** The print faces a template's script sets, by body and a point on the face: PartBuddy keeps them for its plate view as well. */ +export function templatePrintFaces(id: TemplateId, german = false): { body: string; point: [number, number, number] }[] { + return templateSteps(id, german).flatMap((step) => (step.command === 'body.print_face' ? [{ body: step.params.body as string, point: step.params.point as [number, number, number] }] : [])); +} diff --git a/apps/desktop/test/fakeClaude.mjs b/apps/desktop/test/fakeClaude.mjs index 2b840cb8..3c41fb24 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 jar with a screw lid": a hollow round jar, then PartBuddy's own screw lid on its neck. + if (content.includes('a jar with a screw lid')) { + await client.callTool({ name: 'execute', arguments: { command: 'solid.cylinder', params: { diameter: 60, height: 50, operation: 'new', body_name: 'Jar' } } }); + await client.callTool({ name: 'execute', arguments: { command: 'solid.cylinder', params: { diameter: 55.2, height: 47.6, base: [0, 0, 2.4], operation: 'cut', targets: ['Jar'] } } }); + const built = await client.callTool({ name: 'partbuddy_mechanism', arguments: { kind: 'lid', fixed: 'Jar', moving: 'Jar lid', from: [0, 0, 50], diameter: 60 } }); + 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 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' } } }); diff --git a/apps/desktop/test/mechanism.test.ts b/apps/desktop/test/mechanism.test.ts index ca72738f..abb2ed49 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, screwOf, slideOf, withMechanismTool } from '../src/main/agents/mechanismTool'; +import { activated, hingeOf, lidOf, 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, screwLength, screwProblem, screwSteps, slideProblem, slideSteps, type HingeSpec, type LipSpec, type ScrewSpec, type SlideSpec } from '../src/shared/mechanisms'; +import { hingeProblem, hingeSteps, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, screwLength, screwProblem, screwSteps, slideProblem, slideSteps, type HingeSpec, type LidSpec, type LipSpec, type ScrewSpec, type SlideSpec } from '../src/shared/mechanisms'; const spec: HingeSpec = { fixed: 'Base', moving: 'Flap', from: [42, 0, 3], to: [42, 30, 3], diameter: 6, gap: 0.4, knuckles: 3, name: 'Hinge' }; @@ -393,3 +393,85 @@ describe('the screw joint (standards)', () => { 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.'); }); }); + +describe('the screw lid (standards)', () => { + // A jar 60 mm wide whose rim is at 50 mm, centred at (10, 20). + const lid: LidSpec = { fixed: 'Jar', moving: 'Lid', centre: [10, 20, 50], diameter: 60, starts: 1, gap: 0.3, name: 'Thread' }; + + it('sizes the thread by the neck: pitch and depth from the diameter, 1.75 turns, three quarters of a turn per start at least', () => { + expect(lidThread({ diameter: 20, starts: 1 })).toMatchObject({ pitch: 3, depth: 1.1, lead: 3, turns: 1.75, length: 5.25 }); + // Flat crest and root with flanks at 45°: the crest is half a pitch less the depth, the foot half a pitch more. + expect(lidThread({ diameter: 60, starts: 1 })).toEqual({ pitch: 4, depth: 1.2, lead: 4, turns: 1.75, length: 7, crest: 0.8, foot: 3.2 }); + expect(lidThread({ diameter: 100, starts: 2 })).toMatchObject({ pitch: 5, depth: 1.5, lead: 10, turns: 0.875 }); + expect(lidThread({ diameter: 40, starts: 3 })).toMatchObject({ lead: 12, turns: 0.75, length: 9 }); + // Plain neck, thread, half a pitch for the lid's thread and its mouth: what the lid covers of the neck. + expect(lidSkirt({ diameter: 60, starts: 1 })).toBe(14.2); + }); + + it('coils the thread onto the neck, makes the lid with the same thread half a pitch lower and the gap further out, and lays the lid on its top', () => { + const steps = lidSteps(lid); + expect(steps.map((step) => [step.command, step.params.name ?? ''])).toEqual([ + ['sketch.create', 'Thread 1'], + ['sketch.line', ''], + ['solid.coil', 'Thread 1'], + ['solid.cylinder', 'Lid'], + ['solid.cone', 'Thread ◢'], + ['solid.cylinder', 'Thread ○'], + ['sketch.create', 'Thread · 1'], + ['sketch.line', ''], + ['solid.coil', 'Thread · 1'], + ['solid.cylinder', 'Thread ◆'], + ['solid.pattern.circular', ''], + ['body.print_face', ''], + ]); + // The neck's thread: from 30 mm of radius to 31.2 mm, its middle 9.6 mm below the rim at the start, 7 mm of coil. + expect(steps[0]?.params.plane).toEqual({ origin: [10, 20, 40.4], x_dir: [1, 0, 0], y_dir: [0, 0, 1] }); + expect(steps[1]?.params.points).toEqual([[29.8, -1.8], [31.2, -0.4], [31.2, 0.4], [29.8, 1.8]]); + expect(steps[2]?.params).toMatchObject({ pitch: '4 mm', height: '7 mm', base: [10, 20, 40.4], operation: 'join', targets: ['Jar'] }); + // The lid: its bore 0.3 mm clear of the neck's crests, its thread 2 mm lower and reaching to 0.3 mm from the neck. + expect(steps[3]?.params).toMatchObject({ diameter: '67.8 mm', base: [10, 20, 35.8], operation: 'new', body_name: 'Lid' }); + expect(steps[5]?.params).toMatchObject({ diameter: '63 mm', height: '14.2 mm', operation: 'cut', targets: ['Lid'] }); + expect(steps[6]?.params.plane).toMatchObject({ origin: [10, 20, 38.4] }); + expect(steps[7]?.params.points).toEqual([[31.7, -1.8], [30.3, -0.4], [30.3, 0.4], [31.7, 1.8]]); + expect(steps[10]?.params).toMatchObject({ features: ['Thread ◆'], count: 30 }); + expect(steps[11]?.params).toEqual({ body: 'Lid', point: [10, 20, 52.4] }); + // Two starts: each thread twice, the second half a turn on. + const two = lidSteps({ ...lid, starts: 2 }); + expect(two.filter((step) => step.command === 'solid.coil').map((step) => step.params.name)).toEqual(['Thread 1', 'Thread 2', 'Thread · 1', 'Thread · 2']); + expect(two[3]?.params.plane).toMatchObject({ x_dir: [-1, 0, 0] }); + // A narrow neck: the section stops short of the next turn. + expect(lidSteps({ ...lid, diameter: 20 })[1]?.params.points).toEqual([[9.8, -1.45], [11.1, -0.2], [11.1, 0.2], [9.8, 1.45]]); + }); + + it('says what is wrong before anything is built, and takes its defaults from the nozzle', () => { + expect(lidProblem(lid)).toBeNull(); + expect(lidProblem({ ...lid, moving: 'Jar' })).toContain('moving is the name of the lid to make'); + expect(lidProblem({ ...lid, diameter: 10 })).toContain('diameter must be 16–200 mm'); + expect(lidProblem({ ...lid, starts: 4 })).toBe('starts must be 1, 2 or 3'); + const args = { kind: 'lid', fixed: 'Jar', moving: 'Lid', from: [10, 20, 50], diameter: 60 }; + expect(lidOf(args, 0.4)).toMatchObject({ centre: [10, 20, 50], starts: 1, gap: 0.3, name: 'Thread' }); + expect(lidOf(args, 0.6)).toMatchObject({ gap: 0.45 }); + expect(lidOf({ ...args, diameter: undefined }, 0.4)).toContain('are needed'); + expect(describeToolCall('partbuddy_mechanism', { kind: 'lid', moving: 'Jar lid' })).toEqual([{ key: 'screwLid', values: { name: 'Jar lid' } }]); + }); + + it('is built by the tool with one call that makes the lid, and refuses a name that is taken', async () => { + const upstream = (bodies: string[]): UpstreamTools => ({ + instructions: () => undefined, + listTools: () => Promise.resolve([]), + callTool: (name, args) => { + if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: bodies.map((body) => ({ name: body })) }) }] }); + 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 args = { kind: 'lid', fixed: 'Jar', moving: 'Lid', from: [10, 20, 50], diameter: 60 }; + const built = await withMechanismTool(upstream(['Jar']), () => 0.4).callTool('partbuddy_mechanism', args); + expect(built.isError).toBeUndefined(); + const text = String(built.content[0]?.text); + expect(text).toContain('Built the screw lid "Lid" for Jar: a thread with 45° flanks on the neck, pitch 4 mm, 1.2 mm deep, 1 start, and the same inside the lid, 0.3 mm clear; it closes in 1.75 turns and stops with its inner top on the rim.'); + expect(text).toContain('The lid is 67.8 mm wide and covers the top 14.2 mm of the neck'); + const taken = await withMechanismTool(upstream(['Jar', 'Lid']), () => 0.4).callTool('partbuddy_mechanism', args); + expect(taken).toMatchObject({ isError: true, content: [{ text: 'Not built: a body "Lid" exists already; give the lid another name, or delete that body first' }] }); + }); +}); diff --git a/apps/desktop/test/templates.test.ts b/apps/desktop/test/templates.test.ts index 95b7f73a..cc253d1f 100644 --- a/apps/desktop/test/templates.test.ts +++ b/apps/desktop/test/templates.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { templateRequestSchema } from '../src/shared/contract'; -import { TEMPLATE_IDS, germanFeatureName, templateSteps } from '../src/shared/templates'; +import { TEMPLATE_IDS, germanFeatureName, templatePrintFaces, templateSteps } from '../src/shared/templates'; /** The geometry fields whose text is an expression over the design's parameters. */ const EXPRESSION_FIELDS = new Set(['length', 'width', 'height', 'diameter', 'thickness', 'depth', 'corner', 'base', 'position', 'center', 'points', 'point', 'distance', 'cs_diameter', 'radius', 'text_expression', 'offset']); @@ -122,6 +122,10 @@ describe('templates with moving parts', () => { const slider = templateSteps('slideStop', german).find((step) => step.command === 'joint.as_built')?.params as { a: string; b: string; at: { occurrence: string }; name: string }; expect([slider.a, slider.b, slider.at.occurrence, slider.name]).toEqual([base, stop, base, guide]); } + // The stop prints on its top; PartBuddy keeps that face for its plate view, under the body's name in the user's language. + expect(templatePrintFaces('slideStop')).toEqual([{ body: 'Stop', point: [60, 8, 26.4] }]); + expect(templatePrintFaces('slideStop', true)).toEqual([{ body: 'Anschlag', point: [60, 8, 26.4] }]); + expect(templatePrintFaces('sign')).toEqual([]); // The rail and its groove are as long as the base. expect(templateSteps('slideStop').filter((step) => step.command === 'solid.extrude').map((step) => step.params.distance)).toEqual(['length', 'length']); }); diff --git a/docs/feature-catalog.md b/docs/feature-catalog.md index 865750c7..df5395d3 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, 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). | +| G3 Mechanisms the assistant applies (done: hinge, guide, closure, screw joint, screw lid) | 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`. `kind: lid` makes a screw lid for a round container: a thread with flat crests and 45° flanks coiled onto the neck (`solid.coil` over a drawn section; pitch 3, 4 or 5 mm and depth 1.1 to 1.5 mm by the diameter, after the neck finishes of bottles and printed jars; 1.75 turns of engagement; 1 to 3 starts) and the lid itself, a cap that stops with its inner top on the rim, with the same thread half a pitch lower and the gap (0.3 mm at a 0.4 mm nozzle) further out, so the two mesh as built, flutes to grip and its top as print face. Both print without support. SolidCraft's own `solid.thread` was not used: its trapezoidal form has 15° flanks, which overhang, and the phase of two threads cannot be set. 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