diff --git a/apps/desktop/e2e/app.spec.ts b/apps/desktop/e2e/app.spec.ts index 31a797c6..2622e8a6 100644 --- a/apps/desktop/e2e/app.spec.ts +++ b/apps/desktop/e2e/app.spec.ts @@ -1407,7 +1407,7 @@ test('the part material is chosen in the status bar and the profiles are edited await supportFree.press('Space'); await expect(supportFree).not.toBeChecked(); // The design rule groups (2026-11-07) are on from the start; each turns off on its own. - for (const rule of ['rounding', 'printInPlace', 'details', 'snap']) await expect(assistant.getByTestId(`design-${rule}`).getByRole('switch')).toBeChecked(); + for (const rule of ['rounding', 'printInPlace', 'details', 'snap', 'economy']) await expect(assistant.getByTestId(`design-${rule}`).getByRole('switch')).toBeChecked(); const rounding = assistant.getByTestId('design-rounding').getByRole('switch', { name: 'Fillets and chamfers' }); await rounding.press('Space'); await expect(rounding).not.toBeChecked(); diff --git a/apps/desktop/e2e/solidcraft.contract.spec.ts b/apps/desktop/e2e/solidcraft.contract.spec.ts index f694bb22..f69f2241 100644 --- a/apps/desktop/e2e/solidcraft.contract.spec.ts +++ b/apps/desktop/e2e/solidcraft.contract.spec.ts @@ -1,10 +1,18 @@ import AxeBuilder from '@axe-core/playwright'; -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; import { execFileSync } from 'node:child_process'; import { launchApp } from './launch'; +/** + * The assistant's turn is over. The user's own actions are refused while it works, and since PR 357 its tool's + * answer waits for the print check of what it built: the part shows before the turn ends. + */ +async function turnEnded(page: Page): Promise { + await expect(page.getByTestId('composer-stop')).toBeHidden({ timeout: 30_000 }); +} + /** A renderer element in evaluate callbacks, typed loosely because this file is compiled for Node. */ interface LooseRect { left: number; @@ -91,6 +99,7 @@ test('an assistant step changes the headless design, and it is back after a rest await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await expect(page.getByTestId('assistant-text').last()).toHaveText('The design has one body.'); await page.screenshot({ path: test.info().outputPath('headless-box.png') }); // The conversation is written next to the design a second after the chat went quiet. @@ -156,6 +165,7 @@ test('the user undoes, exports and opens the headless design in the SolidCraft w await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); // Undo and redo in the real engine. await page.getByRole('button', { name: 'Undo' }).click(); @@ -222,6 +232,7 @@ test('the dimension field changes the driving parameter directly, with a preview await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); // The first save names the new part after its project (B2) and mirrors it once more; picks wait for that. await expect(page.getByTestId('design-name')).toHaveText('Part', { timeout: 15_000 }); await page.waitForTimeout(800); @@ -352,6 +363,7 @@ test('two edges are selected with Ctrl, measured, and rounded together with a pr await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await page.waitForTimeout(800); const box = await page.getByTestId('viewport').boundingBox(); if (!box) throw new Error('viewport not visible'); @@ -759,6 +771,7 @@ test('a note on a face stays with the design, out of the assistant\'s reach, and await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await page.waitForTimeout(800); const box = await page.getByTestId('viewport').boundingBox(); @@ -849,6 +862,7 @@ test('the dimensions of the box show in the view, and its height changes through await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await page.getByTestId('dimensions').click(); const labels = page.getByTestId('mark-label'); @@ -1006,6 +1020,7 @@ test('a part becomes a project folder that is renamed, duplicated, opened, start await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await expect(name).toHaveText('Part', { timeout: 15_000 }); await expect.poll(() => existsSync(join(projects, 'Part', 'Part.sc')), { timeout: 15_000 }).toBe(true); // The conversation is kept in the project, next to the design. @@ -1106,6 +1121,7 @@ test('PartBuddy starts on the start page with no project open; a saved part gets await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await expect(name).toHaveText('Part', { timeout: 15_000 }); // After the save the project gets its picture and the light it was taken with. const preview = join(projects, 'Part', 'preview.png'); @@ -1215,6 +1231,7 @@ test('a STEP file becomes a project, meshes are inserted into it, converted to f await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await pick(join(outside, 'Platine.step')); await page.evaluate(() => (globalThis as unknown as Bridge).partbuddy.design.export('step')); expect(existsSync(join(outside, 'Platine.step'))).toBe(true); @@ -1479,6 +1496,7 @@ test('a standard screw is seated in a hole as a counterpart that is neither expo await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); // A 3.4 mm hole through the 20 × 20 × 10 mm box, then an M3 × 12 screw in it. // SolidCraft 0.40 (rc.2 and rc.3, 2026-11-08) keeps the bore one cylinder face. await page.evaluate(() => (globalThis as unknown as Bridge).partbuddy.design.applyAction({ action: 'hole', body: 'Body1', points: [[10, 10, 10]], size: 3.4 })); @@ -1529,6 +1547,7 @@ test('the export menu offers the slicers SolidCraft finds, and a print face is s await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); const slicers = await page.evaluate(() => (globalThis as unknown as Bridge).partbuddy.design.slicers()); // The menu lists exactly what SolidCraft found; nothing is sent here, so no slicer window opens. await page.getByTestId('export').click(); @@ -1571,6 +1590,7 @@ test('function blocks are previewed and built at a face: tapped hole, screw hole await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); const top = (x: number, y: number) => ({ body: 'Body1', point: [x, y, 10] }); const blocks = [ { block: 'tappedHole', ...top(5, 5), size: 'M4', depth: 6, modeled: true, more: [[5, 10, 10]] }, @@ -1627,6 +1647,7 @@ test('sketched blocks and a lip are previewed and built: text, nut trap, slot, l await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); // An open case with 3 mm walls: the 20 × 20 × 10 mm box without its top face. await page.evaluate(() => (globalThis as unknown as Bridge).partbuddy.design.applyAction({ action: 'shell', body: 'Body1', points: [[10, 10, 10]], size: 3 })); await expect(page.getByTestId('timeline-feature')).toHaveCount(2, { timeout: 30_000 }); @@ -1698,6 +1719,7 @@ test('a snap fit stands out of a face, its catch across the arm, with its strain await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); const snap = { block: 'snapFit', body: 'Body1', size: 'M3', length: 10, thickness: 1.5, width: 5, catchDepth: 0.8, catchLength: 1.5 }; // Up from the top face, and level out of a side face with the catch pointing up. for (const block of [ @@ -1771,6 +1793,7 @@ test('a logo from an SVG file is engraved and built up at a face, a ring keeping await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); const logo = { block: 'logo', body: 'Body1', size: 'M3', name: 'ring', svg, width: 12 }; for (const block of [ { ...logo, point: [10, 10, 10], normal: [0, 0, 1], depth: 0.6, mode: 'deboss' }, @@ -1935,6 +1958,7 @@ test('a logo in colours becomes a coloured body per fill, inlaid flush in the fa await expect(page.getByTestId('connection-status')).toHaveAttribute('data-engine', 'headless', { timeout: 30_000 }); await say('Build a box'); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); // 16 mm wide on the top face of the 20 mm box, centred at (10, 10): the plate 16 × 8, the dot 4 across. const block = { block: 'logo', body: 'Body1', size: 'M3', name: 'Badge', svg, width: 16, point: [10, 10, 10], normal: [0, 0, 1], depth: 0.6, mode: 'deboss', colours: true }; expect(await page.evaluate((b) => (globalThis as unknown as Bridge).partbuddy.design.previewBlock(b).then((r) => ({ ok: r.ok, error: r.error ?? null })), block)).toEqual({ ok: true, error: null }); @@ -2065,6 +2089,7 @@ test('the light measures parts against each other: an overlap and a gap under th await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await expect(page.getByTestId('print-check')).toHaveAttribute('data-status', 'ready', { timeout: 30_000 }); // A case, a lid sunk 1 mm into it and a clip 0.1 mm beside it (PLA's push fit is 0.15 mm). @@ -2723,6 +2748,7 @@ test('an SVG sent along is placed as a logo by partbuddy_place_drawing: engraved await expect(page.getByTestId('connection-status')).toHaveAttribute('data-engine', 'headless', { timeout: 30_000 }); await say('Build a box'); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); const data = [...readFileSync(join(__dirname, '..', 'test', 'fixtures', 'svg', 'logo-o.svg'))]; await composer.evaluate((field, bytes) => { const view = globalThis as unknown as PictureEvents; @@ -2960,6 +2986,7 @@ test('a built text is picked as a whole and changed afterwards: words, height an await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); // Raised 0.6 mm on the top face's middle, the sketch named after its words. const text = { block: 'text', body: 'Body1', point: [10, 10, 10], normal: [0, 0, 1], size: 'M3', text: 'PB', height: 6, depth: 0.6, mode: 'emboss', bold: false }; await page.evaluate((b) => (globalThis as unknown as Bridge).partbuddy.design.applyBlock(b), text); @@ -2976,6 +3003,9 @@ test('a built text is picked as a whole and changed afterwards: words, height an await expect(async () => { const [x, y] = spots[spot++ % spots.length] ?? [0.5, 0.5]; await page.keyboard.press('Escape'); + // Again each time: the first key 7 is lost when the view was not ready for it, and every click then misses. + await page.keyboard.press('7'); + await page.waitForTimeout(700); await page.mouse.click(box.x + box.width * x, box.y + box.height * y); await expect(page.getByTestId('logo-text')).toBeVisible({ timeout: 3000 }); }, 'the built text under a click').toPass({ timeout: 60_000, intervals: [500] }); @@ -3001,6 +3031,9 @@ test('a built text is picked as a whole and changed afterwards: words, height an await expect(async () => { const [x, y] = spots[spot++ % spots.length] ?? [0.5, 0.5]; await page.keyboard.press('Escape'); + // Again each time: the first key 7 is lost when the view was not ready for it, and every click then misses. + await page.keyboard.press('7'); + await page.waitForTimeout(700); await page.mouse.click(box.x + box.width * x, box.y + box.height * y); await expect(page.getByTestId('logo-words').getByRole('textbox')).toHaveValue('AB', { timeout: 3000 }); }, 'the changed text under a click').toPass({ timeout: 60_000, intervals: [500] }); @@ -3039,6 +3072,7 @@ test('a hole is placed on its face before it is drilled: across and up from the await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await page.getByTestId('viewport').hover(); await page.keyboard.press('7'); await page.waitForTimeout(1000); @@ -3119,6 +3153,8 @@ test('a hinge the assistant joined moves with its slider and goes back to as des return count; }); expect(await turned()).toBe(0); + // Not while a trial still shows "rebuilding": the revision that stands is what the test compares with. + await expect(page.getByTestId('connection-status')).toHaveText(/Revision \d+/, { timeout: 30_000 }); const revision = await page.getByTestId('connection-status').textContent(); const track = await page.getByTestId('joint-value').locator('.relative').first().boundingBox(); if (!track) throw new Error('the slider has no track'); @@ -3189,6 +3225,8 @@ test('a hinge plays in the view, stops where SolidCraft found the parts touching await page.keyboard.press('Escape'); await expect(findings).toBeHidden({ timeout: 1000 }); }).toPass({ timeout: 15_000 }); + // Not while a trial still shows "rebuilding": the revision that stands is what the test compares with. + await expect(page.getByTestId('connection-status')).toHaveText(/Revision \d+/, { timeout: 30_000 }); const revision = await page.getByTestId('connection-status').textContent(); // One click plays it: down to its limit first (the longer way), then up until the parts touch. await page.getByTestId('joints-play').click(); @@ -3393,12 +3431,17 @@ test('the assistant hangs a part on the wall with its blocks: keyhole slots on t 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 wall plate'); await page.getByRole('button', { name: 'Send' }).click(); - await expect(page.getByText(/Built 2 keyhole slots for 4 mm wood screws in Plate: entry 9 mm for the head, a slot 4\.5 mm wide running 8 mm up .* Built 2 countersunk holes for 4 mm wood screws in Plate: 4\.7 mm through, countersink 8\.8 mm at 90°/)).toBeVisible({ timeout: 60_000 }); + // Each block says that the plastic it needs was measured at the real engine (audit R54): the 8 mm plate has it. + // In the whole answer, not one element of it: the print check behind each block ends in a list of measures. + await expect(page.getByTestId('assistant-text').last()).toContainText( + /Built 2 keyhole slots for 4 mm wood screws in Plate: entry 9 mm for the head, a slot 4\.5 mm wide running 8 mm up [\s\S]* PartBuddy measured that plastic before building, from 0\.5 mm under the face on: it is there\.[\s\S]* Built 2 countersunk holes for 4 mm wood screws in Plate: 4\.7 mm through, countersink 8\.8 mm at 90°[\s\S]* PartBuddy measured that plastic, from 0\.5 mm under the face on: it is there\./, + { timeout: 60_000 }, + ); await expect(page.getByTestId('mesh-summary')).toContainText('1 body', { timeout: 30_000 }); // Each block is one line of the work log, and no feature failed. await expect(page.getByText('Keyhole slot for a 4 mm screw, to hang on a wall')).toHaveCount(1); await expect(page.getByText('Countersunk hole for a 4 mm wood screw')).toHaveCount(1); - await expect(page.getByText(/Built 2 pockets for 6 × 2 mm disc magnets in Plate: 6\.3 mm wide and 2\.2 mm deep/)).toBeVisible(); + await expect(page.getByText(/Built 2 pockets for 6 × 2 mm disc magnets in Plate: 6\.3 mm wide and 2\.2 mm deep.* PartBuddy measured that plastic before building, from 0\.5 mm under the face on: it is there\./)).toBeVisible(); await expect(page.getByText('Pocket for a 6 × 2 mm magnet')).toHaveCount(1); await expect(page.getByText(/SolidCraft warns:/)).toHaveCount(0); await expect(page.locator('[data-testid="timeline-feature"][data-state="error"]')).toHaveCount(0); @@ -3536,7 +3579,11 @@ test('the assistant clips a cover onto its base with snap hooks: each hook catch 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 clipped cover'); await page.getByRole('button', { name: 'Send' }).click(); - await expect(page.getByText(/Built 1 snap hook "Back clip" on Base, each with its window through the 2 mm wall of Cover: an arm 12 mm long, 1\.5 mm thick and 5 mm wide, 0\.2 mm off the wall.* Built 1 snap hook "Front clip" on Base/)).toBeVisible({ timeout: 90_000 }); + // In the whole answer, not one element of it: the print check behind each hook ends in a list of measures. + await expect(page.getByTestId('assistant-text').last()).toContainText( + /Built 1 snap hook "Back clip" on Base, each with its window through the 2 mm wall of Cover: an arm 12 mm long, 1\.5 mm thick and 5 mm wide, 0\.2 mm off the wall[\s\S]* Built 1 snap hook "Front clip" on Base/, + { 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('Snap hooks “Back clip” with their windows')).toHaveCount(1); diff --git a/apps/desktop/src/main/agents/blockTool.ts b/apps/desktop/src/main/agents/blockTool.ts index 624cbd9b..a4d94468 100644 --- a/apps/desktop/src/main/agents/blockTool.ts +++ b/apps/desktop/src/main/agents/blockTool.ts @@ -2,8 +2,8 @@ import { BLOCK_SIZES, INSERT, defaultBlock, needsNormal, sizesOf, stepsOf, type import { featureBlockSchema } from '../../shared/contract'; import { KNOB, KNOB_CLEARANCE, KNOB_SHAFTS, knobFlat, knobFlutes, knobInsert, knobProblem, knobSteps, type KnobShaft } from '../../shared/knob'; import { TAPPED_FROM } from '../../shared/mechanisms'; -import { MAGNET, MAGNET_HOLDS, magnetPocket, magnetProblem, magnetSteps, type MagnetHold } from '../../shared/magnet'; -import { KEYHOLE, WALL, WOOD_SCREWS, keyholeDepth, keyholeProblem, keyholeSteps, woodScrewSteps, type WoodScrew } from '../../shared/wallMount'; +import { MAGNET, MAGNET_HOLDS, magnetPocket, magnetProblem, magnetSteps, magnetStock, type MagnetHold } from '../../shared/magnet'; +import { KEYHOLE, WALL, WOOD_SCREWS, WOOD_SCREW_UNDER, countersinkDepth, keyholeDepth, keyholeProblem, keyholeSteps, keyholeStock, woodScrewSteps, woodScrewStock, type WoodScrew } from '../../shared/wallMount'; import { componentOf } from '../solidcraft/actions'; import { count } from '../solidcraft/mirror'; import type { ToolResult, UpstreamTool, UpstreamTools } from './gateProxy'; @@ -123,8 +123,58 @@ export function blockOf(args: Record): FeatureBlock | string { type Step = { command: string; params: Record }; +/** Plastic a block needs where it cuts (audit R54): a cylinder from `base` along `axis` that must lie wholly in the body. */ +export interface Stock { + /** The point on the face it is measured behind, for the answer. */ + at: Vec3; + base: Vec3; + axis: Vec3; + diameter: number; + depth: number; + /** How far under the face the cylinder starts, in mm. */ + from: number; + /** mm³ of the cylinder the block itself has cut away, when it is measured after the block was built. */ + hole?: number; + /** mm³ more the block may have cut away: on a curved face the hole does not end where it ends on a flat one. */ + slack?: number; +} + +/** In mm: how much deeper than asked a hole may reach on a curved face (measured 2026-11-12: an M3 seat on a rod of 20 mm radius took 0.02 mm more of the stock than on a flat face). */ +const CURVED_FACE = 0.3; + +/** In mm: the plastic that must stay under the seat of an insert (three layers of 0.2 mm, as under a magnet pocket; our own choice). */ +export const SEAT_UNDER = 0.6; +/** The plastic round an insert: what its maker asks for, and never under 2 mm (as a boss has it). */ +const seatWall = (size: BlockSize) => Math.max(2, INSERT[size]?.wall ?? 2); + +/** + * In mm: how far under the point the plastic is measured from. A face may fall away that far within the width of the + * stock, as a curved one does (0.47 mm under a magnet pocket on a rod of 20 mm radius); an edge or a chamfer falls + * further and is read as missing plastic. + */ +export const FACE_DROP = 0.5; + +/** mm³ a hole `width` wide may take less than its cylinder on a face that falls away {@link FACE_DROP} at the rim of its stock (the cap of a ball). */ +const rimDrop = (width: number, stock: number) => (Math.PI * FACE_DROP * width ** 4) / (8 * stock ** 2); + +/** The plastic the seat of an insert needs, measured after it is cut: {@link FACE_DROP} under the face on, the seat itself taken off. */ +export function seatStock(block: Extract, point: Vec3, axis: Vec3): Stock { + const round = (value: number) => Math.round(value * 1000) / 1000; + const area = (Math.PI * block.diameter ** 2) / 4; + return { + at: point, + base: point.map((value, k) => round(value + (axis[k] ?? 0) * FACE_DROP)) as Vec3, + axis, + diameter: round(block.diameter + 2 * seatWall(block.size)), + depth: round(block.depth - FACE_DROP + SEAT_UNDER), + from: FACE_DROP, + hole: area * (block.depth - FACE_DROP), + slack: area * CURVED_FACE, + }; +} + /** A wall mounting the model asked for: its steps and what to say; null when the block is none of them; or what is wrong. */ -export function wallOf(args: Record): { body: string; steps: Step[]; text: string } | string | null { +export function wallOf(args: Record): { body: string; steps: Step[]; text: string; stock?: Stock[]; cut?: (point: Vec3, axis: Vec3) => Stock; need: string } | string | null { if (args.block !== 'keyhole' && args.block !== 'woodScrew') return null; const body = typeof args.body === 'string' ? args.body.trim() : ''; const point = vec3(args.point); @@ -139,7 +189,9 @@ export function wallOf(args: Record): { body: string; steps: St return { body, steps: woodScrewSteps({ body, point, more: more as Vec3[], screw, name: 'Wall screw' }), - text: `Built ${String(count)} countersunk hole${count > 1 ? 's' : ''} for ${String(screw)} mm wood screws in ${body}: ${String(size.clearance)} mm through, countersink ${String(size.countersink)} mm at 90°, so the head lies flush. Keep at least ${String(screw)} mm of plastic between a hole and an edge, and the plate thicker than the countersink is deep (${String(Math.round(((size.countersink - size.clearance) / 2) * 10) / 10)} mm).`, + cut: (at, axis) => woodScrewStock(screw, at, axis, FACE_DROP), + need: `a countersunk hole for a ${String(screw)} mm wood screw needs ${String(screw)} mm of plastic round its ${String(size.clearance)} mm hole and a plate at least ${String(Math.round((countersinkDepth(screw) + WOOD_SCREW_UNDER) * 100) / 100)} mm thick. Thicken the plate, or move the hole away from edges and other cuts`, + text: `Built ${String(count)} countersunk hole${count > 1 ? 's' : ''} for ${String(screw)} mm wood screws in ${body}: ${String(size.clearance)} mm through, countersink ${String(size.countersink)} mm at 90°, so the head lies flush. Keep at least ${String(screw)} mm of plastic between the ${String(size.clearance)} mm hole and an edge, and the plate thicker than the countersink is deep (${String(Math.round(((size.countersink - size.clearance) / 2) * 10) / 10)} mm).`, }; } const normal = vec3(args.normal); @@ -152,6 +204,8 @@ export function wallOf(args: Record): { body: string; steps: St return { body, steps: keyholeSteps(spec), + stock: keyholeStock(spec, FACE_DROP), + need: `a keyhole needs the part ${String(keyholeDepth(screw))} mm thick behind its entry and its slot. Thicken the part there, move the keyhole away from edges and other cuts, or use woodScrew`, text: `Built ${String(count)} keyhole slot${count > 1 ? 's' : ''} for ${String(screw)} mm wood screws in ${body}: entry ${String(size.entry)} mm for the head, a slot ${String(size.slot)} mm wide running ${String(KEYHOLE.travel)} mm up to where the screw rests, ` + `and behind its ${String(KEYHOLE.lip)} mm lip a pocket ${String(size.pocket)} mm deep for the head. The part must be at least ${String(keyholeDepth(screw))} mm thick there. ` + @@ -160,7 +214,7 @@ export function wallOf(args: Record): { body: string; steps: St } /** Magnet pockets the model asked for: their steps and what to say; null when the block is none; or what is wrong. */ -export function magnetOf(args: Record): { body: string; steps: Step[]; text: string } | string | null { +export function magnetOf(args: Record): { body: string; steps: Step[]; text: string; stock: Stock[]; need: string } | string | null { if (args.block !== 'magnet') return null; const body = typeof args.body === 'string' ? args.body.trim() : ''; const point = vec3(args.point); @@ -180,10 +234,12 @@ export function magnetOf(args: Record): { body: string; steps: return { body, steps: magnetSteps(spec), + stock: magnetStock(spec, FACE_DROP), + need: `a pocket needs ${String(MAGNET.around)} mm of plastic round it and ${String(MAGNET.under)} mm under it. Thicken the part there, move the pocket away from edges and other cuts, or take a thinner magnet`, text: `Built ${String(count)} pocket${count > 1 ? 's' : ''} for ${String(spec.diameter)} × ${String(spec.height)} mm disc magnets in ${body}: ${String(pocket.diameter)} mm wide and ${String(pocket.depth)} mm deep, so the magnet lies ${String(MAGNET.sunk)} mm under the face, with a chamfered mouth. ` + (hold === 'glue' ? 'The magnet drops in and is held by a drop of superglue. ' : `The magnet is pressed in (${String(MAGNET.room.press)} mm of room on the diameter); with more than that missing the plastic splits along its layers, so try one pocket first. `) + - `Keep at least 1.2 mm of plastic round a pocket and 0.6 mm under it. Mark the poles before gluing a pair: the two parts must attract. Neodymium magnets lose strength above 80 °C. No pocket of these sizes has been printed yet.`, + `Keep at least ${String(MAGNET.around)} mm of plastic round a pocket and ${String(MAGNET.under)} mm under it. Mark the poles before gluing a pair: the two parts must attract. Neodymium magnets lose strength above 80 °C. No pocket of these sizes has been printed yet.`, }; } @@ -240,6 +296,35 @@ function jsonOf(result: ToolResult): unknown { throw new Error(failed ? `SolidCraft refused ${String(failed.command)}: ${String(failed.error).slice(0, 300)}` : text.slice(0, 300)); } +/** + * The way a hole went into the body, from what `inspect.measure` says before and after it: the plastic it took lies + * on its axis, so its centre of mass lies straight under the point. SolidCraft's answer to `solid.hole` names no + * direction, and these blocks are given none. One hole at a time: two holes that take unequal amounts (a stepped + * plate, a pocket under one of them) have no common centre under the middle of their points. Null when it cannot + * be told. Measured 2026-11-12: SolidCraft gives the centre of mass with all the digits of a double, and on a block + * of 200 × 200 × 50 mm the axis of one M3 seat came out within a hundredth of a degree. + */ +export function cutAxis(body: string, before: unknown, after: unknown, point: Vec3): Vec3 | null { + const of = (measured: unknown) => (measured as { bodies?: { name?: unknown; volume_mm3?: unknown; center_of_mass?: unknown }[] } | undefined)?.bodies?.find((entry) => entry.name === body); + const [was, is] = [of(before), of(after)]; + const [from, to] = [vec3(was?.center_of_mass), vec3(is?.center_of_mass)]; + const [full, left] = [was?.volume_mm3, is?.volume_mm3]; + if (typeof full !== 'number' || typeof left !== 'number' || !from || !to) return null; + const taken = full - left; + if (!(taken > 1e-6)) return null; + const way = from.map((value, k) => (value * full - (to[k] ?? 0) * left) / taken - (point[k] ?? 0)) as Vec3; + const length = Math.hypot(...way); + // Nearer to the point than a tenth of a millimetre, the direction is noise. + return length > 0.1 ? (way.map((value) => Math.round((value / length) * 1e6) / 1e6 + 0) as Vec3) : null; +} + +/** How many features and sketches the design has, as `document.inspect` lists them; null when it does not. */ +function featuresOf(inspected: unknown): number | null { + const { timeline, sketches } = (inspected ?? {}) as { timeline?: unknown; sketches?: unknown }; + return Array.isArray(timeline) ? timeline.length + (Array.isArray(sketches) ? sketches.length : 0) : null; +} +const undoDepth = (inspected: unknown) => count((inspected as { undo?: unknown } | undefined)?.undo); + /** Volume in mm³ of every body by name as `inspect.measure` answers; NaN for a body that is not closed. */ function volumesOf(measured: unknown): Map { const bodies = (measured as { bodies?: { name?: unknown; volume_mm3?: unknown }[] } | undefined)?.bodies ?? []; @@ -249,6 +334,9 @@ function volumesOf(measured: unknown): Map { /** Why a block would not land on the body it names, from the volumes before and after a trial; null when it does. */ export function misplaced(body: string, before: Map, after: Map): string | null { if (!before.has(body)) return `there is no body named ${body}; the design has ${[...before.keys()].join(', ') || 'no body'}`; + // SolidCraft makes each loose piece a body of its own (seen with 0.40.1: "Strip" and "Strip (1)"). + const pieces = [...after.keys()].filter((name) => !before.has(name)); + if (pieces.length > 0) return `it would leave ${String(pieces.length + 1)} bodies where ${body} was one (${pieces.join(', ')}): the cut goes right through the part there. Give a point with more plastic round it, or a smaller size`; const changed = (name: string) => Math.abs((after.get(name) ?? 0) - (before.get(name) ?? 0)) > 1e-6; const others = [...new Set([...before.keys(), ...after.keys()])].filter((name) => name !== body && changed(name)); if (others.length > 0) return `at this point it would change ${others.join(', ')}, not only ${body}; give a point on a face of ${body} alone, away from other bodies`; @@ -256,6 +344,96 @@ export function misplaced(body: string, before: Map, after: Map< return null; } +/** The name of the bodies that measure the plastic at a block; they are never kept. */ +const PROBE = 'PartBuddy probe'; + +/** + * How much of a stock cylinder must be plastic. SolidCraft 0.40.1 measures the shared volume exactly, not on + * facets: seven cylinders wholly inside a block, a rod and a ball, along the axes and askew, each gave 1.0000000 + * of their volume; one with 0.02 mm of its 2.8 mm outside gave 0.99286, one with 0.01 mm of its width over an edge + * 0.99993 (tools/audit, r54-shares, 2026-11-12). + */ +export const STOCK_FULL = 0.999; + +/** + * Where the body has less plastic than a block's cuts need (audit R54): each stock cylinder is laid in as a body + * on trial and SolidCraft says how much of it lies in the part. A step that must fail ends the batch, and + * SolidCraft takes the probes back, leaving revision, undo and redo as they were (as mechanismTool.ts measures a + * hinge line). Null when all of it is plastic; `undefined` when it could not be measured. + */ +export async function shortOf(upstream: UpstreamTools, body: string, stock: Stock[], callId: string | undefined): Promise { + try { + const answer = await upstream.callTool( + 'batch', + { + commands: [ + ...stock.flatMap((room, i) => [ + { command: 'solid.cylinder', params: { diameter: `${String(room.diameter)} mm`, height: `${String(room.depth)} mm`, base: room.base, axis: room.axis, operation: 'new', body_name: `${PROBE} ${String(i)}` } }, + { command: 'inspect.interference', params: { bodies: [body, `${PROBE} ${String(i)}`] } }, + ]), + { command: 'sketch.inspect', params: { sketch: 2147483647 } }, + ], + rollback: true, + }, + callId, + ); + const text = answer.content.find((part) => part.type === 'text')?.text; + const results = (JSON.parse(typeof text === 'string' ? text : '') as { results?: unknown[] }).results; + if (!Array.isArray(results) || results.length < stock.length * 2) return undefined; + for (const [i, room] of stock.entries()) { + const found = (results[i * 2 + 1] as { interferences?: { volume_mm3?: unknown }[] } | undefined)?.interferences; + if (!Array.isArray(found)) return undefined; + const area = (Math.PI * room.diameter ** 2) / 4; + const hole = room.hole ?? 0; + const plastic = found.reduce((sum, pair) => sum + (typeof pair.volume_mm3 === 'number' ? pair.volume_mm3 : 0), 0); + const full = area * room.depth - hole; + if (plastic < full * STOCK_FULL - (room.slack ?? 0)) { + // The cylinder starts under the face: with none of it in the part, all that is known is that the part ends above it. + const has = plastic < full * 0.001 ? `less than ${String(room.from)}` : String(Math.round((room.from + (room.depth * plastic) / full) * 100) / 100); + return `at [${room.at.join(', ')}] ${body} has ${has} mm of plastic where ${String(Math.round((room.from + room.depth) * 100) / 100)} mm are needed (the mean over a width of ${String(room.diameter)} mm; the far side, an edge or another cut takes the rest)`; + } + } + return null; + } catch { + return undefined; + } +} + +type Run = (commands: { command: string; params: Record }[]) => Promise; + +/** Undo steps tried beyond those the block sent, before giving up. */ +const UNDO_RESERVE = 3; + +/** + * Takes a block back that was built and must not stay: one Undo at a time, until the design measures as it did + * before (the same bodies with the same volumes, the same number of features, the history no deeper). The depth of + * the undo history alone does not say how many steps: SolidCraft 0.40.1 caps it at 200, so past that a block adds + * steps and the depth stays. Never more steps than the block sent plus a reserve. The top component is made active + * again, which Undo leaves as the block set it. False when the design does not come back to where it was. + */ +async function takeBack(run: Run, before: Map, was: unknown, sent: number): Promise { + const same = (now: Map) => + now.size === before.size && [...before].every(([name, volume]) => now.has(name) && (Number.isNaN(volume) ? Number.isNaN(now.get(name)) : Math.abs((now.get(name) ?? NaN) - volume) <= 1e-6)); + let back = false; + try { + for (let step = 0; step <= sent + UNDO_RESERVE; step += 1) { + const [measured, inspected] = await run([ + { command: 'inspect.measure', params: {} }, + { command: 'document.inspect', params: {} }, + ]); + if (same(volumesOf(measured)) && featuresOf(inspected) === featuresOf(was) && undoDepth(inspected) <= undoDepth(was)) { + back = true; + break; + } + await run([{ command: 'edit.undo', params: {} }]); + } + } catch { + back = false; + } + await run([{ command: 'component.activate', params: { component: 0 } }]).catch(() => undefined); + return back; +} + /** What was built, with the sizes it took from the tables, for the model to tell the user. */ function built(block: FeatureBlock): string { const { block: kind, body, point, ...sizes } = block; @@ -268,7 +446,7 @@ function built(block: FeatureBlock): string { } /** The upstream tools plus `partbuddy_block`. */ -export function withBlockTool(upstream: UpstreamTools, keepFace: (body: string, face: string) => void = () => undefined): UpstreamTools { +export function withBlockTool(upstream: UpstreamTools, keepFace: (body: string, face: string) => void = () => undefined, log: (line: string) => void = () => undefined): UpstreamTools { return { instructions: () => upstream.instructions(), listTools: async () => [...(await upstream.listTools()), blockTool], @@ -296,23 +474,61 @@ export function withBlockTool(upstream: UpstreamTools, keepFace: (body: string, const fresh = wall !== null && 'fresh' in wall; const measure = { command: 'inspect.measure', params: {} }; const inspect = { command: 'document.inspect', params: {} }; - const depth = (inspected: unknown) => count((inspected as { undo?: unknown } | undefined)?.undo); const [measured, inspected] = await run([measure, inspect]); const before = volumesOf(measured); const missing = fresh ? null : misplaced(body, before, before); if (missing?.startsWith('there is no body')) return { content: [{ type: 'text', text: `Not built: ${missing}` }], isError: true }; + // A pocket that comes out of the far side or of an edge is not built at all. + const { stock, cut, need } = (wall ?? {}) as { stock?: Stock[]; cut?: (point: Vec3, axis: Vec3) => Stock; need?: string }; + const short = stock ? await shortOf(upstream, body, stock, callId) : null; + if (short) return { content: [{ type: 'text', text: `Not built: ${short}; ${need ?? ''}.` }], isError: true }; + // Holes that are given no direction are measured once they are cut, each along the way it went in. + const holes: { points: Vec3[]; stock: (point: Vec3, axis: Vec3) => Stock; whole?: number; drop?: number; need: string } | null = + block?.block === 'insertSeat' + ? { + points: [block.point, ...(block.more ?? [])], + stock: (point, axis) => seatStock(block, point, axis), + whole: ((Math.PI * block.diameter ** 2) / 4) * block.depth, + drop: rimDrop(block.diameter, block.diameter + 2 * seatWall(block.size)), + need: `the seat of an ${block.size} insert needs ${String(seatWall(block.size))} mm of plastic round it and ${String(SEAT_UNDER)} mm under it. Thicken the part there, move the seat away from edges and other cuts, or use a boss`, + } + : cut && need + ? { points: [vec3(args.point) ?? [0, 0, 0], ...(Array.isArray(args.more) ? args.more.flatMap((entry) => { const point = vec3(entry); return point ? [point] : []; }) : [])], stock: cut, need } + : null; let results: unknown[]; + let after: boolean | null = null; + // One hole a step, measured after each: every hole has its own way in, also on a stepped part or on two faces. + const sent = holes ? sequence.flatMap((step) => [step, measure]) : [...sequence, measure]; try { - results = await run([...sequence, measure]); + results = await run(sent); // The point decides what SolidCraft cuts, so the named body is checked before the block stays. const wrong = fresh ? null : misplaced(body, before, volumesOf(results[results.length - 1])); if (wrong) throw new Error(wrong); + if (holes) { + const stocks: Stock[] = []; + for (const [i, point] of holes.points.entries()) { + const [was, is] = [i === 0 ? measured : results[i * 2 - 1], results[i * 2 + 1]]; + const taken = (volumesOf(was).get(body) ?? NaN) - (volumesOf(is).get(body) ?? NaN); + // A seat is cut out of solid plastic: where its rim breaks out of the face it takes less than its cylinder. + if (holes.whole && taken < holes.whole * STOCK_FULL - (holes.drop ?? 0)) + throw new Error( + `at [${point.join(', ')}] the seat would cut ${String(Math.round(taken * 10) / 10)} mm³ of plastic where its whole cylinder is ${String(Math.round(holes.whole * 10) / 10)} mm³: its rim breaks out of the face, into an edge, a chamfer, the far side or another cut; ${holes.need}.`, + ); + const axis = cutAxis(body, was, is, point); + if (axis) stocks.push(holes.stock(point, axis)); + } + const thin = stocks.length === holes.points.length ? await shortOf(upstream, body, stocks, callId) : undefined; + if (thin) throw new Error(`${thin}; ${holes.need}.`); + after = thin === null; + } } catch (cause) { - // Nothing of a block that failed half way or landed on another body stays: undone step by step, as the work - // log goes back. (A batch with `rollback` or a checkpoint is no way back in SolidCraft 0.40.1: after the one - // the same modelled thread fails for real, and the other is gone once a batch has run.) - const steps = depth((await run([inspect]))[0]) - depth(inspected); - if (steps > 0) await run(Array.from({ length: steps }, () => ({ command: 'edit.undo', params: {} }))); + // Nothing of a block that failed half way, landed on another body or found too little plastic stays. (A batch with + // `rollback` or a checkpoint is no way back in SolidCraft 0.40.1: after the one the same modelled thread fails for + // real, and the other is gone once a batch has run.) + if (!(await takeBack(run, before, inspected, sequence.length))) { + log(`partbuddy_block ${String(args.block)} on ${body} could not be taken back after: ${(cause as Error).message.slice(0, 200)}`); + throw new Error(`${(cause as Error).message.replace(/\.$/, '')}. What was built of it could NOT be taken back: undo it before you call again`, { cause }); + } throw cause; } // The plate view lays the part on the face SolidCraft names (print-faces.json), as the slicer will. @@ -322,7 +538,20 @@ export function withBlockTool(upstream: UpstreamTools, keepFace: (body: string, const warning = (result as { warning?: unknown } | undefined)?.warning; return typeof warning === 'string' ? [warning.slice(0, 200)] : []; }); - return { content: [{ type: 'text', text: `${wall ? wall.text : block ? built(block) : ''}${warnings.length > 0 ? ` SolidCraft warns: ${warnings.join(' ')}` : ''}` }] }; + // What was measured is said as it was measured, and what was not is said too, never passed over. + const unmeasured = ' PartBuddy could not measure that plastic this time: check it yourself.'; + const under = `from ${String(FACE_DROP)} mm under the face on`; + const stockSaid = stock + ? short === null + ? ` PartBuddy measured that plastic before building, ${under}: it is there.` + : unmeasured + : holes + ? after + ? ` PartBuddy measured that plastic, ${under}${holes.whole ? ', and that each seat cut its whole cylinder' : ''}: it is there.` + : unmeasured + : ''; + const said = (wall ? wall.text : block ? built(block) : '') + (block?.block === 'insertSeat' ? ` Each seat needs ${String(seatWall(block.size))} mm of plastic round it and ${String(SEAT_UNDER)} mm under it.` : ''); + return { content: [{ type: 'text', text: `${said}${stockSaid}${warnings.length > 0 ? ` SolidCraft warns: ${warnings.join(' ')}` : ''}` }] }; } catch (cause) { return { content: [{ type: 'text', text: `Not built: ${(cause as Error).message}` }], isError: true }; } diff --git a/apps/desktop/src/main/agents/designTool.ts b/apps/desktop/src/main/agents/designTool.ts index 1d8e5606..abe67751 100644 --- a/apps/desktop/src/main/agents/designTool.ts +++ b/apps/desktop/src/main/agents/designTool.ts @@ -16,7 +16,7 @@ const designTool: UpstreamTool = { name: DESIGN_TOOL, description: 'Record that the user turned a group of the design rules off or back on for this project, e.g. "no fillets" → ' + - 'rounding false, "screw the hinge instead" → printInPlace false. Only for what the user said; leave out the groups ' + + 'rounding false, "screw the hinge instead" → printInPlace false, "make it solid" → economy false. Only for what the user said; leave out the groups ' + 'they did not mention.', inputSchema: { type: 'object', @@ -25,6 +25,7 @@ const designTool: UpstreamTool = { printInPlace: choice('Print-in-place hinges, joints and clips'), details: choice('Refined details (hole chamfers, sunk screw heads, lead-ins, even walls)'), snap: choice('Sizes snapped to nozzle and layer height'), + economy: choice('Saving material and print time (shells and ribs instead of solid blocks, low build height, no needless support)'), }, additionalProperties: false, }, diff --git a/apps/desktop/src/main/agents/viewContext.ts b/apps/desktop/src/main/agents/viewContext.ts index 355fe384..0801ff71 100644 --- a/apps/desktop/src/main/agents/viewContext.ts +++ b/apps/desktop/src/main/agents/viewContext.ts @@ -194,6 +194,6 @@ export function withViewTool( export const VIEW_GUIDANCE = 'A message may start with a [PartBuddy: …] note naming the face, edge or vertex the user selected in PartBuddy’s view; ' + `that is what "this face", "this edge" or "the marked one" means. Call ${VIEW_TOOL} to see what the user looks at. ` + - `PartBuddy checks the part for printing after every change and shows the result to the user as a light; ${VIEW_TOOL} ` + - 'gives you the same findings. Read them after your last change, before you call the part done. ' + + 'PartBuddy checks the part for printing after every change and shows the result to the user as a light; the findings come ' + + `behind the answer of each tool call that changed the design, and ${VIEW_TOOL} gives the latest ones. ` + 'Take a screenshot only when the look of the part matters: it costs many tokens.'; diff --git a/apps/desktop/src/main/agents/workLog.ts b/apps/desktop/src/main/agents/workLog.ts index 8d20bd89..fa816257 100644 --- a/apps/desktop/src/main/agents/workLog.ts +++ b/apps/desktop/src/main/agents/workLog.ts @@ -18,10 +18,13 @@ export interface WorkLogOptions { /** Runs Undo or Redo `count` times as one request. */ travel(command: 'edit.undo' | 'edit.redo', count: number): Promise; emit(event: AgentEvent): void; + /** The answer of a call that moved the design's revision, as the model gets it (the print check goes behind it). */ + changed?(result: ToolResult, after: DesignHistory): Promise; + log?(line: string): void; } /** Tools that may change the design; every other tool only reads and is not tracked. */ -export const CHANGING_TOOLS = new Set(['execute', 'batch', 'set_parameter', 'undo', 'redo', 'partbuddy_mechanism', 'partbuddy_block']); +export const CHANGING_TOOLS = new Set(['execute', 'batch', 'set_parameter', 'undo', 'redo', 'partbuddy_mechanism', 'partbuddy_block', 'partbuddy_place_drawing', 'partbuddy_move_drawing']); /** Tools that move through the history instead of adding to it. */ const HISTORY_TOOLS = new Set(['undo', 'redo']); @@ -62,9 +65,9 @@ export class WorkLog { return { instructions: () => upstream.instructions(), listTools: () => upstream.listTools(), - callTool: (name, args, callId) => { + callTool: async (name, args, callId) => { if (!CHANGING_TOOLS.has(name)) return upstream.callTool(name, args, callId); - return this.serial(async () => { + const { result, moved } = await this.serial(async () => { const before = await this.readHistory(); const result: ToolResult = await upstream.callTool(name, args, callId); const after = await this.readHistory(); @@ -73,8 +76,12 @@ export class WorkLog { } else { this.publish(); } - return result; + // Without the history nobody knows whether the design changed: said in the log, since the model then gets no check. + if (!before || !after) this.options.log?.(`the design's history could not be read round ${name}; its answer carries no print check`); + return { result, moved: before && after && designChanged(before, after) ? after : null }; }); + // Outside the queue: the user's own changes do not wait for the check. + return moved && this.options.changed ? this.options.changed(result, moved) : result; }, }; } @@ -207,6 +214,20 @@ export class WorkLog { } } +/** + * True when the call left another design behind: the undo depth moved, or a change took the place of what Redo held. + * The revision alone says nothing: a trial, a save and a block that was taken back move it and leave the design as it was. + */ +export function designChanged(before: DesignHistory, after: DesignHistory): boolean { + if (before.documentId !== after.documentId || before.undo !== after.undo || (before.redo > 0 && after.redo === 0)) return true; + // At the cap a step adds to the history and its depth stays: there a moved revision is all that tells, and it + // counts, at the price of a check too many after a trial. Without this no change past the cap got its check. + return after.undo >= UNDO_CAP && before.revision !== after.revision; +} + +/** How deep SolidCraft's undo history goes (0.40.1, measured 2026-11-12: 260 steps read as 200). */ +export const UNDO_CAP = 200; + /** True when the history grew or a new change replaced what Redo could have brought back. */ export function addedToHistory(before: DesignHistory, after: DesignHistory): boolean { if (before.documentId !== after.documentId || before.revision === after.revision) return false; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index e889a796..7bb66f1f 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -57,7 +57,7 @@ import { readVersion } from './solidcraft/engineVersion'; import { safeDesignFileName } from './solidcraft/headless'; import { longPath, SolidCraftSupervisor } from './solidcraft/supervisor'; import { ChatArchive } from './chat/chatArchive'; -import { printCheckNote } from './print/printCheckNote'; +import { CHECK_PENDING_VIEW, CheckTold, printCheckNote, withCheckOfChange } from './print/printCheckNote'; import { templatePrintFaces, templateSteps } from '../shared/templates'; import { testPieceSteps } from '../shared/testPieces'; import { sketchNote } from './agents/sketchNote'; @@ -371,11 +371,13 @@ if (!app.requestSingleInstanceLock()) { // The check as partbuddy_view shows it: a check of an older revision is still catching up. const checkForView = () => { if (!printCheck) return null; - if (printCheck.revision !== supervisor.currentRevision()?.revision) - return '[PartBuddy: the ready-to-print check of the latest change is still running; call partbuddy_view again in a moment.]'; - return printCheckNote(printCheck); + if (printCheck.revision !== supervisor.currentRevision()?.revision) return CHECK_PENDING_VIEW; + return printCheckNote(printCheck, true); }; + // What the tool answers of this turn already said of the check: the same verdict is not told in full again. + const checkTold = new CheckTold(); bridge.handle(invokeChannels.printReportCheck, (report) => { + if (report.problem) log(`print check of revision ${String(report.revision)}: ${report.problem}`); printCheck = report; return undefined; }); @@ -550,6 +552,9 @@ if (!app.requestSingleInstanceLock()) { history: () => supervisor.history(), travel: (command, count) => supervisor.travel(command, count), emit: emitEvent, + // The check of each change goes back with the tool's answer, so no turn ends on a part the model never saw checked. + changed: (result, after) => withCheckOfChange(result, after, () => ({ mirrored: supervisor.currentRevision(), report: printCheck }), (report) => checkTold.note(report)), + log, }); workLog = steps; // Questions and plans as cards (M2a); changes wait until the user answered. @@ -585,7 +590,7 @@ if (!app.requestSingleInstanceLock()) { steps.tools( withDesignTool( withAssemblyTool( - withViewTool(guardNotes(withStandardTool(withPrintFaces(withBlockTool(withMechanismTool(withDrawings(upstream, drawings), () => printProfiles.part().printer.nozzle, keepMechanismFace), keepMechanismFace), keepMechanismFace)), (trial) => supervisor.trialSteps(trial)), () => viewState, () => notesService.forView(), checkForView), + withViewTool(guardNotes(withStandardTool(withPrintFaces(withBlockTool(withMechanismTool(withDrawings(upstream, drawings), () => printProfiles.part().printer.nozzle, keepMechanismFace), keepMechanismFace, log), keepMechanismFace)), (trial) => supervisor.trialSteps(trial)), () => viewState, () => notesService.forView(), checkForView), () => viewState?.bodies.map((body) => body.name) ?? [], saveAssembly, ), @@ -729,6 +734,7 @@ if (!app.requestSingleInstanceLock()) { }); const conversation = agent.conversation(); const sentAt = new Date(); + checkTold.forget(); const lines = [ // Once after a restart: what the earlier conversation about this design was about. chats?.takeDigest() ?? null, diff --git a/apps/desktop/src/main/print/printCheckNote.ts b/apps/desktop/src/main/print/printCheckNote.ts index 4963ca1e..aaf160c5 100644 --- a/apps/desktop/src/main/print/printCheckNote.ts +++ b/apps/desktop/src/main/print/printCheckNote.ts @@ -1,4 +1,5 @@ import type { PrintCheckReport } from '../../shared/contract'; +import type { ToolResult } from '../agents/gateProxy'; type Finding = PrintCheckReport['findings'][number]; type Places = Extract['places']; @@ -68,19 +69,157 @@ function line(finding: Finding, nozzle: number): string { } } +/** What the mesh check looks at at once (the finding kinds of printCheck.ts), so "nothing found" claims no more. */ +const CHECKED_MESH = 'build volume, wall and floor thickness, overhang angle, area on the bed, open bodies, failed features, bodies that touch'; +/** What SolidCraft measures after it (withClearance, withMotion): it takes longer and comes with the full check. */ +const MEASURED = { clearance: 'bodies that overlap or come closer than a push fit', joints: 'joints that stop before a limit' }; +const CHECKED_MEASURED = `${MEASURED.clearance}, ${MEASURED.joints}`; +/** What the model is to do while the measures are out; partbuddy_view itself says only that they are coming, or a small model calls it in circles. */ +const FETCH_FULL = 'call partbuddy_view for the full check before you call the part done'; +const preliminaryLine = (view: boolean) => `Preliminary: clearance and joints still being measured (${CHECKED_MEASURED}); ${view ? 'the full check will be here in a moment' : FETCH_FULL}.`; +/** A measure that failed is said, never read as "nothing found" (audit H-05). */ +const unmeasuredLine = (failed: readonly ('clearance' | 'joints')[]) => + `NOT measured, because SolidCraft's measure failed: ${failed.map((what) => MEASURED[what]).join(', ')}. Measure it yourself (${failed.map((what) => (what === 'clearance' ? 'inspect.clearance' : 'joint.sweep')).join(', ')}) before you call the part done.`; +/** What a clean check says nothing about (audit R3); the model must not pass the verdict on as more. */ +const NOT_CHECKED = 'Not checked: strength and layer direction under load, the fits and hole allowances you chose, details and text too fine to print, bed-edge chamfers. Judge those yourself.'; + +/** Bodies listed with their measures before the rest is counted. */ +const MAX_BODIES = 6; + +/** + * Why the numbers are there and what they are not. Solid volume alone would mislead: turning a block into a shell + * with ribs takes most of it away, and little of the filament, since the slicer fills a block sparsely anyway. The + * shell estimate is two wall lines on every surface and 15 % infill inside (Prusa and Protolabs Network name 10 to + * 20 % as usual): an assumption, since PartBuddy does not know the slicer's settings. + */ +const MEASURES_HEAD = + 'Per body as it prints. Solid volume overstates what thinning a thick region saves, because the slicer fills it sparsely anyway; the shell estimate ' + + '(two wall lines on every surface, 15 % infill inside: an assumption, not the slicer\'s settings) is nearer to the filament. What counts for sure: fewer layers, less area to support, less surface. Promise neither grams nor minutes:'; + +/** A volume as the model reads it: under 1 cm³ in mm³, so a small part still shows what a change saved. */ +const volume = (mm3: number) => (mm3 < 1000 ? `${String(Math.round(mm3))} mm³` : `${String(Math.round(mm3 / 10) / 100)} cm³`); + +type Body = NonNullable[number]; + +/** One body's measures: the numbers the model compares before and after a change. */ +function measureLine(body: Body): string { + return `- ${JSON.stringify(body.name)}: ${volume(body.volume)} solid, about ${volume(body.shell)} as a shell, ${mm(body.height)} high (${String(body.layers)} layers), ${String(body.surface)} mm² of surface, ${String(body.contact)} mm² on the bed, ${String(body.support)} mm² leaning past the overhang limit.`; +} + +function measureLines(report: PrintCheckReport, head = MEASURES_HEAD, only: (body: Body) => boolean = () => true): string[] { + const bodies = (report.bodies ?? []).slice(0, MAX_BODIES).filter(only); + if (bodies.length === 0) return []; + const rest = (report.bodies ?? []).length - MAX_BODIES + (report.bodiesOmitted ?? 0); + return [head, ...bodies.map(measureLine), ...(rest > 0 ? [`- and ${String(rest)} more ${rest === 1 ? 'body' : 'bodies'}.`] : [])]; +} + +/** The findings as lines; what does not fit is counted, never dropped without a word. */ +function findingLines(report: PrintCheckReport): string[] { + const lines = report.findings.slice(0, 12).map((finding) => line(finding, report.nozzle)); + const rest = report.findings.length - lines.length + (report.omitted ?? 0); + if (rest > 0) lines.push(`- and ${String(rest)} more ${rest === 1 ? 'finding' : 'findings'} not listed here; the user sees them in the ready-to-print panel.`); + return lines; +} + /** * The ready-to-print check (M7) as the model reads it: PartBuddy checks after every revision, the - * model hears of it with the next message when the result changed. Face and feature names come - * from the design, so they are quoted. + * model reads it behind the answer of each tool that changed the design, and with the next message + * when the result changed. Face and feature names come from the design, so they are quoted. */ -export function printCheckNote(report: PrintCheckReport): string { +export function printCheckNote(report: PrintCheckReport, view = false): string { const head = '[PartBuddy: ready-to-print check of the part as it is now (each body lying on its print face when it has one, else as designed along +Z on its own lowest point; overhangs by angle only):'; - if (report.findings.length === 0) return `${head} ready to print, nothing found.]`; + const failed = report.unmeasured ?? []; + const preliminary = [...(report.measuring ? [preliminaryLine(view)] : []), ...(failed.length > 0 ? [unmeasuredLine(failed)] : [])]; + const measured = (['clearance', 'joints'] as const).filter((what) => !failed.includes(what)).map((what) => `, ${MEASURED[what]}`).join(''); + if (report.findings.length === 0) + return [`${head} nothing found ${report.measuring ? `so far (${CHECKED_MESH})` : `in what it checks (${CHECKED_MESH}${measured})`}.`, ...preliminary, ...measureLines(report), `${NOT_CHECKED}]`].join('\n'); const verdict = report.status === 'block' ? 'not ready to print' : report.status === 'warn' ? 'prints, with warnings' : 'ready to print'; - const lines = report.findings.slice(0, 12).map((finding) => line(finding, report.nozzle)); - // What does not fit the note is counted, never dropped without a word. - const rest = report.findings.length - lines.length + (report.omitted ?? 0); - if (rest > 0) lines.push(`- and ${String(rest)} more ${rest === 1 ? 'finding' : 'findings'} not listed here; the user sees them in the ready-to-print panel.`); - return [`${head} ${verdict}.`, ...lines, 'Fix what blocks printing before you call the part done; the user sees the same findings.]'].join('\n'); + return [`${head} ${verdict}.`, ...findingLines(report), ...preliminary, ...measureLines(report), `Fix what blocks printing before you call the part done; the user sees the same findings. ${NOT_CHECKED}]`].join('\n'); +} + +/** + * The check behind the tool answers of one turn (review of PR 357): in full the first time and whenever the verdict + * or a finding changed, else as one line with the measures that changed. Thirty steps of a build would otherwise + * carry thirty copies of the same note. + */ +export class CheckTold { + private verdict: string | null = null; + private readonly bodies = new Map(); + + /** A new message of the user: the next check is told in full again. */ + forget(): void { + this.verdict = null; + this.bodies.clear(); + } + + note(report: PrintCheckReport): string { + // Every finding with its faces and numbers, not the lines shown: a change in the thirteenth finding is a change. + const verdict = JSON.stringify([report.status, report.measuring === true, report.unmeasured ?? [], report.findings, report.omitted ?? 0]); + const same = verdict === this.verdict; + const changed = (body: Body) => this.bodies.get(body.name) !== measureLine(body); + const short = same + ? [ + `[PartBuddy: ready-to-print check after this change: unchanged, ${ + report.findings.length === 0 + ? 'nothing found' + : `${report.status === 'block' ? 'still not ready to print, ' : ''}the same ${String(report.findings.length + (report.omitted ?? 0))} ${report.findings.length + (report.omitted ?? 0) === 1 ? 'finding' : 'findings'} as before` + }${report.measuring ? `; clearance and joints still being measured: ${FETCH_FULL}` : ''}${(report.unmeasured ?? []).length > 0 ? `; ${unmeasuredLine(report.unmeasured ?? [])}` : '.'}`, + ...measureLines(report, 'Measures that changed:', changed), + ].join('\n') + ']' + : printCheckNote(report); + this.verdict = verdict; + this.bodies.clear(); + for (const body of report.bodies ?? []) this.bodies.set(body.name, measureLine(body)); + return short; + } +} + +/** Said behind a tool's answer in place of a check that has not caught up with the design yet. */ +export const CHECK_PENDING = + '[PartBuddy: the ready-to-print check of the latest change is still running; call partbuddy_view for it before you call the part done.]'; +/** The same from partbuddy_view itself, which must not send a small model round in circles. */ +export const CHECK_PENDING_VIEW = '[PartBuddy: the ready-to-print check of the latest change is still running; it will be here in a moment.]'; + +/** + * How long a changing tool's answer waits for the check of what it built, and how long of that for the full check + * once clearance and joints are measured as well. Measured 2026-11-12 on SolidCraft 0.40.1 without a window + * (tools/audit, check-timing): mirror and mesh check 0.21 s for a box with its lid and 0.14 s for a hinge, the + * hinge's two joint sweeps 0.5 s more; the renderer adds 0.25 s of quiet time. So the mesh check is there after + * about half a second and the full one after about one: three times that each. + */ +export const CHECK_WAIT_MS = 3000; +export const CHECK_FULL_MS = 1500; + +/** What PartBuddy holds right now: the revision the mirror last delivered and the check last reported. */ +export interface CheckState { + mirrored: { documentId: string; revision: number } | null; + report: PrintCheckReport | null; +} + +/** + * The answer of a tool that changed the design, with the check of that change behind it (audit + * R1): the model reads the findings in the turn that caused them, on every route, instead of being + * asked to fetch them. The check runs in the renderer after the mirror, so the answer waits for + * it: up to `fullMs` for the full check, then it takes the mesh check and says that it is + * preliminary, and after `waitMs` without any it says the check is still running. + */ +export async function withCheckOfChange( + result: ToolResult, + change: { documentId: string; revision: number }, + state: () => CheckState, + tell: (report: PrintCheckReport) => string = printCheckNote, + waitMs = CHECK_WAIT_MS, + fullMs = CHECK_FULL_MS, +): Promise { + const started = Date.now(); + for (;;) { + const { mirrored, report } = state(); + // SolidCraft's revision also moves without a mirror (a save), so the mirror may be past the change. + const checked = mirrored?.documentId === change.documentId && mirrored.revision >= change.revision && report?.revision === mirrored.revision ? report : null; + const waited = Date.now() - started; + if (checked && (!checked.measuring || waited >= fullMs)) return { ...result, content: [...result.content, { type: 'text', text: tell(checked) }] }; + if (waited >= waitMs) return { ...result, content: [...result.content, { type: 'text', text: checked ? tell(checked) : CHECK_PENDING }] }; + await new Promise((resolve) => setTimeout(resolve, 50)); + } } diff --git a/apps/desktop/src/main/print/printProfiles.ts b/apps/desktop/src/main/print/printProfiles.ts index 9bc4c0e1..ba13db66 100644 --- a/apps/desktop/src/main/print/printProfiles.ts +++ b/apps/desktop/src/main/print/printProfiles.ts @@ -247,10 +247,11 @@ const RULE_NAMES: Record = { printInPlace: 'print-in-place joints', details: 'refined details', snap: 'sizes snapped to nozzle and layer', + economy: 'saving material and print time', }; /** - * What a careful FDM designer does without being asked (decided 2026-11-07), in four groups the user + * What a careful FDM designer does without being asked (decided 2026-11-07), in five groups the user * can turn off: in the settings for every project, in the chat for one. The sizes come from the * printer in use, so they fit its nozzle and layer height. Sizes the user names are above all of it. */ @@ -283,11 +284,17 @@ export function designLines(design: DesignRules, printer: FitPrinter, material: lines.push( `- Sizes you choose yourself snap up to the printer: walls and ribs to whole lines of ${mm(line)} (${series(line, 2, 5)}), floors, lids, steps and chamfer heights to whole layers of ${mm(layer)} (${series(layer, 3, 6)}), floors and lids at least ${mm(minimumFloor(layer))}, print-in-place gaps ${mm(gap)}, radii and details at least ${mm(unitSize('nozzle', printer))}. Mark each such parameter's comment with the multiple, e.g. "Wall [auto 3×line]", "Floor [auto 5×layer]", "Hinge gap [auto 1×nozzle]", so PartBuddy fits it again when the user changes printer or nozzle. Sizes the user named get no mark.`, ); + // Material and print time (2026-11-12): what the shape decides of them, by the makers' design guides (Prusa, Protolabs + // Network, Markforged, Stratasys Direct); what the slicer decides is named as not the assistant's to promise. + if (design.economy) + lines.push( + `- Material and print time, from the first shape on and not as a clean-up: build a shell, not a block. Slicers print ${series(line, 2, 4)} (two to four lines) as wall and fill anything thicker with sparse infill, so stiffen with ribs as thick as the wall, at least two lines (${mm(line * 2)}), instead of thickening the wall. Keep the part low as it prints: every ${mm(layer)} of height is one more layer, and print time grows with the layers. Shape it so it needs no support, which costs more time and material than any other choice. Leave out whole regions that do nothing (an open frame instead of a plate, a shorter body); small pockets and holes in a filled region add walls and save little or nothing. Split a part only when that saves support or much height and a joint is acceptable. Loaded regions, sizes the user names and what the user wants solid stay as they are. Infill, speed and layer height are set in the slicer: never promise grams or minutes. The print check lists each body's solid volume, height, layers and support area: compare them before and after a change.`, + ); if (off.length > 0) lines.push(`- The user turned off ${off.map((rule) => RULE_NAMES[rule]).join(', ')}: do not add them unless the user asks for them.`); lines.push( - '- After each design step, name what you added on your own and the sizes you snapped in one short line, e.g. "Outer edges 2 mm rounded, hinge print-in-place with 0.4 mm gap, lid sliding fit 0.3 mm", so the user can turn it off or change it.', - `- When the user turns one of these off or back on ("no fillets", "screw the hinge instead"), call partbuddy_design_rules, so it holds for the rest of the project, also after a restart.`, + `- After each design step, name what you added on your own${design.economy ? ', what you did for material and print time' : ''} and the sizes you snapped in one short line, e.g. "Outer edges 2 mm rounded, hinge print-in-place with 0.4 mm gap, lid sliding fit 0.3 mm${design.economy ? `, shell ${mm(line * 3)} with two ribs instead of a solid block` : ''}", so the user can turn it off or change it.`, + `- When the user turns one of these off or back on ("no fillets", "screw the hinge instead", "make it solid"), call partbuddy_design_rules, so it holds for the rest of the project, also after a restart.`, ); return lines; } diff --git a/apps/desktop/src/renderer/src/i18n/de.json b/apps/desktop/src/renderer/src/i18n/de.json index 69930a08..0d3ebb8f 100644 --- a/apps/desktop/src/renderer/src/i18n/de.json +++ b/apps/desktop/src/renderer/src/i18n/de.json @@ -1033,6 +1033,10 @@ "snap": { "label": "Maße einrasten", "hint": "Selbst gewählte Wände auf ganze Linienbreiten, Böden und Höhen auf ganze Schichten. Bei einem Druckerwechsel passt PartBuddy nur diese Maße an." + }, + "economy": { + "label": "Sparsam konstruieren", + "hint": "Spart Material und Druckzeit: Schalen mit Rippen statt massiver Blöcke, geringe Bauhöhe, keine unnötigen Stützen. Funktion und Festigkeit gehen vor; Füllung und Geschwindigkeit bleiben Sache deines Slicers." } }, "add": "Drucker hinzufügen", diff --git a/apps/desktop/src/renderer/src/i18n/en.json b/apps/desktop/src/renderer/src/i18n/en.json index b9a21030..cafc4c42 100644 --- a/apps/desktop/src/renderer/src/i18n/en.json +++ b/apps/desktop/src/renderer/src/i18n/en.json @@ -1033,6 +1033,10 @@ "snap": { "label": "Snap sizes", "hint": "Walls it chooses become whole line widths, floors and heights whole layers. When you change printer, PartBuddy adjusts only these sizes." + }, + "economy": { + "label": "Design lean", + "hint": "Saves material and print time: shells with ribs instead of solid blocks, low build height, no needless support. Function and strength come first; infill and speed stay with your slicer." } }, "add": "Add printer", diff --git a/apps/desktop/src/renderer/src/print/bodyMeasures.ts b/apps/desktop/src/renderer/src/print/bodyMeasures.ts new file mode 100644 index 00000000..ef57ec2c --- /dev/null +++ b/apps/desktop/src/renderer/src/print/bodyMeasures.ts @@ -0,0 +1,84 @@ +import { Overhang, meshMetrics, overhangLimits, overhangOf, type PrintCheckBody, type PrintCheckProfile } from '@partbuddy/geometry'; +import { Quaternion, Vector3 } from 'three'; + +import type { PrintCheckReport } from '../../../shared/contract'; + +export type BodyMeasures = NonNullable[number]; +type Profile = Pick; + +const round = (value: number, digits: number) => Math.round(value * 10 ** digits) / 10 ** digits; + +/** The infill the shell estimate reckons with: Prusa and Protolabs Network name 10 to 20 % as usual. An assumption, said as one. */ +const INFILL = 0.15; + +/** + * What a body costs to print as far as its shape decides it: solid volume, height and layers as it + * lies on the bed, its surface, the area on the bed and the area that leans past the material's limit. The + * assistant reads them with every check, so it sees what a change saved without being asked to + * measure. Grams and minutes are not among them: infill, walls and speed are the slicer's. The shell + * estimate comes nearer to filament than the solid volume: two wall lines on every surface and + * sparse infill in what is left, never more than the solid. + * shortcut: the pose (print face down) and the reach of small overhangs repeat what printCheck.ts + * and overhang.ts of packages/geometry keep to themselves (`laid`, `smallOverhangReach`); take them + * from there once the package exports them. + */ +export function bodyMeasures(body: PrintCheckBody, profile: Profile): BodyMeasures { + const { model } = body; + const down = body.printFace ? model.table.faces.find((face) => face.name === body.printFace)?.planeNormal : undefined; + const turn = down ? new Quaternion().setFromUnitVectors(new Vector3(...down).normalize(), new Vector3(0, 0, -1)) : null; + const v = new Vector3(); + const laid = (source: ArrayLike) => { + const out = new Float32Array(source.length); + for (let i = 0; i < source.length; i += 3) { + v.set(source[i] ?? 0, source[i + 1] ?? 0, source[i + 2] ?? 0); + if (turn) v.applyQuaternion(turn); + out.set([v.x, v.y, v.z], i); + } + return out; + }; + const positions = laid(model.geometry.getAttribute('position').array); + const index = (model.geometry.index?.array ?? new Uint32Array()) as Uint32Array; + const { volumeMm3, bbox } = meshMetrics({ name: body.name, faceCount: model.table.faces.length, positions: Float64Array.from(positions), indices: index, triangleFaces: model.triangleFaces }); + const height = Math.max(0, bbox.max[2] - bbox.min[2]); + const map = overhangOf( + { positions, normals: laid(model.geometry.getAttribute('normal').array), index, vertexFaces: model.vertexFaces, faceCount: model.table.faces.length }, + bbox.min[2], + overhangLimits(profile.overhang), + Math.round(4 * 1.125 * profile.nozzle * 100) / 100, + ); + const solid = Math.abs(volumeMm3); + const surface = map.area.reduce((sum, area) => sum + area, 0); + const walls = Math.min(solid, surface * 2 * 1.125 * profile.nozzle); + const measures = { + // A name longer than the report takes is cut, not a reason to lose the report. + name: body.name.slice(0, 200), + volume: round(solid, 0), + shell: round(walls + (solid - walls) * INFILL, 0), + height: round(height, 1), + layers: profile.layer > 0 ? Math.ceil(height / profile.layer - 1e-6) : 0, + surface: round(surface, 0), + contact: round(map.area[Overhang.Bed], 0), + support: round(map.area[Overhang.Support], 0), + }; + const odd = Object.entries(measures).find(([, value]) => typeof value === 'number' && !(Number.isFinite(value) && value >= 0)); + if (odd) throw new Error(`${odd[0]} is ${String(odd[1])}`); + return measures; +} + +/** + * The measures of every closed body. One that cannot be measured is left out and named in `problem`, for the log: + * the check itself must still reach the assistant, which otherwise waits for it with every step. + */ +export function measuresOf(bodies: readonly PrintCheckBody[], profile: Profile): { bodies: BodyMeasures[]; problem?: string } { + const measured: BodyMeasures[] = []; + const problems: string[] = []; + for (const body of bodies) { + if (body.model.table.faces.length === 0 || body.model.openEdges > 0) continue; + try { + measured.push(bodyMeasures(body, profile)); + } catch (error) { + problems.push(`${body.name.slice(0, 40)}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return { bodies: measured, ...(problems.length > 0 ? { problem: `no measures for ${problems.join('; ')}`.slice(0, 300) } : {}) }; +} diff --git a/apps/desktop/src/renderer/src/print/report.ts b/apps/desktop/src/renderer/src/print/report.ts index 392b0d94..1c22d506 100644 --- a/apps/desktop/src/renderer/src/print/report.ts +++ b/apps/desktop/src/renderer/src/print/report.ts @@ -2,12 +2,17 @@ import type { PrintCheck } from '@partbuddy/geometry'; import type { PrintCheckReport } from '../../../shared/contract'; -/** The check without the camera boxes, as the main process words it for the assistant. */ -export function reportOf(result: PrintCheck, nozzle: number, revision: number): PrintCheckReport { +/** The check without the camera boxes, as the main process words it for the assistant; `bodies`: what each body costs to print. */ +export function reportOf(result: PrintCheck, nozzle: number, revision: number, bodies: NonNullable = [], more: Pick = {}): PrintCheckReport { return { status: result.status, nozzle, revision, + ...(more.measuring ? { measuring: true } : {}), + ...(more.unmeasured && more.unmeasured.length > 0 ? { unmeasured: more.unmeasured } : {}), + ...(more.problem ? { problem: more.problem.slice(0, 300) } : {}), + ...(bodies.length > 0 ? { bodies: bodies.slice(0, 40) } : {}), + ...(bodies.length > 40 ? { bodiesOmitted: bodies.length - 40 } : {}), ...(result.findings.length > 40 ? { omitted: result.findings.length - 40 } : {}), findings: result.findings.slice(0, 40).map((finding) => { if (finding.kind === 'size' || finding.kind === 'feature' || finding.kind === 'posed') return finding; diff --git a/apps/desktop/src/renderer/src/print/usePrintCheck.ts b/apps/desktop/src/renderer/src/print/usePrintCheck.ts index f9b61611..9dd6d25f 100644 --- a/apps/desktop/src/renderer/src/print/usePrintCheck.ts +++ b/apps/desktop/src/renderer/src/print/usePrintCheck.ts @@ -4,6 +4,7 @@ import { useEffect } from 'react'; import { platform } from '../platform'; import { useStudio } from '../state/store'; +import { measuresOf } from './bodyMeasures'; import { reportOf } from './report'; /** Quiet time after a revision before the check runs, so a burst of steps is checked once. */ @@ -38,17 +39,27 @@ export function usePrintCheck(): void { const parts = printed.filter((body) => !isLogoColourBody(body.name)); const measuring = parts.length >= 2 || joints.length > 0; useStudio.getState().setPrintCheck({ revision: model.revision, result: joints.length > 0 ? withMotion(result, { joints, stops: [] }) : result, ...(measuring ? { measuring } : {}) }); - const report = (final: typeof result) => { + // What each closed body costs to print, for the assistant to see what a change saved. + const measures = measuresOf(printed, { nozzle: print.printer.nozzle, layer: print.printer.layer, overhang: print.material.overhang }); + const report = (final: typeof result, pending = false, unmeasured: ('clearance' | 'joints')[] = []) => { + const more = { ...(pending ? { measuring: true } : {}), ...(unmeasured.length > 0 ? { unmeasured } : {}), ...(measures.problem ? { problem: measures.problem } : {}) }; void platform() - .print.reportCheck(reportOf(final, print.printer.nozzle, model.revision)) + .print.reportCheck(reportOf(final, print.printer.nozzle, model.revision, measures.bodies, more)) + // A report the main process does not take would leave the assistant waiting for a check that never comes: + // the check goes again without the measures, and says why. + .catch((error: unknown) => platform().print.reportCheck(reportOf(final, print.printer.nozzle, model.revision, [], { ...more, problem: `report refused, sent again without measures: ${String(error)}` }))) .catch(() => undefined); }; if (!measuring) { report(result); return; } + // The mesh check goes ahead at once: the assistant reads it with its tool's answer, marked as preliminary. + report(result, true); // What SolidCraft measures exactly. The mesh check shows at once; the light takes the measures // when they come, if nothing changed. A measure that fails leaves its findings out. + // A measure that fails leaves its findings out, and the check says that it does. + const failed: ('clearance' | 'joints')[] = []; const measure = async (): Promise => { let final = result; // Several parts (F2): overlaps and gaps under the push fit. @@ -61,6 +72,7 @@ export function usePrintCheck(): void { ) .catch(() => null); if (measured) final = withClearance(final, measured, pushFit); + else failed.push('clearance'); } // Parts that move (ADR 0027): every joint value runs from where it stands to its limits. if (joints.length > 0) { @@ -71,7 +83,10 @@ export function usePrintCheck(): void { ).slice(0, 40); const stopped = await platform() .design.sweepJoints(sweeps.map(({ joint, index, from, to }) => ({ joint, index, from, to }))) - .catch(() => []); + .catch(() => { + failed.push('joints'); + return []; + }); const stops = sweeps.flatMap((sweep, i) => { const stop = stopped[i]; return stop ? [{ joint: sweep.name, unit: sweep.unit, to: sweep.to, ...stop }] : []; @@ -83,7 +98,7 @@ export function usePrintCheck(): void { void measure().then((final) => { if (stale) return; useStudio.getState().setPrintCheck({ revision: model.revision, result: final }); - report(final); + report(final, false, failed); }); }, CHECK_DELAY_MS); let stale = false; diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index 55126d7d..4eae2af0 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -1085,16 +1085,17 @@ const uniqueIds = (items: readonly { id: string }[]) => new Set(items.map((item) /** * The groups of design rules the assistant applies without being asked (decided 2026-11-07): - * rounded and chamfered edges, print-in-place joints, clean details and sizes snapped to nozzle and - * layer. The user turns a group off in the settings, or for one project in the chat. + * rounded and chamfered edges, print-in-place joints, clean details, sizes snapped to nozzle and + * layer, and (2026-11-12) material and print time. The user turns a group off in the settings, or + * for one project in the chat. */ -export const DESIGN_RULES = ['rounding', 'printInPlace', 'details', 'snap'] as const; +export const DESIGN_RULES = ['rounding', 'printInPlace', 'details', 'snap', 'economy'] as const; export type DesignRule = (typeof DESIGN_RULES)[number]; export const designRulesSchema = z - .object({ rounding: z.boolean().default(true), printInPlace: z.boolean().default(true), details: z.boolean().default(true), snap: z.boolean().default(true) }) + .object({ rounding: z.boolean().default(true), printInPlace: z.boolean().default(true), details: z.boolean().default(true), snap: z.boolean().default(true), economy: z.boolean().default(true) }) .strict(); export type DesignRules = z.infer; -export const DESIGN_DEFAULTS: DesignRules = { rounding: true, printInPlace: true, details: true, snap: true }; +export const DESIGN_DEFAULTS: DesignRules = { rounding: true, printInPlace: true, details: true, snap: true, economy: true }; export const printSettingsSchema = z .object({ @@ -1169,6 +1170,27 @@ export const printCheckReportSchema = z nozzle: range(0.1, 2), /** The mirrored revision the check belongs to, so a check of an older revision reads as pending. */ revision: z.number().int().nonnegative(), + /** True for the mesh check sent ahead while SolidCraft still measures clearance and joints; the full check follows. */ + measuring: z.boolean().optional(), + /** What SolidCraft could not measure for this check (its measure failed): the check then says nothing of it. */ + unmeasured: z.array(z.enum(['clearance', 'joints'])).max(2).optional(), + /** + * Per printed body, as it lies on the bed: solid volume and the shell estimate in mm³ (two wall lines on every + * surface, 15 % infill inside), height in mm and its layers, mm² of surface, on the bed and leaning past the + * material's limit. What the shape decides of material and print time; the rest is the slicer's. + */ + bodies: z + .array( + z + .object({ name: z.string().max(200), volume: range(0, 1e12), shell: range(0, 1e12), height: range(0, 1e6), layers: z.number().int().nonnegative(), surface: range(0, 1e9), contact: range(0, 1e9), support: range(0, 1e9) }) + .strict(), + ) + .max(40) + .optional(), + /** What the renderer could not measure or report, for the log. */ + problem: z.string().max(300).optional(), + /** How many more bodies the design has than `bodies` carries. */ + bodiesOmitted: z.number().int().positive().max(100_000).optional(), }) .strict(); diff --git a/apps/desktop/src/shared/magnet.ts b/apps/desktop/src/shared/magnet.ts index 112c1f00..fe7740d3 100644 --- a/apps/desktop/src/shared/magnet.ts +++ b/apps/desktop/src/shared/magnet.ts @@ -12,9 +12,10 @@ export type MagnetHold = (typeof MAGNET_HOLDS)[number]; /** * In mm: what is added to the magnet's diameter (to drop it in on a drop of glue, or to press it in), - * how far it lies under the face so nothing stands proud, and the chamfer at the pocket's mouth. + * how far it lies under the face so nothing stands proud, the chamfer at the pocket's mouth, and the + * plastic that must stay round a pocket and under it (three lines and three layers of a 0.4 mm nozzle). */ -export const MAGNET = { room: { glue: 0.3, press: 0.1 }, sunk: 0.2, chamfer: 0.4 }; +export const MAGNET = { room: { glue: 0.3, press: 0.1 }, sunk: 0.2, chamfer: 0.4, around: 1.2, under: 0.6 }; export interface MagnetSpec { body: string; @@ -37,6 +38,17 @@ export function magnetPocket(spec: Pick round(-value / length) + 0) as Vec3; + const pocket = magnetPocket(spec); + return [spec.point, ...spec.more].map((point) => ({ at: point, base: point.map((value, k) => round(value + (axis[k] ?? 0) * from)) as Vec3, axis, diameter: round(pocket.diameter + 2 * MAGNET.around), depth: round(pocket.depth - from + MAGNET.under), from })); +} + /** What is wrong with a magnet pocket before anything is built; null when it can be built. */ export function magnetProblem(spec: MagnetSpec): string | null { if (Math.hypot(...spec.normal) < 1e-9) return 'normal must not be zero'; diff --git a/apps/desktop/src/shared/standards.ts b/apps/desktop/src/shared/standards.ts index a8286975..33e39b85 100644 --- a/apps/desktop/src/shared/standards.ts +++ b/apps/desktop/src/shared/standards.ts @@ -102,5 +102,5 @@ export const STANDARDS: Record `- ${rule}`).join('\n')}\nNone of this has been printed by PartBuddy yet. The print check in partbuddy_view still judges what you build.`; + return `${title}. Design it yourself by these rules, and tell the user where a figure is a range or our own choice:\n${rules.map((rule) => `- ${rule}`).join('\n')}\nNone of this has been printed by PartBuddy yet, and the print check does not test these rules: measure what you built against them.`; } diff --git a/apps/desktop/src/shared/stepPhrases.ts b/apps/desktop/src/shared/stepPhrases.ts index e1256648..4c1e72a6 100644 --- a/apps/desktop/src/shared/stepPhrases.ts +++ b/apps/desktop/src/shared/stepPhrases.ts @@ -322,6 +322,8 @@ export function describeToolCall(tool: string, args: Params): StepPhrase[] { 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) } }]; + if (tool === 'partbuddy_move_drawing') return [{ key: 'logoMoved', values: { name: text(args.logo, 'SVG').slice(0, 40) } }]; + if (tool === 'partbuddy_place_drawing' && args.mode !== 'part') return [{ key: 'logo', values: { name: text(args.drawing, 'SVG').slice(0, 40), mode: args.mode === 'emboss' ? 'emboss' : 'deboss' } }]; const steps = commandsOf(tool, args); const phrases = steps ? describeCommands(steps).slice(0, MAX_PHRASES_PER_STEP) : []; return phrases.length > 0 ? phrases : [{ key: 'command', values: { command: tool.slice(0, 80) } }]; diff --git a/apps/desktop/src/shared/wallMount.ts b/apps/desktop/src/shared/wallMount.ts index 2d2001b2..b3c4ad7b 100644 --- a/apps/desktop/src/shared/wallMount.ts +++ b/apps/desktop/src/shared/wallMount.ts @@ -55,6 +55,42 @@ export function keyholeDepth(screw: WoodScrew): number { return round(KEYHOLE.lip + WALL[screw].pocket + KEYHOLE.behind); } +/** + * The plastic each keyhole needs: as thick as {@link keyholeDepth} behind the entry and behind where the screw + * rests, as cylinders that start `from` mm under the face. + */ +export function keyholeStock(spec: KeyholeSpec, from: number): { at: Vec3; base: Vec3; axis: Vec3; diameter: number; depth: number; from: number }[] { + const [n, up] = [unit(spec.normal), unit(spec.up)]; + const axis = n.map((value) => round(-value) + 0) as Vec3; + const room = { axis, diameter: WALL[spec.screw].entry, depth: round(keyholeDepth(spec.screw) - from), from }; + const behind = (point: Vec3) => ({ at: point, base: point.map((value, k) => round(value + (axis[k] ?? 0) * from)) as Vec3, ...room }); + return [spec.point, ...spec.more].flatMap((rest) => [behind(rest), behind(rest.map((value, k) => round(value - (up[k] ?? 0) * KEYHOLE.travel)) as Vec3)]); +} + +/** In mm: the clearance hole that must stay under a countersink for the screw to hold the plate (three layers of 0.2 mm; our own choice). */ +export const WOOD_SCREW_UNDER = 0.6; + +/** How deep the 90° countersink of a wood screw goes. */ +export function countersinkDepth(screw: WoodScrew): number { + return round((WALL[screw].countersink - WALL[screw].clearance) / 2); +} + +/** + * The plastic one countersunk hole needs: the screw's diameter of plastic round its clearance hole (so the + * countersink keeps about 2 mm, and a 15 mm tab takes a 4 mm screw), and the plate thicker than the countersink is + * deep. `axis` is the way the hole went into the part, `from` how far under the face the cylinder starts; the hole + * itself is taken off. + */ +export function woodScrewStock(screw: WoodScrew, point: Vec3, axis: Vec3, from: number): { at: Vec3; base: Vec3; axis: Vec3; diameter: number; depth: number; from: number; hole: number; slack: number } { + const size = WALL[screw]; + const cone = Math.max(0, countersinkDepth(screw) - from); + // The 90° cone is as much wider than the clearance hole as it still has to go down. + const [wide, narrow] = [size.clearance / 2 + cone, size.clearance / 2]; + const hole = (Math.PI * cone * (wide ** 2 + wide * narrow + narrow ** 2)) / 3 + Math.PI * narrow ** 2 * WOOD_SCREW_UNDER; + // On a curved face the hole does not end where it ends on a flat one: 0.3 mm of its depth are allowed for that. + return { at: point, base: point.map((value, k) => round(value + (axis[k] ?? 0) * from)) as Vec3, axis, diameter: round(size.clearance + 2 * screw), depth: round(cone + WOOD_SCREW_UNDER), from, hole, slack: Math.PI * narrow ** 2 * 0.3 }; +} + /** What is wrong with a keyhole before anything is built; null when it can be built. */ export function keyholeProblem(spec: KeyholeSpec): string | null { if (Math.hypot(...spec.normal) < 1e-9 || Math.hypot(...spec.up) < 1e-9) return 'normal and up must not be zero'; diff --git a/apps/desktop/test/blockTool.test.ts b/apps/desktop/test/blockTool.test.ts index d2724e17..1ae7be4a 100644 --- a/apps/desktop/test/blockTool.test.ts +++ b/apps/desktop/test/blockTool.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { blockOf, knobOf, magnetOf, misplaced, wallOf, withBlockTool } from '../src/main/agents/blockTool'; -import type { UpstreamTools } from '../src/main/agents/gateProxy'; +import { FACE_DROP, blockOf, cutAxis, knobOf, magnetOf, misplaced, seatStock, shortOf, wallOf, withBlockTool } from '../src/main/agents/blockTool'; +import type { ToolResult, UpstreamTools } from '../src/main/agents/gateProxy'; import { decide } from '../src/main/agents/policy'; import { KNOB, knobProblem, knobSteps, type KnobSpec } from '../src/shared/knob'; -import { MAGNET, magnetPocket, magnetSteps } from '../src/shared/magnet'; +import { MAGNET, magnetPocket, magnetSteps, magnetStock } from '../src/shared/magnet'; import { describeToolCall } from '../src/shared/stepPhrases'; -import { KEYHOLE, WALL, keyholeDepth, keyholeProblem, keyholeSteps, woodScrewSteps, type KeyholeSpec } from '../src/shared/wallMount'; +import { KEYHOLE, WALL, keyholeDepth, keyholeProblem, keyholeSteps, keyholeStock, woodScrewSteps, woodScrewStock, type KeyholeSpec } from '../src/shared/wallMount'; describe('the function blocks for the assistant (standards)', () => { it('fills a block in from the standard tables and lays over it only what the model gave', () => { @@ -48,8 +48,8 @@ describe('the function blocks for the assistant (standards)', () => { expect(hole.content[0]).toMatchObject({ text: 'Built 2 × screwHole on Plate at [10, 10, 6]: size M3, head cap, fit normal.' }); const key = await tools.callTool('partbuddy_block', { block: 'keyhole', body: 'Plate', point: [20, 0, 80], normal: [0, -1, 0] }); expect(String(key.content[0]?.text)).toContain('Built 1 keyhole slot for 4 mm wood screws in Plate: entry 9 mm for the head, a slot 4.5 mm wide running 8 mm up to where the screw rests, and behind its 2 mm lip a pocket 3.5 mm deep for the head. The part must be at least 6.7 mm thick there.'); - // The holes need no sketch; the keyhole's sketches are drawn with the plate's component active. - expect(batches).toEqual(['solid.hole solid.hole', 'component.list', 'component.activate solid.cylinder sketch.create sketch.slot.center_to_center solid.extrude sketch.create sketch.slot.center_to_center solid.extrude component.activate']); + // The holes need no sketch; the keyhole's plastic is measured on trial, then its sketches are drawn with the plate's component active. + expect(batches).toEqual(['solid.hole solid.hole', 'component.list', 'solid.cylinder inspect.interference solid.cylinder inspect.interference sketch.inspect', 'component.activate solid.cylinder sketch.create sketch.slot.center_to_center solid.extrude sketch.create sketch.slot.center_to_center solid.extrude component.activate']); expect(await tools.callTool('partbuddy_block', { block: 'keyhole', body: 'Plate', point: [20, 0, 80] })).toMatchObject({ isError: true, content: [{ text: 'Not built: keyhole needs normal: the way out of the face against the wall, three numbers' }] }); expect(decide('partbuddy_block', { block: 'screwHole' })).toEqual({ allow: true }); expect(describeToolCall('partbuddy_block', { block: 'keyhole', screw: 4.5 })).toEqual([{ key: 'keyhole', values: { screw: 4.5 } }]); @@ -158,7 +158,7 @@ describe('pockets for disc magnets (standards)', () => { expect(steps[1]?.params).toMatchObject({ diameter: '7.1 mm', top_diameter: '6.3 mm', height: '0.4 mm', base: [10, 10, 0], axis: [0, 0, 1], operation: 'cut' }); expect(steps[2]?.params).toMatchObject({ base: [30, 10, 0] }); expect(magnetPocket({ diameter: 10, height: 3, hold: 'press' })).toEqual({ diameter: 10.1, depth: 3.2 }); - expect(MAGNET).toEqual({ room: { glue: 0.3, press: 0.1 }, sunk: 0.2, chamfer: 0.4 }); + expect(MAGNET).toEqual({ room: { glue: 0.3, press: 0.1 }, sunk: 0.2, chamfer: 0.4, around: 1.2, under: 0.6 }); }); it('fills a pocket in from the standard, says how the magnet is held and refuses what is no magnet', () => { @@ -258,11 +258,276 @@ describe('a block that lands on another body is taken back (audit W1)', () => { }; const answer = await withBlockTool(upstream).callTool('partbuddy_block', { block: 'screwHole', body: 'A', point: [30, 10, 20], more: [[40, 10, 20]] }); expect(answer).toMatchObject({ isError: true, content: [{ text: 'Not built: at this point it would change B, not only A; give a point on a face of A alone, away from other bodies' }] }); - expect(batches).toEqual(['inspect.measure document.inspect', 'solid.hole solid.hole inspect.measure', 'document.inspect', 'edit.undo edit.undo']); + // One Undo at a time, measured before each, until the design is as it was (this double takes both holes back with one); then the top component is made active. + expect(batches).toEqual(['inspect.measure document.inspect', 'solid.hole solid.hole inspect.measure', 'inspect.measure document.inspect', 'edit.undo', 'inspect.measure document.inspect', 'component.activate']); expect(String((await withBlockTool(upstream).callTool('partbuddy_block', { block: 'screwHole', body: 'Ghost', point: [30, 10, 20] })).content[0]?.text)).toBe('Not built: there is no body named Ghost; the design has A, B'); }); }); +describe('a block is built only where the part has the plastic for it (audit R54)', () => { + interface PlateOptions { + /** The plate is this thick from x = 30 on: a step. */ + thin?: number; + split?: boolean; + /** The share of its cylinder a hole takes: under 1 where its rim breaks out of the face. */ + rim?: number; + /** SolidCraft's cap on the depth of the undo history, and how many steps the design already has. */ + cap?: number; + steps?: number; + /** Undo does nothing: a block cannot be taken back. */ + stuck?: boolean; + } + /** + * A plate lying on z = 0, `thick` mm thick (`thin` from x = 30 on), as SolidCraft 0.40.1 answers: the share of a probe + * cylinder along Z that lies in it, holes drilled down from their point, each loose piece a body of its own, the undo + * depth capped. A probe that does not run along Z finds no plastic: a wrong axis shows as a refusal. + */ + function plate(thick: number, { thin = thick, split = false, rim = 1, cap = 200, steps = 1, stuck = false }: PlateOptions = {}) { + const batches: string[] = []; + const holes: { x: number; y: number; top: number; diameter: number; depth: number }[] = []; + const num = (value: unknown) => parseFloat(String(value)); + const area = (diameter: number) => (Math.PI * diameter ** 2) / 4; + const topAt = (x: number) => (x < 30 ? thick : thin); + const reach = (hole: { top: number; depth: number }) => Math.min(hole.depth, hole.top); + let active = 0; + const measured = () => { + const [solid, centre] = [100_000, [50, 20, 3]]; + const taken = holes.map((hole) => ({ volume: area(hole.diameter) * reach(hole) * rim, at: [hole.x, hole.y, hole.top - reach(hole) / 2] })); + const volume = solid - taken.reduce((sum, hole) => sum + hole.volume, 0); + const com = centre.map((value, k) => (solid * value - taken.reduce((sum, hole) => sum + hole.volume * (hole.at[k] ?? 0), 0)) / volume); + return { bodies: [{ name: 'Plate', volume_mm3: volume, center_of_mass: com }, ...(split && holes.length > 0 ? [{ name: 'Plate (1)', volume_mm3: 100, center_of_mass: [0, 0, 0] }] : [])] }; + }; + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve([]), + callTool: (_name, args) => { + const commands = args.commands as { command: string; params: Record }[]; + batches.push(commands.map((step) => step.command).join(' ')); + const trial = args.rollback === true; + let probe = { x: 0, y: 0, diameter: 0, top: 0, bottom: 0, alongZ: true }; + const results = commands.map((step) => { + if (step.command === 'component.activate') active = step.params.component as number; + if (trial && step.command === 'solid.cylinder') { + const [base, axis, height] = [step.params.base as number[], step.params.axis as number[], num(step.params.height)]; + const [z, way] = [base[2] ?? 0, axis[2] ?? 0]; + probe = { x: base[0] ?? 0, y: base[1] ?? 0, diameter: num(step.params.diameter), top: Math.max(z, z + way * height), bottom: Math.min(z, z + way * height), alongZ: Math.abs(Math.abs(way) - 1) < 1e-3 }; + } + if (step.command === 'inspect.interference') { + const within = (top: number, bottom: number) => Math.max(0, Math.min(top, probe.top) - Math.max(bottom, probe.bottom)); + const cut = holes.filter((hole) => Math.hypot(hole.x - probe.x, hole.y - probe.y) < 1e-6).reduce((sum, hole) => sum + area(hole.diameter) * within(hole.top, hole.top - reach(hole)), 0); + return { interferences: [{ a: 'Plate', b: 'PartBuddy probe 0', volume_mm3: probe.alongZ ? area(probe.diameter) * within(topAt(probe.x), 0) - cut : 0 }] }; + } + if (!trial && (step.command === 'solid.hole' || step.command === 'solid.cylinder')) { + const at = (step.params.position ?? step.params.base) as number[]; + holes.push({ x: at[0] ?? 0, y: at[1] ?? 0, top: at[2] ?? 0, diameter: num(step.params.diameter ?? '3.4'), depth: num(step.params.depth ?? step.params.height ?? '1000') }); + } + if (step.command === 'edit.undo' && !stuck) holes.pop(); + if (step.command === 'inspect.measure') return measured(); + if (step.command === 'document.inspect') return { undo: Math.min(cap, steps + holes.length), timeline: Array.from({ length: steps + holes.length }), sketches: [] }; + return {}; + }); + // The trial ends on a step that must fail; SolidCraft answers it as an error that carries the results before it. + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify(trial ? { results: results.slice(0, -1), failed: { command: 'sketch.inspect' } } : { results }) }], ...(trial ? { isError: true } : {}) }); + }, + }; + const logged: string[] = []; + return { tools: withBlockTool(upstream, () => undefined, (line) => logged.push(line)), upstream, batches, holes, logged, active: () => active }; + } + const text = async (answer: Promise) => String((await answer).content[0]?.text); + const seatNeed = 'the seat of an M3 insert needs 2 mm of plastic round it and 0.6 mm under it. Thicken the part there, move the seat away from edges and other cuts, or use a boss.'; + + it('names the plastic each block needs, measured from half a millimetre under the face on', () => { + const key: KeyholeSpec = { body: 'Plate', point: [20, 30, 0], normal: [0, 0, -1], up: [0, 1, 0], screw: 4, more: [], name: 'Keyhole' }; + // Down to 6.7 mm, where the screw rests and at the entry 8 mm below it. + expect(keyholeStock(key, FACE_DROP)).toEqual([ + { at: [20, 30, 0], base: [20, 30, 0.5], axis: [0, 0, 1], diameter: 9, depth: 6.2, from: 0.5 }, + { at: [20, 22, 0], base: [20, 22, 0.5], axis: [0, 0, 1], diameter: 9, depth: 6.2, from: 0.5 }, + ]); + // A 6 × 2 mm magnet: 6.3 + 2 × 1.2 wide, down to 0.6 mm under its 2.2 mm pocket. + expect(magnetStock({ body: 'Plate', point: [10, 10, 3], normal: [0, 0, 2], more: [[30, 10, 3]], diameter: 6, height: 2, hold: 'glue', name: 'Magnet' }, FACE_DROP)).toEqual([ + { at: [10, 10, 3], base: [10, 10, 2.5], axis: [0, 0, -1], diameter: 8.7, depth: 2.3, from: 0.5 }, + { at: [30, 10, 3], base: [30, 10, 2.5], axis: [0, 0, -1], diameter: 8.7, depth: 2.3, from: 0.5 }, + ]); + // The seat of an M3 insert: 4 + 2 × 2 wide, down to 0.6 mm under its 6.7 mm, less the seat itself. + const seat = seatStock({ block: 'insertSeat', body: 'Plate', point: [10, 10, 10], size: 'M3', diameter: 4, depth: 6.7 }, [10, 10, 10], [0, 0, -1]); + expect(seat).toMatchObject({ at: [10, 10, 10], base: [10, 10, 9.5], diameter: 8, depth: 6.8, from: 0.5 }); + expect(seat.hole).toBeCloseTo(Math.PI * 4 * 6.2, 6); + // A 4 mm wood screw: 4 mm of plastic round its 4.7 mm hole (so a 15 mm tab takes it), down to 0.6 mm of that hole under the 2.05 mm countersink. + const screw = woodScrewStock(4, [10, 10, 10], [0, 0, -1], FACE_DROP); + expect(screw).toMatchObject({ base: [10, 10, 9.5], diameter: 12.7, depth: 2.15, from: 0.5 }); + expect(screw.diameter).toBeLessThan(15); + // What the hole itself takes of that cylinder: the cone from 7.8 mm at its start down to 4.7 mm, then the hole. + expect(screw.hole).toBeCloseTo((Math.PI * 1.55 * (3.9 ** 2 + 3.9 * 2.35 + 2.35 ** 2)) / 3 + Math.PI * 2.35 ** 2 * 0.6, 6); + }); + + it('reads the way a hole went in from what it took out of the body, one hole at a time', () => { + const before = { bodies: [{ name: 'Plate', volume_mm3: 16000, center_of_mass: [20, 20, 5] }] }; + // A hole of 100 mm³ whose middle lies at [10, 10, 7], drilled from [10, 10, 10]. + const after = { bodies: [{ name: 'Plate', volume_mm3: 15900, center_of_mass: [(16000 * 20 - 100 * 10) / 15900, (16000 * 20 - 100 * 10) / 15900, (16000 * 5 - 100 * 7) / 15900] }] }; + expect(cutAxis('Plate', before, after, [10, 10, 10])).toEqual([0, 0, -1]); + expect(cutAxis('Plate', before, before, [10, 10, 10])).toBeNull(); + expect(cutAxis('Ghost', before, after, [10, 10, 10])).toBeNull(); + // A centre of mass nearer to the point than a tenth of a millimetre tells no direction. + expect(cutAxis('Plate', before, after, [10, 10, 7.05])).toBeNull(); + }); + + it('measures each of several holes along its own way in: two wood screws on a stepped plate (review 2 of PR 357)', async () => { + // 8 mm thick at x = 10, 4 mm at x = 50: the holes take unequal amounts, and their common centre lies askew under the middle. + const stepped = plate(8, { thin: 4 }); + expect(await text(stepped.tools.callTool('partbuddy_block', { block: 'woodScrew', body: 'Plate', point: [10, 20, 8], more: [[50, 20, 4]] }))).toMatch( + /^Built 2 countersunk holes .* PartBuddy measured that plastic, from 0\.5 mm under the face on: it is there\.$/, + ); + // Each hole is cut and measured by itself, then both are probed in one trial. + expect(stepped.batches).toContain('solid.hole inspect.measure solid.hole inspect.measure'); + expect(stepped.holes).toHaveLength(2); + // Two seats, one of them over a pocket that takes its lower half: only that one is short. + const pocket = plate(10, { thin: 5 }); + expect(await text(pocket.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [10, 20, 10], more: [[50, 20, 5]] }))).toContain('Not built: at [50, 20, 5] the seat would cut'); + expect(pocket.holes).toEqual([]); + }); + + it('builds no keyhole in a 3 mm plate and no magnet pocket through it, and says what it measured', async () => { + const { tools, batches } = plate(3); + expect(await text(tools.callTool('partbuddy_block', { block: 'keyhole', body: 'Plate', point: [20, 30, 3], normal: [0, 0, 1], up: [0, 1, 0] }))).toBe( + 'Not built: at [20, 30, 3] Plate has 3 mm of plastic where 6.7 mm are needed (the mean over a width of 9 mm; the far side, an edge or another cut takes the rest); a keyhole needs the part 6.7 mm thick behind its entry and its slot. Thicken the part there, move the keyhole away from edges and other cuts, or use woodScrew.', + ); + expect(await text(tools.callTool('partbuddy_block', { block: 'magnet', body: 'Plate', point: [20, 10, 3], normal: [0, 0, 1], height: 3 }))).toContain( + 'Not built: at [20, 10, 3] Plate has 3 mm of plastic where 3.8 mm are needed (the mean over a width of 8.7 mm', + ); + // Nothing but trials reached SolidCraft: no cut, so nothing to undo. + expect(batches.filter((batch) => batch.includes('solid.cylinder')).every((batch) => batch.endsWith('sketch.inspect'))).toBe(true); + expect(batches.join(' ')).not.toContain('solid.extrude'); + }); + + it('says where the plastic is missing: at the entry of a keyhole, not where the screw rests', async () => { + let probes = 0; + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve([]), + callTool: (_name, args) => { + const commands = args.commands as { command: string }[]; + // The first probe (where the screw rests) is all plastic, the second (the entry) is half in the air. + const results = commands.map((step) => (step.command === 'inspect.interference' ? { interferences: [{ volume_mm3: ((Math.PI * 81) / 4) * 6.2 * (probes++ === 0 ? 1 : 0.5) }] } : {})); + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }], isError: true }); + }, + }; + const stock = keyholeStock({ body: 'Plate', point: [20, 5, 0], normal: [0, 0, -1], up: [0, 1, 0], screw: 4, more: [], name: 'Keyhole' }, FACE_DROP); + expect(await shortOf(upstream, 'Plate', stock, undefined)).toContain('at [20, -3, 0] Plate has 3.6 mm of plastic where 6.7 mm are needed'); + }); + + it('builds them where the plastic is, and says what it measured and from where', async () => { + const { tools } = plate(10); + expect(await text(tools.callTool('partbuddy_block', { block: 'magnet', body: 'Plate', point: [20, 10, 10], normal: [0, 0, 1] }))).toMatch( + /^Built 1 pocket .* PartBuddy measured that plastic before building, from 0\.5 mm under the face on: it is there\.$/, + ); + }); + + it('says so when the plastic could not be measured, instead of passing it over', async () => { + const silent: UpstreamTools = { instructions: () => undefined, listTools: () => Promise.resolve([]), callTool: () => Promise.resolve({ content: [{ type: 'text', text: 'no' }], isError: true }) }; + expect(await shortOf(silent, 'Plate', [{ at: [0, 0, 0], base: [0, 0, 0], axis: [0, 0, 1], diameter: 9, depth: 6.7, from: 0 }], undefined)).toBeUndefined(); + }); + + it('takes back the seat of an insert that comes out of the far side, and one that leaves no floor', async () => { + const thin = plate(3); + expect(await text(thin.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [20, 20, 3] }))).toBe( + `Not built: at [20, 20, 3] the seat would cut 37.7 mm³ of plastic where its whole cylinder is 84.2 mm³: its rim breaks out of the face, into an edge, a chamfer, the far side or another cut; ${seatNeed}`, + ); + expect(thin.holes).toEqual([]); + // A plate just as thick as the seat is deep: the hole is whole, and has no floor (review of PR 357). + const flush = plate(6.7); + expect(await text(flush.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [20, 20, 6.7] }))).toBe( + `Not built: at [20, 20, 6.7] Plate has 6.52 mm of plastic where 7.3 mm are needed (the mean over a width of 8 mm; the far side, an edge or another cut takes the rest); ${seatNeed}`, + ); + expect(flush.holes).toEqual([]); + const thick = plate(10); + expect(await text(thick.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [20, 20, 10] }))).toBe( + 'Built insertSeat on Plate at [20, 20, 10]: size M3, diameter 4, depth 6.7. Each seat needs 2 mm of plastic round it and 0.6 mm under it. PartBuddy measured that plastic, from 0.5 mm under the face on, and that each seat cut its whole cylinder: it is there.', + ); + }); + + it('takes back a seat whose rim breaks into a chamfer or an edge: it cuts less than its cylinder (review 2 of PR 357)', async () => { + // At a 3 mm chamfer the seat of an M3 insert took 1.3 % less than its cylinder; the plastic lower down is all there. + const chamfer = plate(20, { rim: 0.987 }); + expect(await text(chamfer.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [20, 20, 20] }))).toContain( + 'Not built: at [20, 20, 20] the seat would cut 83.1 mm³ of plastic where its whole cylinder is 84.2 mm³: its rim breaks out of the face, into an edge, a chamfer, the far side or another cut', + ); + expect(chamfer.holes).toEqual([]); + // A curved face takes a little off the rim as well, and passes: 0.03 % on a rod of 20 mm radius, as measured. + const rod = plate(20, { rim: 0.9997 }); + expect(await text(rod.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [20, 20, 20] }))).toMatch(/^Built insertSeat /); + }); + + it('takes back a countersunk hole in a plate thinner than the countersink with its hole under it', async () => { + const thin = plate(2); + // (The double drills a plain hole, so the figure it "has" is not the engine's; that one is in the audit harness.) + expect(await text(thin.tools.callTool('partbuddy_block', { block: 'woodScrew', body: 'Plate', point: [20, 20, 2] }))).toMatch( + /^Not built: at \[20, 20, 2\] Plate has [\d.]+ mm of plastic where 2\.65 mm are needed \(the mean over a width of 12\.7 mm; the far side, an edge or another cut takes the rest\); a countersunk hole for a 4 mm wood screw needs 4 mm of plastic round its 4\.7 mm hole and a plate at least 2\.65 mm thick\. Thicken the plate, or move the hole away from edges and other cuts\.$/, + ); + expect(thin.holes).toEqual([]); + }); + + it('takes back a block that cuts the body into pieces', async () => { + expect(misplaced('Strip', new Map([['Strip', 720]]), new Map([['Strip', 300], ['Strip (1)', 300]]))).toBe( + 'it would leave 2 bodies where Strip was one (Strip (1)): the cut goes right through the part there. Give a point with more plastic round it, or a smaller size', + ); + const { tools, holes } = plate(10, { split: true }); + expect(await text(tools.callTool('partbuddy_block', { block: 'screwHole', body: 'Plate', point: [10, 10, 10], head: 'none' }))).toContain('Not built: it would leave 2 bodies where Plate was one (Plate (1))'); + expect(holes).toEqual([]); + }); +}); + +describe('a block is taken back by what the design measures, not by the depth of the undo history (review 2 of PR 357)', () => { + /** A design whose undo history is full: SolidCraft 0.40.1 reads 200 however many steps there are. */ + function capped(stuck = false) { + const batches: string[] = []; + let holes = 0; + let active = 7; + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve([]), + callTool: (_name, args) => { + const commands = args.commands as { command: string; params: Record }[]; + batches.push(commands.map((step) => step.command).join(' ')); + const results = commands.map((step) => { + if (step.command === 'solid.hole') holes += 1; + if (step.command === 'edit.undo' && !stuck) holes -= 1; + if (step.command === 'component.activate') active = step.params.component as number; + // Every hole lands on body B as well: the block must not stay. + if (step.command === 'inspect.measure') return { bodies: [{ name: 'A', volume_mm3: 16000 - holes * 50 }, { name: 'B', volume_mm3: 48000 - holes * 50 }] }; + if (step.command === 'document.inspect') return { undo: 200, timeline: Array.from({ length: 260 + holes }), sketches: [] }; + return {}; + }); + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }] }); + }, + }; + const logged: string[] = []; + return { tools: withBlockTool(upstream, () => undefined, (line) => logged.push(line)), batches, logged, left: () => holes, active: () => active }; + } + + it('undoes both holes of a block though the depth reads 200 before and after, and makes the top component active again', async () => { + const design = capped(); + const answer = await design.tools.callTool('partbuddy_block', { block: 'screwHole', body: 'A', point: [30, 10, 20], more: [[40, 10, 20]] }); + expect(answer).toMatchObject({ isError: true, content: [{ text: 'Not built: at this point it would change B, not only A; give a point on a face of A alone, away from other bodies' }] }); + expect(design.left()).toBe(0); + expect(design.batches.filter((batch) => batch === 'edit.undo')).toHaveLength(2); + expect(design.batches.at(-1)).toBe('component.activate'); + expect(design.active()).toBe(0); + expect(design.logged).toEqual([]); + }); + + it('gives up after the steps it sent and three more, and says that the block is still there', async () => { + const design = capped(true); + const answer = await design.tools.callTool('partbuddy_block', { block: 'screwHole', body: 'A', point: [30, 10, 20] }); + expect(String(answer.content[0]?.text)).toBe( + 'Not built: at this point it would change B, not only A; give a point on a face of A alone, away from other bodies. What was built of it could NOT be taken back: undo it before you call again', + ); + // One step sent, three in reserve. + expect(design.batches.filter((batch) => batch === 'edit.undo')).toHaveLength(5); + expect(design.logged).toEqual(['partbuddy_block screwHole on A could not be taken back after: at this point it would change B, not only A; give a point on a face of A alone, away from other bodies']); + expect(design.active()).toBe(0); + }); +}); + describe('what the assistant may ask of a block (audit W3, S-02, N-bloecke-4)', () => { const at = { body: 'Cube', point: [10, 10, 20] }; diff --git a/apps/desktop/test/bodyMeasures.test.ts b/apps/desktop/test/bodyMeasures.test.ts new file mode 100644 index 00000000..ef40574d --- /dev/null +++ b/apps/desktop/test/bodyMeasures.test.ts @@ -0,0 +1,96 @@ +import { buildRenderMesh, buildViewerModel, type FaceTable, type PrintCheckBody, type TaggedMesh } from '@partbuddy/geometry'; +import { describe, expect, it } from 'vitest'; + +import { printCheckNote } from '../src/main/print/printCheckNote'; +import { bodyMeasures, measuresOf } from '../src/renderer/src/print/bodyMeasures'; +import { reportOf } from '../src/renderer/src/print/report'; +import { printCheckReportSchema } from '../src/shared/contract'; + +/** A box as SolidCraft exports it: six named faces with their normals, outward triangles. */ +function box(name: string, size: [number, number, number]): PrintCheckBody { + const [x, y, z] = size; + const corners = [[0, 0, 0], [x, 0, 0], [x, y, 0], [0, y, 0], [0, 0, z], [x, 0, z], [x, y, z], [0, y, z]]; + const quads = [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]]; + const faces: [string, [number, number, number]][] = [['F1:-z', [0, 0, -1]], ['F1:+z', [0, 0, 1]], ['F1:-y', [0, -1, 0]], ['F1:+x', [1, 0, 0]], ['F1:+y', [0, 1, 0]], ['F1:-x', [-1, 0, 0]]]; + const mesh: TaggedMesh = { + name, + faceCount: 6, + positions: Float64Array.from(corners.flat()), + indices: Uint32Array.from(quads.flatMap(([a, b, c, d]) => [a, b, c, a, c, d] as number[])), + triangleFaces: Uint32Array.from(quads.flatMap((_, face) => [face, face])), + }; + const table: FaceTable = { model: 'test', body: name, units: 'mm', faces: faces.map(([faceName, planeNormal], index) => ({ index, name: faceName, exact: true, area: 0, centroid: [0, 0, 0], point: [0, 0, 0], planeNormal })) }; + return { name, model: buildViewerModel(buildRenderMesh(mesh, table), table) }; +} + +const PLA = { nozzle: 0.4, layer: 0.2, overhang: 45 }; + +describe('what a body costs to print, as the assistant reads it with every check (2026-11-12)', () => { + it('measures a 20 × 30 × 10 mm box as designed: 6 cm³, 50 layers, 600 mm² on the bed, nothing to support', () => { + expect(bodyMeasures(box('Box', [20, 30, 10]), PLA)).toEqual({ name: 'Box', volume: 6000, shell: 2583, height: 10, layers: 50, surface: 2200, contact: 600, support: 0 }); + // A coarser layer: fewer layers for the same height. + expect(bodyMeasures(box('Box', [20, 30, 10]), { ...PLA, layer: 0.3 }).layers).toBe(34); + }); + + it('measures it as it lies on its print face: stood on its small side it is twice as high', () => { + expect(bodyMeasures({ ...box('Box', [20, 30, 10]), printFace: 'F1:-x' }, PLA)).toMatchObject({ volume: 6000, height: 20, layers: 100, contact: 300, support: 0 }); + }); + + it('reckons a block mostly as sparse infill and a thin wall in full, so the shell estimate does not promise what thinning a block saves', () => { + // 40 × 40 × 20 mm: 32 cm³ solid; two lines on 6400 mm² of surface are 5.76 cm³, the rest counts at 15 %. + expect(bodyMeasures(box('Block', [40, 40, 20]), PLA)).toMatchObject({ volume: 32000, surface: 6400, shell: 5760 + Math.round((32000 - 5760) * 0.15) }); + // A wall of four lines is wall all through: nothing in it is infill. + expect(bodyMeasures(box('Wall', [40, 40, 1.8]), PLA)).toMatchObject({ volume: 2880, shell: 2880 }); + }); + + it('goes into the note one line per body, with what it is not: grams and minutes', () => { + const report = reportOf({ status: 'ready', findings: [] }, 0.4, 3, [bodyMeasures(box('Box', [20, 30, 10]), PLA), bodyMeasures(box('Pin', [3, 3, 10]), PLA)]); + expect(printCheckReportSchema.safeParse(report).success).toBe(true); + const note = printCheckNote(report); + expect(note).toContain('Solid volume overstates what thinning a thick region saves, because the slicer fills it sparsely anyway;'); + expect(note).toContain('What counts for sure: fewer layers, less area to support, less surface. Promise neither grams nor minutes:'); + expect(note).toContain('- "Box": 6 cm³ solid, about 2.58 cm³ as a shell, 10 mm high (50 layers), 2200 mm² of surface, 600 mm² on the bed, 0 mm² leaning past the overhang limit.'); + // Under 1 cm³ in mm³: a small part still shows what a change saved. + expect(note).toContain('- "Pin": 90 mm³ solid, about 90 mm³ as a shell, 10 mm high'); + // A check without measures (no closed body) says nothing of them. + expect(printCheckNote({ status: 'ready', findings: [], nozzle: 0.4, revision: 3 })).not.toContain('Per body'); + }); + + it('lists six bodies and counts the rest, also what the report itself left out', () => { + const many = Array.from({ length: 45 }, (_, i) => ({ name: `Pin ${String(i + 1)}`, volume: 100, shell: 100, height: 5, layers: 25, surface: 100, contact: 20, support: 0 })); + const report = reportOf({ status: 'warn', findings: [{ kind: 'open', severity: 'warn', bodies: ['Shell'] }] }, 0.4, 3, many); + expect(report.bodies).toHaveLength(40); + expect(report.bodiesOmitted).toBe(5); + expect(printCheckReportSchema.safeParse(report).success).toBe(true); + const note = printCheckNote(report); + expect(note).toContain('- "Pin 6": 100 mm³ solid'); + expect(note).not.toContain('"Pin 7"'); + expect(note).toContain('- and 39 more bodies.'); + }); +}); + +describe('a body that cannot be measured does not hold the check back (review of PR 357)', () => { + it('leaves that body out, names it for the log and still measures the others', () => { + const broken = box('Broken', [10, 10, 10]); + broken.model.geometry.getAttribute('position').array.fill(Number.NaN); + const { bodies, problem } = measuresOf([box('Box', [20, 30, 10]), broken], PLA); + expect(bodies.map((body) => body.name)).toEqual(['Box']); + expect(problem).toMatch(/^no measures for Broken: /); + // The report still fits the contract and carries the problem to the main process, which logs it. + const report = reportOf({ status: 'ready', findings: [] }, 0.4, 3, bodies, { ...(problem ? { problem } : {}) }); + expect(printCheckReportSchema.safeParse(report).success).toBe(true); + expect(report.problem).toContain('Broken'); + expect(measuresOf([box('Box', [20, 30, 10])], PLA).problem).toBeUndefined(); + }); + + it('cuts a name the report would refuse instead of losing the report', () => { + const { bodies } = measuresOf([box('N'.repeat(300), [10, 10, 10])], PLA); + expect(bodies[0]?.name).toHaveLength(200); + expect(printCheckReportSchema.safeParse(reportOf({ status: 'ready', findings: [] }, 0.4, 3, bodies)).success).toBe(true); + }); + + it('marks the mesh check that goes ahead of the measures', () => { + expect(reportOf({ status: 'ready', findings: [] }, 0.4, 3, [], { measuring: true }).measuring).toBe(true); + expect('measuring' in reportOf({ status: 'ready', findings: [] }, 0.4, 3)).toBe(false); + }); +}); diff --git a/apps/desktop/test/checkOfChange.test.ts b/apps/desktop/test/checkOfChange.test.ts new file mode 100644 index 00000000..b2445ad6 --- /dev/null +++ b/apps/desktop/test/checkOfChange.test.ts @@ -0,0 +1,335 @@ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { GateProxy, type ToolResult, type UpstreamTools } from '../src/main/agents/gateProxy'; +import { policyToolSet } from '../src/main/agents/loop'; +import { Questions } from '../src/main/agents/questions'; +import { CHANGING_TOOLS, UNDO_CAP, designChanged, WorkLog } from '../src/main/agents/workLog'; +import { CHECK_PENDING, CHECK_PENDING_VIEW, CheckTold, printCheckNote, withCheckOfChange, type CheckState } from '../src/main/print/printCheckNote'; +import type { PrintCheckReport } from '../src/shared/contract'; +import { describeToolCall } from '../src/shared/stepPhrases'; + +const cleanups: (() => Promise | void)[] = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); +}); + +const thinWall: PrintCheckReport['findings'][number] = { kind: 'thinWall', severity: 'block', floor: false, thickness: 0.3, minimum: 0.9, area: 400, places: [{ body: 'Plate', faces: ['F2:side:0'] }] }; +const box = { name: 'Plate', volume: 6000, shell: 2100, height: 10, layers: 50, surface: 2200, contact: 600, support: 0 }; + +interface Options { + /** How long mirror and mesh check take after a change; null: they never come. */ + checkAfterMs?: number | null; + /** How long SolidCraft measures clearance and joints after that; null: one body, nothing to measure. */ + measureMs?: number | null; + waitMs?: number; + fullMs?: number; +} + +/** + * The tool chain as index.ts builds it for every route, on a design with SolidCraft's linear history: `solid.extrude` + * leaves a 0.3 mm wall, a trial moves the revision alone, `fail` changes the design and answers as an error. + */ +function setup({ checkAfterMs = 100, measureMs = null, waitMs = 1000, fullMs = 500, steps = 0 }: Options & { steps?: number } = {}) { + // SolidCraft reads the depth of its undo history as 200 at most. + const design = { revision: 1, steps, redo: 0, get undo() { return Math.min(UNDO_CAP, this.steps); } }; + const state: CheckState = { mirrored: { documentId: 'doc-1', revision: 1 }, report: { status: 'ready', findings: [], nozzle: 0.4, revision: 1 } }; + const logged: string[] = []; + let historyFails = false; + const changed = () => { + design.revision += 1; + const built = design.revision; + if (checkAfterMs === null) return; + const report: PrintCheckReport = { status: 'block', findings: [thinWall], nozzle: 0.4, revision: built, bodies: [box] }; + setTimeout(() => { + // A later change has its own check; an older one never overwrites it. + if (state.mirrored && state.mirrored.revision > built) return; + state.mirrored = { documentId: 'doc-1', revision: built }; + state.report = measureMs === null ? report : { ...report, measuring: true }; + if (measureMs !== null) setTimeout(() => state.report?.revision === built && (state.report = report), measureMs); + }, checkAfterMs); + }; + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve(['execute', 'undo', 'partbuddy_place_drawing', 'partbuddy_move_drawing'].map((name) => ({ name, inputSchema: { type: 'object' as const } }))), + callTool: (name, args): Promise => { + if (args.command === 'solid.extrude' || args.command === 'fail' || name.endsWith('_drawing')) { + design.steps += 1; + design.redo = 0; + changed(); + } + if (name === 'undo') { + design.steps -= 1; + design.redo += 1; + changed(); + } + // A trial, a save, a block that was taken back: the revision moves, the design and its history stay. + if (args.command === 'trial') design.revision += 1; + return Promise.resolve({ content: [{ type: 'text', text: args.command === 'fail' ? 'failed half way' : 'ok' }], ...(args.command === 'fail' ? { isError: true } : {}) }); + }, + }; + const told = new CheckTold(); + const log = new WorkLog({ + history: () => (historyFails ? Promise.reject(new Error('no answer')) : Promise.resolve({ documentId: 'doc-1', revision: design.revision, undo: design.undo, redo: design.redo })), + travel: () => Promise.resolve(), + emit: () => undefined, + changed: (result, after) => withCheckOfChange(result, after, () => state, (report) => told.note(report), waitMs, fullMs), + log: (line) => logged.push(line), + }); + return { tools: log.tools(upstream), told, logged, state, failHistory: () => (historyFails = true) }; +} + +const extrude = { command: 'solid.extrude', params: { distance: 5 } }; +const BLOCKED = 'not ready to print.\n- blocks printing: walls down to 0.3 mm, thinner than the 0.4 mm nozzle'; +const textOf = (result: ToolResult) => result.content.map((part) => String(part.text)).join('\n'); + +/** What Claude Code reads: the tool result through the gate proxy. */ +async function throughProxy(tools: UpstreamTools, args: Record, name = 'execute'): Promise { + const proxy = new GateProxy(tools, () => undefined); + await proxy.start(); + cleanups.push(() => proxy.stop()); + const client = new Client({ name: 'test', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(new URL(proxy.url), { requestInit: { headers: { Authorization: `Bearer ${proxy.token}` } } })); + cleanups.push(() => client.close()); + const result = await client.callTool({ name, arguments: args }); + return (result.content as { text: string }[]).map((part) => part.text).join('\n'); +} + +/** What ChatGPT and local models read: the tool result of PartBuddy's own loop. */ +async function throughLoop(tools: UpstreamTools, args: Record, name = 'execute'): Promise { + const set = await policyToolSet(tools, { nextId: () => 'call-1', event: () => undefined }); + const execute = set[name]?.execute as (input: unknown, options: unknown) => Promise; + return execute(args, { toolCallId: 'call-1', messages: [] }); +} + +describe('the print check of a change comes back with the tool that made it (audit R1)', () => { + it('puts a blocking check into the answer of the changing call on the Claude Code route', async () => { + const text = await throughProxy(setup().tools, extrude); + expect(text).toContain('ok'); + expect(text).toContain(BLOCKED); + }); + + it('does the same in the loop of ChatGPT and local models', async () => { + expect(await throughLoop(setup().tools, extrude)).toContain(BLOCKED); + }); + + it('adds nothing to a call that changed nothing, and does not wait for it', async () => { + const read = { command: 'inspect.measure', params: {} }; + expect(await throughProxy(setup().tools, read)).toBe('ok'); + expect(await throughLoop(setup().tools, read)).toBe('ok'); + }); + + it('waits a bounded time and then says the check is still running, never an older verdict', async () => { + const started = Date.now(); + const text = await throughLoop(setup({ checkAfterMs: null, waitMs: 300 }).tools, extrude); + expect(Date.now() - started).toBeLessThan(2000); + expect(text).toBe(`ok\n${CHECK_PENDING}`); + expect(text).not.toContain('nothing found'); + }); + + it('takes a mirror that is past the change, and no check of another design', async () => { + const result: ToolResult = { content: [{ type: 'text', text: 'ok' }] }; + const report: PrintCheckReport = { status: 'block', findings: [thinWall], nozzle: 0.4, revision: 9 }; + const text = async (state: CheckState) => (await withCheckOfChange(result, { documentId: 'doc-1', revision: 7 }, () => state, printCheckNote, 0)).content.at(-1)?.text; + expect(await text({ mirrored: { documentId: 'doc-1', revision: 9 }, report })).toContain('blocks printing'); + expect(await text({ mirrored: { documentId: 'doc-2', revision: 9 }, report })).toBe(CHECK_PENDING); + expect(await text({ mirrored: { documentId: 'doc-1', revision: 9 }, report: { ...report, revision: 6 } })).toBe(CHECK_PENDING); + expect(await text({ mirrored: null, report: null })).toBe(CHECK_PENDING); + }); + + it('tells partbuddy_view to wait, not to call partbuddy_view', () => { + expect(CHECK_PENDING_VIEW).not.toContain('call partbuddy_view'); + expect(CHECK_PENDING_VIEW).toContain('in a moment'); + }); +}); + +describe('designs of several bodies: the mesh check goes ahead of the measures (review of PR 357)', () => { + it('takes the full check when SolidCraft has measured in time', async () => { + const text = textOf(await setup({ measureMs: 100 }).tools.callTool('execute', extrude)); + expect(text).toContain(BLOCKED); + expect(text).not.toContain('Preliminary'); + }); + + it('does not wait for a long measure: the mesh check comes as preliminary, with its findings and measures', async () => { + const started = Date.now(); + const text = textOf(await setup({ measureMs: 60_000, waitMs: 2000, fullMs: 300 }).tools.callTool('execute', extrude)); + expect(Date.now() - started).toBeLessThan(1500); + expect(text).toContain(BLOCKED); + expect(text).toContain('Preliminary: clearance and joints still being measured (bodies that overlap or come closer than a push fit, joints that stop before a limit); call partbuddy_view for the full check before you call the part done.'); + expect(text).toContain('- "Plate": 6 cm³ solid'); + expect(text).not.toContain(CHECK_PENDING); + }); + + it('never ends on a preliminary check without saying how to get the full one, in full and in short', () => { + const told = new CheckTold(); + const clean: PrintCheckReport = { status: 'ready', findings: [], nozzle: 0.4, revision: 2, measuring: true }; + expect(told.note(clean)).toContain('call partbuddy_view for the full check before you call the part done'); + expect(told.note({ ...clean, revision: 3 })).toBe( + '[PartBuddy: ready-to-print check after this change: unchanged, nothing found; clearance and joints still being measured: call partbuddy_view for the full check before you call the part done.]', + ); + }); + + it('does not send partbuddy_view back to partbuddy_view: from the view a preliminary check says that the rest is coming', () => { + const fromView = printCheckNote({ status: 'ready', findings: [], nozzle: 0.4, revision: 2, measuring: true }, true); + expect(fromView).toContain('Preliminary: clearance and joints still being measured (bodies that overlap or come closer than a push fit, joints that stop before a limit); the full check will be here in a moment.'); + expect(fromView).not.toContain('call partbuddy_view'); + }); + + it('says what SolidCraft could not measure instead of counting it as checked', () => { + const failed = printCheckNote({ status: 'ready', findings: [], nozzle: 0.4, revision: 2, unmeasured: ['clearance'] }); + expect(failed).toContain('nothing found in what it checks (build volume, wall and floor thickness, overhang angle, area on the bed, open bodies, failed features, bodies that touch, joints that stop before a limit).'); + expect(failed).toContain("NOT measured, because SolidCraft's measure failed: bodies that overlap or come closer than a push fit. Measure it yourself (inspect.clearance) before you call the part done."); + const both = printCheckNote({ status: 'warn', findings: [{ kind: 'open', severity: 'warn', bodies: ['A'] }], nozzle: 0.4, revision: 2, unmeasured: ['clearance', 'joints'] }); + expect(both).toContain('Measure it yourself (inspect.clearance, joint.sweep)'); + // A check that lost its measures is another check than the one before. + const told = new CheckTold(); + told.note({ status: 'ready', findings: [], nozzle: 0.4, revision: 2 }); + expect(told.note({ status: 'ready', findings: [], nozzle: 0.4, revision: 3, unmeasured: ['joints'] })).toContain('NOT measured'); + }); + + it('does not call a preliminary clean check clean of what is still measured', () => { + const note = printCheckNote({ status: 'ready', findings: [], nozzle: 0.4, revision: 1, measuring: true }); + expect(note).toContain('nothing found so far (build volume, wall and floor thickness, overhang angle, area on the bed, open bodies, failed features, bodies that touch).'); + expect(note).not.toContain('in what it checks'); + }); +}); + +describe('only a call that changed the design gets a check (review of PR 357)', () => { + it('reads a moved revision alone as no change: a trial, a save, a block that was taken back', async () => { + expect(designChanged({ documentId: 'a', revision: 4, undo: 2, redo: 1 }, { documentId: 'a', revision: 9, undo: 2, redo: 1 })).toBe(false); + // A block that was built and undone again leaves more to redo, and the design as it was. + expect(designChanged({ documentId: 'a', revision: 4, undo: 2, redo: 0 }, { documentId: 'a', revision: 9, undo: 2, redo: 3 })).toBe(false); + expect(designChanged({ documentId: 'a', revision: 4, undo: 2, redo: 0 }, { documentId: 'a', revision: 5, undo: 3, redo: 0 })).toBe(true); + expect(designChanged({ documentId: 'a', revision: 4, undo: 2, redo: 1 }, { documentId: 'a', revision: 5, undo: 1, redo: 2 })).toBe(true); + // One step undone and another built in its place: the depth is the same, what Redo held is gone. + expect(designChanged({ documentId: 'a', revision: 4, undo: 2, redo: 1 }, { documentId: 'a', revision: 6, undo: 2, redo: 0 })).toBe(true); + expect(designChanged({ documentId: 'a', revision: 4, undo: 2, redo: 0 }, { documentId: 'b', revision: 1, undo: 0, redo: 0 })).toBe(true); + const started = Date.now(); + expect(textOf(await setup({ checkAfterMs: null, waitMs: 1500 }).tools.callTool('execute', { command: 'trial' }))).toBe('ok'); + expect(Date.now() - started).toBeLessThan(1000); + }); + + it('still gives the check once the undo history is full: 200 before, 200 after (review 2 of PR 357)', async () => { + // Measured at SolidCraft 0.40.1: a step past the cap moves the revision and leaves undo at 200 and redo at 0. + expect(designChanged({ documentId: 'a', revision: 300, undo: 200, redo: 0 }, { documentId: 'a', revision: 301, undo: 200, redo: 0 })).toBe(true); + // A call that only read moves nothing, at the cap as below it. + expect(designChanged({ documentId: 'a', revision: 300, undo: 200, redo: 0 }, { documentId: 'a', revision: 300, undo: 200, redo: 0 })).toBe(false); + const { tools } = setup({ steps: 260 }); + expect(textOf(await tools.callTool('execute', extrude))).toContain(BLOCKED); + expect(textOf(await tools.callTool('execute', extrude))).toContain('ready-to-print check after this change: unchanged'); + expect(textOf(await tools.callTool('execute', { command: 'inspect.measure' }))).toBe('ok'); + }); + + it('gives the check to a call that failed half way and left the design changed', async () => { + const text = textOf(await setup().tools.callTool('execute', { command: 'fail' })); + expect(text).toContain('failed half way'); + expect(text).toContain(BLOCKED); + }); + + it('gives it after undo as well', async () => { + const { tools } = setup(); + await tools.callTool('execute', extrude); + expect(textOf(await tools.callTool('undo', {}))).toContain('ready-to-print check'); + }); + + it('gives each of two calls sent together the check of its own change or a later one, never an older', async () => { + const { tools, state } = setup(); + const [first, second] = await Promise.all([tools.callTool('execute', extrude), tools.callTool('execute', extrude)]); + // Both end on the check of revision 3, the design as it is after both. + expect(state.report?.revision).toBe(3); + expect(textOf(first)).toContain('ready-to-print check'); + expect(textOf(second)).toContain('ready-to-print check'); + expect(textOf(first) + textOf(second)).not.toContain('nothing found'); + }); + + it('says in the log when the history cannot be read, since the answer then carries no check', async () => { + const { tools, logged, failHistory } = setup(); + failHistory(); + expect(textOf(await tools.callTool('execute', extrude))).toBe('ok'); + expect(logged).toEqual(["the design's history could not be read round execute; its answer carries no print check"]); + }); +}); + +describe('the drawing tools change the design like any other (review of PR 357)', () => { + it('lists every tool of PartBuddy that builds, moves or removes geometry', () => { + // partbuddy_view, _standard, _ask, _plan, _assembly and _design_rules change no geometry. + expect([...CHANGING_TOOLS].filter((name) => name.startsWith('partbuddy_')).sort()).toEqual(['partbuddy_block', 'partbuddy_mechanism', 'partbuddy_move_drawing', 'partbuddy_place_drawing']); + }); + + it('names them in the work log as the user\'s own logo steps are named', () => { + expect(describeToolCall('partbuddy_place_drawing', { drawing: 'svg-1', mode: 'emboss', depth: 1 })).toEqual([{ key: 'logo', values: { name: 'svg-1', mode: 'emboss' } }]); + expect(describeToolCall('partbuddy_move_drawing', { logo: 'SVG-Logo Badge', by: [1, 0] })).toEqual([{ key: 'logoMoved', values: { name: 'SVG-Logo Badge' } }]); + }); + + it('puts the check behind a placed and a moved logo on both routes', async () => { + expect(await throughProxy(setup().tools, { drawing: 'svg-1', mode: 'deboss', depth: 1 }, 'partbuddy_place_drawing')).toContain(BLOCKED); + expect(await throughLoop(setup().tools, { logo: 'SVG-Logo Badge', by: [1, 0] }, 'partbuddy_move_drawing')).toContain(BLOCKED); + }); + + it('holds them back while a question is open', async () => { + const questions = new Questions(() => undefined); + const tools = questions.tools(setup().tools); + await tools.callTool('partbuddy_ask', { questions: [{ question: 'Which side?', options: [{ label: 'Top' }, { label: 'Front' }] }] }); + for (const name of ['partbuddy_place_drawing', 'partbuddy_move_drawing']) { + const answer = await tools.callTool(name, {}); + expect(answer.isError).toBe(true); + expect(textOf(answer)).toContain('The user has not answered your question yet'); + } + }); +}); + +describe('the same check is not told in full with every step (review of PR 357)', () => { + const blocked: PrintCheckReport = { status: 'block', findings: [thinWall], nozzle: 0.4, revision: 2, bodies: [box] }; + + it('tells it in full once, then in one line, and names only the measures that changed', () => { + const told = new CheckTold(); + expect(told.note(blocked)).toBe(printCheckNote(blocked)); + expect(told.note({ ...blocked, revision: 3 })).toBe('[PartBuddy: ready-to-print check after this change: unchanged, still not ready to print, the same 1 finding as before.]'); + const lower = { ...blocked, revision: 4, bodies: [{ ...box, height: 8, layers: 40 }, { ...box, name: 'Lid' }] }; + expect(told.note(lower)).toBe( + '[PartBuddy: ready-to-print check after this change: unchanged, still not ready to print, the same 1 finding as before.\nMeasures that changed:\n' + + '- "Plate": 6 cm³ solid, about 2.1 cm³ as a shell, 8 mm high (40 layers), 2200 mm² of surface, 600 mm² on the bed, 0 mm² leaning past the overhang limit.\n' + + '- "Lid": 6 cm³ solid, about 2.1 cm³ as a shell, 10 mm high (50 layers), 2200 mm² of surface, 600 mm² on the bed, 0 mm² leaning past the overhang limit.]', + ); + // Thirty steps on a clean part: one full note, then a line each. + const clean: PrintCheckReport = { status: 'ready', findings: [], nozzle: 0.4, revision: 5, bodies: [box] }; + const notes = Array.from({ length: 30 }, (_, i) => told.note({ ...clean, revision: 5 + i })); + expect(notes[0]).toBe(printCheckNote(clean)); + expect(new Set(notes.slice(1))).toEqual(new Set(['[PartBuddy: ready-to-print check after this change: unchanged, nothing found.]'])); + expect(notes.join('').length).toBeLessThan(printCheckNote(clean).length + 29 * 90); + }); + + it('tells it in full again when a finding changed, the measures came in, or the user wrote', () => { + const told = new CheckTold(); + told.note(blocked); + const thinner = { ...blocked, findings: [{ ...thinWall, thickness: 0.2 }] }; + expect(told.note(thinner)).toBe(printCheckNote(thinner)); + expect(told.note({ ...thinner, measuring: true })).toContain('Preliminary'); + expect(told.note(thinner)).toBe(printCheckNote(thinner)); + told.forget(); + expect(told.note(thinner)).toBe(printCheckNote(thinner)); + }); + + it('reads a change in any finding as a change, also past the twelve it lists and the faces it shows (review 2 of PR 357)', () => { + const wall = (i: number): PrintCheckReport['findings'][number] => ({ kind: 'feature', severity: 'warn', feature: `Shell${String(i)}`, message: 'thin result' }); + const many: PrintCheckReport = { status: 'warn', findings: Array.from({ length: 14 }, (_, i) => wall(i)), nozzle: 0.4, revision: 2 }; + const told = new CheckTold(); + told.note(many); + // The fourteenth finding is another one: the lines shown are the same, the check is not. + const other = { ...many, revision: 3, findings: [...many.findings.slice(0, 13), wall(99)] }; + expect(told.note(other)).toBe(printCheckNote(other)); + // A fifth body in a finding that names four: the same. + const places = (count: number) => Array.from({ length: count }, (_, i) => ({ body: `Pin ${String(i)}`, faces: [] })); + const loose = (count: number): PrintCheckReport => ({ status: 'warn', nozzle: 0.4, revision: 4, findings: [{ kind: 'contact', severity: 'warn', area: 2, bodies: 6, places: places(count) }] }); + told.note(loose(5)); + const moved = { ...loose(5), findings: [{ kind: 'contact' as const, severity: 'warn' as const, area: 2, bodies: 6, places: [...places(4), { body: 'Peg', faces: [] }] }] }; + expect(told.note(moved)).toBe(printCheckNote(moved)); + expect(told.note({ ...moved, revision: 9 })).toContain('unchanged'); + }); + + it('keeps it short through the tool chain: the second step of a turn gets one line', async () => { + const { tools } = setup(); + expect(textOf(await tools.callTool('execute', extrude))).toContain(BLOCKED); + expect(textOf(await tools.callTool('execute', extrude))).toBe('ok\n[PartBuddy: ready-to-print check after this change: unchanged, still not ready to print, the same 1 finding as before.]'); + }); +}); diff --git a/apps/desktop/test/designRules.test.ts b/apps/desktop/test/designRules.test.ts index 403d69c2..f6f5d66d 100644 --- a/apps/desktop/test/designRules.test.ts +++ b/apps/desktop/test/designRules.test.ts @@ -28,6 +28,7 @@ const MARKERS: Record = { printInPlace: 'first look for a print-in-place solution', details: 'chamfer hole and screw entries', snap: 'Sizes you choose yourself snap up to the printer', + economy: 'Material and print time, from the first shape on', }; const small = { nozzle: 0.4, layer: 0.2 }; @@ -39,7 +40,7 @@ describe('design rules in the print note (decided 2026-11-07)', () => { const note = printNote(profiles.part()); for (const marker of Object.values(MARKERS)) expect(note).toContain(marker); expect(note).toContain('Sizes the user names are fixed: build them exactly'); - expect(note).toContain('name what you added on your own and the sizes you snapped in one short line'); + expect(note).toContain('name what you added on your own, what you did for material and print time and the sizes you snapped in one short line'); expect(note).toContain('walls and ribs to whole lines of 0.45 mm (0.9, 1.35, 1.8, 2.25 mm)'); expect(note).toContain('to whole layers of 0.2 mm (0.6, 0.8, 1, 1.2 mm)'); expect(note).toContain('print-in-place gaps 0.4 mm'); @@ -89,7 +90,7 @@ describe('design rules in the print note (decided 2026-11-07)', () => { expect(profiles.part().design).toMatchObject({ rounding: false, details: false }); // The chat turns rounding back on for this project; details keep the setting. profiles.setDesign({ rounding: true, printInPlace: false }); - expect(profiles.part().design).toEqual({ rounding: true, printInPlace: false, details: false, snap: true }); + expect(profiles.part().design).toEqual({ rounding: true, printInPlace: false, details: false, snap: true, economy: true }); // null hands the group back to the setting. profiles.setDesign({ rounding: null }); expect(profiles.part().design.rounding).toBe(false); @@ -99,6 +100,44 @@ describe('design rules in the print note (decided 2026-11-07)', () => { }); }); +describe('material and print time as a rule group of its own (2026-11-12)', () => { + it('is on by default, from the first shape on, with walls and layers of the printer in use', () => { + const { profiles } = setup(); + const note = printNote(profiles.part()); + // 0.4 mm nozzle, 0.2 mm layer: lines of 0.45 mm. + expect(note).toContain('Slicers print 0.9, 1.35, 1.8 mm (two to four lines) as wall and fill anything thicker with sparse infill'); + // Ribs are no thinner than the thinnest wall that prints (review of PR 357: half a 0.9 mm wall is one line). + expect(note).toContain('stiffen with ribs as thick as the wall, at least two lines (0.9 mm), instead of thickening the wall'); + expect(note).not.toContain('three quarters'); + expect(note).toContain('every 0.2 mm of height is one more layer'); + expect(note).toContain('shell 1.35 mm with two ribs instead of a solid block'); + // It never outranks function, and it promises nothing the slicer decides. + expect(note).toContain('Loaded regions, sizes the user names and what the user wants solid stay as they are.'); + expect(note).toContain('never promise grams or minutes'); + expect(note).toContain('A refinement never makes function or printing worse'); + const settings = profiles.settings(); + profiles.save({ ...settings, printers: settings.printers.map((printer) => ({ ...printer, nozzle: 0.6, layer: 0.3 })) }); + const wide = printNote(profiles.part()); + expect(wide).toContain('Slicers print 1.35, 2.025, 2.7 mm (two to four lines) as wall'); + expect(wide).toContain('at least two lines (1.35 mm)'); + expect(wide).toContain('every 0.3 mm of height is one more layer'); + }); + + it('stays off once the user turned it off in the chat, and the report line asks for nothing of it', async () => { + const { profiles, make } = setup(); + const tools = withDesignTool({ instructions: () => undefined, listTools: () => Promise.resolve([]), callTool: () => Promise.reject(new Error('upstream')) }, (change) => profiles.setDesign(change).design); + const reply = await tools.callTool(DESIGN_TOOL, { economy: false }); + expect(reply.content[0]).toMatchObject({ text: 'Kept for this project. On: rounding, printInPlace, details, snap. Off: economy.' }); + // After a restart and after another group changed, it is still off. + make().setDesign({ rounding: false }); + const note = printNote(make().part()); + expect(note).not.toContain(MARKERS.economy); + expect(note).toContain('The user turned off rounded and chamfered edges, saving material and print time'); + expect(note).toContain('name what you added on your own and the sizes you snapped in one short line'); + expect(note).not.toContain('instead of a solid block'); + }); +}); + describe('sizes fitted to nozzle and layer', () => { it('snaps the assistant\'s sizes for 0.4 / 0.2', () => { expect(snapSize('wall', 1.2, small)).toBe(1.35); diff --git a/apps/desktop/test/printCheckNote.test.ts b/apps/desktop/test/printCheckNote.test.ts index 68032295..974ab399 100644 --- a/apps/desktop/test/printCheckNote.test.ts +++ b/apps/desktop/test/printCheckNote.test.ts @@ -5,8 +5,14 @@ import { reportOf } from '../src/renderer/src/print/report'; import { printCheckReportSchema } from '../src/shared/contract'; describe('the ready-to-print check as the assistant reads it (M7)', () => { - it('says plainly when nothing was found', () => { - expect(printCheckNote({ status: 'ready', findings: [], nozzle: 0.4, revision: 1 })).toContain('ready to print, nothing found'); + it('says what it looked at when nothing was found, and what it did not (audit R3)', () => { + const clean = printCheckNote({ status: 'ready', findings: [], nozzle: 0.4, revision: 1 }); + expect(clean).toContain('nothing found in what it checks (build volume, wall and floor thickness, overhang angle, area on the bed,'); + expect(clean).toContain('Not checked: strength and layer direction under load, the fits and hole allowances you chose,'); + // No verdict the model could hand on as "ready to print". + expect(clean).not.toMatch(/ready to print[,.]/); + // With findings the limits stand as well. + expect(printCheckNote({ status: 'warn', nozzle: 0.4, revision: 1, findings: [{ kind: 'open', severity: 'warn', bodies: ['A'] }] })).toContain('Not checked: strength'); }); it('words every finding with its numbers and quoted face names, blocking ones first', () => { diff --git a/apps/desktop/test/standardTool.test.ts b/apps/desktop/test/standardTool.test.ts index de98411c..4e46561a 100644 --- a/apps/desktop/test/standardTool.test.ts +++ b/apps/desktop/test/standardTool.test.ts @@ -20,7 +20,7 @@ describe('the standards the assistant looks up', () => { const feet = String((await tools.callTool('partbuddy_standard', { topic: 'feet' })).content[0]?.text); expect(feet).toContain('Feet and what a part stands on. Design it yourself by these rules'); expect(feet).toContain('SJ5003 11.1 × 5.0'); - expect(feet).toContain('None of this has been printed by PartBuddy yet.'); + expect(feet).toContain('None of this has been printed by PartBuddy yet, and the print check does not test these rules'); expect(await tools.callTool('partbuddy_standard', { topic: 'gear' })).toMatchObject({ isError: true, content: [{ text: `topic must be one of ${STANDARD_TOPICS.join(', ')}` }] }); expect(await tools.callTool('inspect_design', {})).toMatchObject({ content: [{ text: 'upstream inspect_design' }] }); expect(decide('partbuddy_standard', { topic: 'feet' })).toEqual({ allow: true });