diff --git a/apps/desktop/e2e/solidcraft.contract.spec.ts b/apps/desktop/e2e/solidcraft.contract.spec.ts index f69f224..95984f9 100644 --- a/apps/desktop/e2e/solidcraft.contract.spec.ts +++ b/apps/desktop/e2e/solidcraft.contract.spec.ts @@ -3281,8 +3281,8 @@ test('a hinge whose knuckles start above the bed carries them on webs at 45°: o 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 hinged case'); await page.getByRole('button', { name: 'Send' }).click(); - await expect(page.getByText(/Built the hinge "Case hinge" between Base \(stays\) and Flap \(turns\): 3 knuckles/)).toBeVisible({ timeout: 60_000 }); - await expect(page.getByText(/3 knuckles start above the bed and stand on webs at 45° down to their own walls/)).toBeVisible(); + await expect(page.getByText(/Built the hinge "Case hinge" between Base \(stays\) and Flap \(turns\): 5 knuckles/)).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText(/All 5 knuckles start above the bed and stand on webs at 45° that run from under the barrel down to their own wall, and the pin is flat underneath where it bridges from knuckle to knuckle, so the hinge prints as it stands, without support./)).toBeVisible(); // With the webs the flap still folds all the way onto the base. await expect(page.getByText(/It turns from -179° to/)).toBeVisible(); await expect(page.getByText(/Warning:/)).toHaveCount(0); @@ -3292,8 +3292,8 @@ test('a hinge whose knuckles start above the bed carries them on webs at 45°: o const findings = page.getByTestId('print-check-panel'); await expect(findings).toBeVisible(); await page.waitForTimeout(1500); - // What is left hanging is the pin where it bridges the gaps between the knuckles. - await expect(findings.locator('[data-kind="overhang"]')).toContainText('25 mm²'); + // Nothing is left hanging: the webs pass under the whole barrel, and the pin is flat where it bridges 6.5 mm from knuckle to knuckle. + await expect(findings.locator('[data-kind="overhang"]')).toHaveCount(0); await expect(findings.locator('[data-kind="blocked"], [data-kind="overlap"], [data-kind="thinWall"]')).toHaveCount(0); await page.screenshot({ path: test.info().outputPath('case-hinge.png') }); } finally { @@ -3322,7 +3322,7 @@ test('the assistant builds a print-in-place hinge with one call: one piece per b await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a hinged flap'); await page.getByRole('button', { name: 'Send' }).click(); // PartBuddy's answer to the tool: the gap is the nozzle of the printer in use, and the limits are where the plates meet. - await expect(page.getByText(/Built the hinge "Flap hinge" between Base \(stays\) and Flap \(turns\): 3 knuckles, barrel 6 mm, pin 3 mm, gap 0\.4 mm everywhere\. It turns from -145° to 67°/)).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText(/Built the hinge "Flap hinge" between Base \(stays\) and Flap \(turns\): 5 knuckles, barrel 6 mm, pin 3 mm, gap 0\.4 mm everywhere\. It turns from -145° to 67°/)).toBeVisible({ timeout: 60_000 }); // The line was measured against both plates on trial, and the answer says how deep the knuckles' rooms go into each. // The line lies 2 mm from each plate, and a room is 3.4 mm about it. const rooms = page.getByText(/The knuckles' rooms go 1\.4 mm into Base and 1\.4 mm into Flap/); @@ -3781,7 +3781,7 @@ test('a hinge whose line is out of reach of the bodies is taken back, with where 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 flap out of reach'); await page.getByRole('button', { name: 'Send' }).click(); - await expect(page.getByText(/Not built: 3 piece\(s\) did not touch their body and stand apart from it\. Put the hinge line within 2\.5 mm of both bodies .* Everything was taken back; call again with the line moved\./)).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText(/Not built: 5 piece\(s\) did not touch their body and stand apart from it\. Put the hinge line within 2\.5 mm of both bodies .* Everything was taken back./)).toBeVisible({ timeout: 60_000 }); // The two plates are as they were: no knuckle, no cut, no joint. await expect(page.getByTestId('mesh-summary')).toContainText('2 bodies · 12 faces', { timeout: 30_000 }); await expect(page.getByTestId('joints')).toHaveCount(0); diff --git a/apps/desktop/src/main/agents/mechanismTool.ts b/apps/desktop/src/main/agents/mechanismTool.ts index abd089d..ded5b32 100644 --- a/apps/desktop/src/main/agents/mechanismTool.ts +++ b/apps/desktop/src/main/agents/mechanismTool.ts @@ -1,6 +1,9 @@ -import { LID, LIP, hingeProblem, hingeSteps, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, pinDiameter, screwLength, screwProblem, screwSteps, slideProblem, slideSteps, snapProblem, snapSteps, latchProblem, latchReach, latchSteps, LATCH, pegLength, pegProblem, pegSteps, PEG, SNAP, type HingeSpec, type HingeSide, type LatchSpec, type LidSpec, type LipSpec, type PegSpec, type ScrewSpec, type SlideSpec, type SnapSpec } from '../../shared/mechanisms'; +import { LID, LIP, WEB, hangs, hingeProblem, hingeSteps, pinBridge, webFoot, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, pinDiameter, screwLength, screwProblem, screwSteps, slideProblem, slideRoof, slideSteps, snapProblem, snapSteps, latchProblem, latchReach, latchSteps, LATCH, pegLength, pegProblem, pegSteps, PEG, SNAP, type HingeSpec, type HingeSide, type LatchSpec, type LidSpec, type LipSpec, type PegSpec, type ScrewSpec, type SlideSpec, type SnapSpec } from '../../shared/mechanisms'; import { BLOCK_SIZES, INSERT, type BlockSize } from '../../shared/screwSizes'; +import { BRIDGE_SPAN } from '@partbuddy/geometry'; + import { componentOf } from '../solidcraft/actions'; +import { markOf, takeBack, type Mark, type Run } from './takeBack'; import type { ToolResult, UpstreamTool, UpstreamTools } from './gateProxy'; /** @@ -17,10 +20,10 @@ const vector = { type: 'array', items: { type: 'number' }, minItems: 3, maxItems const mechanismTool: UpstreamTool = { name: MECHANISM_TOOL, description: - 'Build a print-in-place mechanism between two bodies that exist, printed in one piece without support, with the same gap everywhere. ' + - 'hinge: knuckles in turn on both bodies and a pin through them. Give the hinge line (from, to) in the gap between the bodies, level with the bed and half the barrel diameter above it when the part prints lying flat; ' + - 'the line must lie closer to both bodies than the barrel radius less 0.5 mm, so the knuckles grow out of them; PartBuddy cuts each knuckle\'s room in the other body itself, a gap wider than the barrel, and carries knuckles that start above the bed on webs at 45° down to their own walls: add no supports of your own. Give wall, the thickness of the walls along the line: a room that would leave less than two lines of a wall behind it is refused, with how far to move the line. ' + - 'slide: a dovetail rail on the fixed body and its groove in the moving body, which lies over the rail a gap above the face. Give the middle line of the rail (from, to) on that face and the face normal (up). ' + + 'Build a mechanism between two bodies that exist, with the same gap everywhere; the answer says how it prints. ' + + 'hinge: knuckles in turn on both bodies and a pin through them, printed in place as one piece. Give the hinge line (from, to) in the gap between the bodies, level with the bed and half the barrel diameter above it when the part prints lying flat; ' + + 'the line must lie closer to both bodies than the barrel radius less 0.5 mm, so the knuckles grow out of them; PartBuddy cuts each knuckle\'s room in the other body itself, a gap wider than the barrel, and carries knuckles that start above the bed on webs at 45° down to their own walls, where the wall reaches about one and a quarter barrel diameters below the line (the answer says where it does not, and what then needs support): add no supports of your own. Give wall, the thickness of the walls along the line: a room that would leave less than two lines of a wall behind it is refused, with how far to move the line. ' + + 'slide: a dovetail rail on the fixed body and its groove in the moving body, which lies over the rail a gap above the face. Give the middle line of the rail (from, to) on that face and the face normal (up). The two print apart and the moving body is pushed onto the rail from an end. Where the moving body stands high enough over that face (the answer names how high) the groove gets a roof at 45° and prints as it stands; otherwise its ceiling is flat and the answer says how to lay the part. ' + 'PartBuddy also joins the two with a joint, finds how far it moves before the bodies meet (a slide: while the moving body stays on its rail) and sets that as its limits; the user then moves it with a slider in the view. ' + 'closure: a snap closure between a lid and its box, nothing standing out: a lip inside the lid that goes into the box along its wall and centres the lid, a bead that snaps into a rille of the box\'s wall, and a notch for a fingernail. ' + 'Give from and to as opposite corners of the lid\'s inner room at its rim (both at the rim\'s height), up as the way the lip stands out of the lid (+Z when the lid lies open side up, -Z when it sits on the box), wall and radius of the lid, and the side with the catch (a long wall, opposite the hinge). ' + @@ -120,8 +123,9 @@ export function hingeOf(args: Record, nozzle: number): HingeSpe const spec: HingeSpec = { ...rest, diameter: typeof args.diameter === 'number' ? args.diameter : 6, - // Knuckles of at most 10 mm where the line allows: inside each one the pin bridges from neighbour to neighbour. - knuckles: typeof args.knuckles === 'number' ? args.knuckles : ([3, 5, 7, 9].find((count) => length / count <= 10) ?? 9), + // Knuckles of at most 10 mm where the line allows. Lying, it is the pin's bridge that counts: inside each knuckle of the + // moving body it bridges from one fixed knuckle to the next, a gap further on each side than the knuckle is long. + knuckles: typeof args.knuckles === 'number' ? args.knuckles : ([3, 5, 7, 9].find((count) => (Math.abs(rest.to[2] - rest.from[2]) < 1e-6 ? (length + (count + 1) * rest.gap) / count : length / count) <= 10) ?? 9), }; return hingeProblem(spec) ?? spec; } @@ -242,12 +246,17 @@ export function screwOf(args: Record): ScrewSpec | string { /** The occurrence that places a body, from `component.list`; null while the body is still in the design itself. */ export function occurrenceOf(listing: unknown, body: string): string | null { + return placed(listing, body)?.name ?? null; +} + +/** That occurrence with its id, which is what `joint.list` names a joint's two sides by. */ +function placed(listing: unknown, body: string): { id?: unknown; name: string } | null { const components = (listing as { components?: unknown } | undefined)?.components; if (!Array.isArray(components)) return null; for (const entry of components as { parent?: unknown; bodies?: unknown; occurrences?: unknown }[]) { if (entry.parent === null || !Array.isArray(entry.bodies) || !entry.bodies.includes(body)) continue; - const name = (Array.isArray(entry.occurrences) ? (entry.occurrences[0] as { name?: unknown } | undefined) : undefined)?.name; - if (typeof name === 'string') return name; + const occurrence = Array.isArray(entry.occurrences) ? (entry.occurrences[0] as { id?: unknown; name?: unknown } | undefined) : undefined; + if (typeof occurrence?.name === 'string') return { id: occurrence.id, name: occurrence.name }; } return null; } @@ -276,7 +285,18 @@ function reach(sweep: unknown, to: number, step: number): number { return typeof stopped === 'number' && Number.isFinite(stopped) ? Math.trunc(stopped / step) * step : to; } -type Run = (commands: Step[]) => Promise; +/** + * One call of the tool: how it reaches SolidCraft, and the design as it was before the first step was built (null + * when it could not be read, unset while nothing is begun), so a call that fails later is taken back. `shares` is the + * joint a second hinge on the same line goes on turning about. + */ +interface Site { + upstream: UpstreamTools; + run: Run; + callId: string | undefined; + mark: { state?: Mark | null }; + shares?: { name: string; limits: [number, number] }; +} /** Keeps the face a mechanism laid its part on, by the face's name, for the plate view. */ type KeepFace = (body: string, face: string) => void; const round2 = (value: number) => Math.round(value * 100) / 100; @@ -307,12 +327,22 @@ export function activated(steps: Step[], listing: unknown): Step[] { /** * Builds the solids. A joined piece that does not touch its body stays in it as a separate piece, * and SolidCraft says so in the step's warning (0.40.0-rc.3: "… is now in 2 separate pieces"); it - * means the line is not where the bodies are: everything is taken back (practice run 2026-11-10: the model kept a gap between the + * means the line is not where the bodies are: the call fails and with that everything is taken back (practice run 2026-11-10: the model kept a gap between the * hinge line and the walls, mended the loose knuckles with webs, and their warnings stayed in the * timeline), and `where` tells the model where the line goes. Volume the two still share is a warning. + * + * A cut that leaves a new body behind has cut its body apart (a groove's roof through a plate thinner than it is high: + * SolidCraft answers `created_bodies` and no warning): that fails the call too, whatever the mechanism. + * + * With `joins` the mechanism ends in a joint of that type: a second one between the same two bodies is refused, unless + * it is a hinge on the line of the first (two short hinges on a long flap), which shares that joint. A + * name another joint has gets a number, in `spec` itself, before the steps are made. SolidCraft takes a name twice, and + * sweep and limits by that name then met the first joint (audit 2026-11-12, N-mechanik-3: the answer gave the first + * hinge's limits, the second had none, and a third call built a third joint between the same parts). */ -async function build(upstream: UpstreamTools, run: Run, spec: { fixed: string; moving: string }, steps: Step[] | (() => Step[]), where: string, callId: string | undefined, makes = false, ready: () => Promise = () => Promise.resolve()): Promise { - const before = jsonOf(await upstream.callTool('inspect_design', {}, callId)) as { bodies?: { name?: unknown }[]; undo?: unknown }; +async function build(site: Site, spec: { fixed: string; moving: string; name: string; from?: Vec3; to?: Vec3 }, steps: Step[] | (() => Step[]), where: string, makes = false, ready: () => Promise = () => Promise.resolve(), joins?: 'revolute' | 'slider'): Promise { + const { upstream, run, callId, mark } = site; + const before = jsonOf(await upstream.callTool('inspect_design', {}, callId)) as { bodies?: { name?: unknown }[] }; const bodies = (before.bodies ?? []).map((body) => body.name); // A mechanism that makes its moving body (a lid) needs the name free. if (makes && bodies.includes(spec.moving)) throw new Error(`a body ${JSON.stringify(spec.moving)} exists already; give the lid another name, or delete that body first`); @@ -327,40 +357,99 @@ async function build(upstream: UpstreamTools, run: Run, spec: { fixed: string; m .filter((joint) => (joint.values ?? []).some((value) => typeof value.value === 'number' && Math.abs(value.value) > 1e-6)) .map((joint) => String(joint.name)); if (away.length > 0) throw new Error(`the joint(s) ${away.map((name) => JSON.stringify(name)).join(', ')} stand away from 0; drive them back with joint.drive first, so the bodies are where they were designed`); + if (joins) { + const list = ((joints as { joints?: unknown } | undefined)?.joints ?? []) as { name?: unknown; type?: unknown; a?: unknown; b?: unknown; frame_a?: { origin?: unknown; z?: unknown }; values?: { limits?: unknown }[] }[]; + const [a, b] = [placed(listing, spec.fixed), placed(listing, spec.moving)]; + const side = (occurrence: { id?: unknown; name: string }, value: unknown) => value === occurrence.id || value === occurrence.name; + const same = a && b ? list.find((joint) => joint.type === joins && ((side(a, joint.a) && side(b, joint.b)) || (side(b, joint.a) && side(a, joint.b)))) : undefined; + const kind = joins === 'revolute' ? 'hinge' : 'guide'; + if (same) { + // On the line of the first hinge the second turns about the same axis: it binds nothing and needs no joint of its own. + const [origin, z] = [vec3(same.frame_a?.origin), vec3(same.frame_a?.z)]; + const line = spec.from && spec.to ? spec.to.map((value, i) => value - (spec.from?.[i] ?? 0)) : null; + const off = (vector: number[], along: number[]) => Math.hypot(...[0, 1, 2].map((i) => (vector[(i + 1) % 3] ?? 0) * (along[(i + 2) % 3] ?? 0) - (vector[(i + 2) % 3] ?? 0) * (along[(i + 1) % 3] ?? 0))) / Math.hypot(...along); + const onLine = joins === 'revolute' && origin && z && line && spec.from && off(z, line) < 1e-3 * Math.hypot(...z) && off(origin.map((value, i) => value - (spec.from?.[i] ?? 0)), line) < 0.01; + // On the stretch of the first itself the new knuckles would be cut into the old ones (audit 2026-11-12, H-02): the + // first joint lies in the middle of its hinge, so a line that holds that point lies over it. + const at = onLine ? origin.reduce((sum, value, i) => sum + (value - (spec.from?.[i] ?? 0)) * (line[i] ?? 0), 0) / line.reduce((sum, value) => sum + value * value, 0) : -1; + // A hinge that reaches only partly over the first, or over a second one that has no joint of its own, holds no + // such point (review 3 of PR #358): those are known by their pins. + const over = at >= 0 && at <= 1 ? String(same.name) : onLine && spec.from ? await pinOver(run, spec.from, line) : null; + if (over !== null) throw new Error(`the hinge ${JSON.stringify(over)} already joins ${spec.fixed} and ${spec.moving} on this stretch of the line, and new knuckles would be cut into its own. Change that one instead: take it back with undo, or delete its features and its joint, then call again. A second hinge goes on the same line beside the first.`); + if (!onLine) throw new Error(`the joint ${JSON.stringify(same.name)} already joins ${spec.fixed} and ${spec.moving} as a ${kind}${joins === 'revolute' ? ' on another line' : ''}, and a second ${joins === 'revolute' ? 'axis' : 'guide'} between the same two bodies would bind it. ${joins === 'revolute' ? 'Put this hinge on the line of the first, or change the first' : 'Change that one instead'}: take it back with undo, or delete its features and its joint, then call again.`); + const limits = same.values?.[0]?.limits; + site.shares = { name: String(same.name), limits: Array.isArray(limits) && typeof limits[0] === 'number' && typeof limits[1] === 'number' ? [limits[0], limits[1]] : [-180, 180] }; + } + const taken = new Set(list.map((joint) => joint.name)); + const asked = spec.name; + for (let n = 2; taken.has(spec.name); n += 1) spec.name = `${asked} (${String(n)})`; + } // The bodies are there and stand as designed: what must be measured on them before anything is built. await ready(); - const built = await run(activated(typeof steps === 'function' ? steps() : steps, listing)); + // From here on the design changes: a call that fails later is taken back to this state. + mark.state = await markOf(run); + const sent = activated(typeof steps === 'function' ? steps() : steps, listing); + const built = await run(sent); + for (const [i, step] of sent.entries()) { + const result = built[i] as { created_bodies?: unknown; removed_bodies?: unknown } | undefined; + const made = [result?.created_bodies, result?.removed_bodies].flatMap((names) => (Array.isArray(names) ? (names as unknown[]) : [])); + // A step that makes its own body (a lid, a screw) says so; every other one works on a body that is there. + if (made.length === 0 || step.params.operation === 'new' || step.command === 'parts.insert') continue; + throw new Error(`the step ${JSON.stringify(step.params.name ?? step.command)} cut ${JSON.stringify((step.params.targets as unknown[] | undefined)?.[0] ?? step.params.body ?? 'a body')} apart (${made.map((name) => JSON.stringify(name)).join(', ')} came of it): the body is too thin or too small where the mechanism goes.`); + } const apart = built.filter((result) => { const warning = (result as { warning?: unknown } | undefined)?.warning; return typeof warning === 'string' && warning.includes('separate pieces'); }).length; - const [after, clash] = await run([ - { command: 'document.inspect', params: {} }, - { command: 'inspect.interference', params: { bodies: [spec.fixed, spec.moving] } }, - ]); + if (apart > 0) throw new Error(`${String(apart)} piece(s) did not touch their body and stand apart from it. ${where}`); + const [clash] = await run([{ command: 'inspect.interference', params: { bodies: [spec.fixed, spec.moving] } }]); const overlaps = ((clash as { count?: unknown } | undefined)?.count as number | undefined) ?? 0; - if (apart > 0) { - const loose = `${String(apart)} piece(s) did not touch their body and stand apart from it. ${where}`; - const undone = (after as { undo?: unknown } | undefined)?.undo; - if (typeof before.undo !== 'number' || typeof undone !== 'number' || undone <= before.undo) throw new Error(`${loose} Undo what was built, then call again.`); - for (let step = before.undo; step < undone; step += 1) await upstream.callTool('undo', {}, callId); - throw new Error(`${loose} Everything was taken back; call again with the line moved.`); - } return overlaps > 0 ? ' Warning: the two bodies still share volume away from the mechanism; make room between them.' : ''; } +/** + * The name of a hinge whose pin lies on the stretch `from`–`from + line`, or null. Every hinge keeps its pin as the + * feature " ●", and `timeline.inspect` says where that cylinder starts and how long it is (SolidCraft 0.40.1). + * shortcut: a pin that was renamed, or whose body was moved since, is not found; then only the first joint's point is known. + */ +async function pinOver(run: Run, from: Vec3, line: number[]): Promise { + try { + const [design] = await run([{ command: 'document.inspect', params: { timeline: 'summary' } }]); + const timeline = (design as { timeline?: unknown } | undefined)?.timeline; + const pins = (Array.isArray(timeline) ? (timeline as { id?: unknown; name?: unknown }[]) : []).filter((entry) => typeof entry.name === 'string' && entry.name.endsWith(' ●')); + if (pins.length === 0) return null; + const read = (await run(pins.map((pin) => ({ command: 'timeline.inspect', params: { feature: pin.id } })))) as ({ suppressed?: unknown; definition?: { base?: unknown; axis?: unknown; height?: unknown } } | undefined)[]; + const length = Math.hypot(...line); + const along = (vector: number[]) => vector.reduce((sum, value, i) => sum + value * ((line[i] ?? 0) / length), 0); + for (const [n, pin] of read.entries()) { + const [base, axis, height] = [vec3(pin?.definition?.base), vec3(pin?.definition?.axis), Number.parseFloat(String(pin?.definition?.height))]; + if (!base || !axis || !(height > 0) || pin?.suppressed === true) continue; + const rel = base.map((value, i) => value - (from[i] ?? 0)); + const [start, turn] = [along(rel), along(axis) / Math.hypot(...axis)]; + // Beside the line, or across it: another hinge. + if (Math.sqrt(Math.max(0, rel.reduce((sum, value) => sum + value * value, 0) - start * start)) > 0.01 || Math.abs(turn) < 0.999) continue; + const [low, high] = turn > 0 ? [start, start + height] : [start - height, start]; + if (low < length - 1e-6 && high > 1e-6) return String(pins[n]?.name).slice(0, -2); + } + } catch { + // Not readable: the first joint's point was tried already. + } + return null; +} + /** Makes the two bodies components unless they are, joins them where they are and sets the limits: the range clear of contact within `range`. */ -async function join(run: Run, spec: { fixed: string; moving: string; from: Vec3; to: Vec3; name: string }, type: 'revolute' | 'slider', range: [number, number], step: number): Promise<[number, number] | string> { +async function join(run: Run, spec: { fixed: string; moving: string; from: Vec3; to: Vec3; name: string }, type: 'revolute' | 'slider', range: [number, number], step: number, shares?: string): Promise<[number, number] | string> { let [listing] = await run([{ command: 'component.list', params: {} }]); const loners = [spec.fixed, spec.moving].filter((name) => occurrenceOf(listing, name) === null); if (loners.length > 0) [, listing] = await run([{ command: 'component.from_bodies', params: { bodies: loners } }, { command: 'component.list', params: {} }]); const a = occurrenceOf(listing, spec.fixed); const b = occurrenceOf(listing, spec.moving); - if (!a || !b) throw new Error('the solids are built, but the bodies did not become components; join them with joint.as_built'); + if (!a || !b) throw new Error(`${spec.fixed} and ${spec.moving} did not become components, so no joint could join them.`); const middle = spec.from.map((value, i) => (value + (spec.to[i] ?? value)) / 2); const axis = spec.to.map((value, i) => value - (spec.from[i] ?? 0)); - const sweeps = range.filter((to) => to !== 0).map((to) => ({ command: 'joint.sweep', params: { joint: spec.name, from: 0, to } })); - await run([{ command: 'joint.as_built', params: { type, a, b, at: { occurrence: a, point: middle, z: axis }, name: spec.name, quiet: true } }]); + const joint = shares ?? spec.name; + const sweeps = range.filter((to) => to !== 0).map((to) => ({ command: 'joint.sweep', params: { joint, from: 0, to } })); + if (!shares) await run([{ command: 'joint.as_built', params: { type, a, b, at: { occurrence: a, point: middle, z: axis }, name: spec.name, quiet: true } }]); // SolidCraft refuses a sweep it cannot afford, such as parts that lie against each other along the way: the joint stays, without limits. let results: unknown[]; try { @@ -370,7 +459,7 @@ async function join(run: Run, spec: { fixed: string; moving: string; from: Vec3; } const limit = (to: number) => (to === 0 ? 0 : reach(results[sweeps.findIndex((sweep) => sweep.params.to === to)], to, step)); const limits: [number, number] = [limit(range[0]), limit(range[1])]; - await run([{ command: 'joint.limits', params: { joint: spec.name, min: limits[0], max: limits[1] } }]); + await run([{ command: 'joint.limits', params: { joint, min: limits[0], max: limits[1] } }]); return limits; } @@ -382,6 +471,10 @@ const PROBE = 'PartBuddy probe'; * reach it). A wall thicker than that by less than two lines is left as a skin behind the room (practice run * 2026-11-12: 0.8 mm of a 1.8 mm wall). Measured on trial with a thin rod on the line: a step that must fail ends the * batch, and SolidCraft takes the rod back. Null when it could not be measured. + * + * `support` names the bed and, for the webs of a hinge that hangs, each body whose wall reaches down to where its web + * would meet it: tried with a second rod inside that wall, just under the web's foot. A web drawn to the wall's plane + * where the wall had ended hung 4.6 mm in the air under a lid plate (audit 2026-11-12, N-mechanik-5). */ export async function roomDepths(upstream: UpstreamTools, spec: HingeSpec, callId: string | undefined): Promise<{ fixed: number; moving: number; support?: NonNullable } | null> { try { @@ -429,14 +522,71 @@ export async function roomDepths(upstream: UpstreamTools, spec: HingeSpec, callI return { toward: [off[0] / level, off[1] / level, 0], distance: Math.round(level * 100) / 100 }; }; const lows = (((reply.results?.[2] as { bodies?: { bbox?: { min?: unknown } }[] } | undefined)?.bodies ?? []) as { bbox?: { min?: unknown } }[]).flatMap((body) => vec3(body.bbox?.min)?.[2] ?? []); - const [fixed, moving] = [side(spec.fixed), side(spec.moving)]; - const support = fixed && moving && lows.length > 0 ? { bed: Math.min(...lows), fixed, moving } : undefined; - return { fixed: depth(spec.fixed), moving: depth(spec.moving), ...(support ? { support } : {}) }; + if (lows.length === 0) return { fixed: depth(spec.fixed), moving: depth(spec.moving) }; + const bed = Math.min(...lows); + const sides = hangs(spec, bed) ? await carried(upstream, spec, bed, { fixed: side(spec.fixed), moving: side(spec.moving) }, callId) : {}; + return { fixed: depth(spec.fixed), moving: depth(spec.moving), support: { bed, ...sides } }; } catch { return null; } } +/** How thick a trial rod is (mm). */ +const ROD = 0.2; + +/** + * Thin rods along a line, tried in the design and taken back like the rod of `roomDepths`: for each, the share of it + * (0 to 1) that lies inside `body`. A rod wholly in the body says there is material all along that line. + */ +async function rodsInside(upstream: UpstreamTools, rods: { name: string; base: number[]; body: string }[], axis: number[], length: number, callId: string | undefined): Promise> { + const shares = new Map(); + if (rods.length === 0 || length <= 0) return shares; + const answer = await upstream.callTool( + 'batch', + { + commands: [ + ...rods.map((rod) => ({ command: 'solid.cylinder', params: { diameter: ROD, height: length, base: rod.base, axis, operation: 'new', body_name: rod.name } })), + { command: 'inspect.clearance', params: { bodies: rods.map((rod) => rod.name), against: [...new Set(rods.map((rod) => rod.body))], min_volume: 0 } }, + { command: 'sketch.inspect', params: { sketch: 2147483647 } }, + ], + rollback: true, + }, + callId, + ); + const text = answer.content.find((part) => part.type === 'text')?.text; + const found = ((JSON.parse(typeof text === 'string' ? text : '') as { results?: unknown[] }).results?.[rods.length] as { overlaps?: unknown } | undefined)?.overlaps; + // A rod or the comparison that failed ends the batch before its answer: that says nothing about the body. + if (!Array.isArray(found)) throw new Error('the trial gave no answer'); + // Measured on SolidCraft 0.40.1 (2026-11-12): a rod wholly in a body of 30, 100 and 300 mm comes back with this + // volume to nine digits, and one through a 1.5 mm gap with 1.500 mm less. + const whole = (Math.PI * ROD * ROD * length) / 4; + for (const rod of rods) { + const inside = (found as { a?: unknown; b?: unknown; volume_mm3?: unknown }[]).find((pair) => [pair.a, pair.b].includes(rod.name) && [pair.a, pair.b].includes(rod.body))?.volume_mm3; + shares.set(rod.name, typeof inside === 'number' ? inside / whole : 0); + } + return shares; +} + +/** + * Of the walls beside a hinge line, those that can carry a web: the web ends on the bed, or a rod inside the wall just + * under the web's foot lies in the body along the whole line. + */ +async function carried(upstream: UpstreamTools, spec: HingeSpec, bed: number, sides: { fixed: HingeSide | null; moving: HingeSide | null }, callId: string | undefined): Promise<{ fixed?: HingeSide; moving?: HingeSide }> { + const axis = spec.to.map((value, i) => value - (spec.from[i] ?? 0)); + const tried = (['fixed', 'moving'] as const).flatMap((key) => { + const wall = sides[key]; + if (!wall) return []; + const drop = webFoot(spec.diameter, wall.distance) + ROD; + // null: the web ends on the bed before it comes to the wall's foot. + const base = spec.from[2] - drop <= bed ? null : spec.from.map((value, i) => value + (wall.toward[i] ?? 0) * (wall.distance + WEB.into / 2) - (i === 2 ? drop : 0)); + return [{ key, wall, base, name: `${PROBE} ${key}` }]; + }); + const shares = await rodsInside(upstream, tried.flatMap((entry) => (entry.base ? [{ name: entry.name, base: entry.base, body: spec[entry.key] }] : [])), axis, Math.hypot(...axis), callId).catch(() => new Map()); + const out: { fixed?: HingeSide; moving?: HingeSide } = {}; + for (const entry of tried) if (entry.base === null || (shares.get(entry.name) ?? 0) >= 0.9) out[entry.key] = entry.wall; + return out; +} + /** What is wrong with the walls behind the knuckles' rooms, when the model said how thick they are; null when they print. */ export function skinProblem(depths: { fixed: number; moving: number }, spec: { fixed: string; moving: string }, wall: number, nozzle: number): string | null { const least = Math.round(nozzle * 2 * 1.125 * 100) / 100; @@ -453,72 +603,189 @@ export function skinProblem(depths: { fixed: number; moving: number }, spec: { f const MOVE = 'The user moves it with the slider "Moving parts" in the view.'; /** What the model hears when the joint is made but its range could not be found. */ -const unswept = (reason: string) => - `SolidCraft could not run it through its range (${reason.slice(0, 160)}), so no limits are set. The two bodies probably lie against each other somewhere: look with inspect.clearance, make room, then set joint.limits yourself after joint.sweep.`; +const unswept = (reason: string, shares?: string) => + `SolidCraft could not run it through its range (${reason.slice(0, 160)}), so ${shares ? `the limits of "${shares}" stay as they were, untried with this hinge` : 'no limits are set'}. The two bodies probably lie against each other somewhere: look with inspect.clearance, make room, then set joint.limits yourself after joint.sweep.`; + +/** What leans 45° prints without support only in a material that allows it: PETG and the carbon-filled ones stop at 40°. */ +const leans = (what: string, overhang: number) => `${what} lean 45°, more than the ${String(overhang)}° the material in use prints without support: they need support there, or a material that prints 45°.`; -async function buildMechanism(upstream: UpstreamTools, args: Record, nozzle: number, callId: string | undefined, keepFace: KeepFace): Promise { +/** + * What a lying hinge that hangs above the bed needs to print, from what was measured and built: said only as far as it + * holds. "Stand on webs …, so they print without support" stood over 90 to 619 mm² the print check still found + * (audit 2026-11-12, N-mechanik-4), and over webs that had no wall under them (N-mechanik-5). + */ +function hingePrint(spec: HingeSpec, depths: Awaited>, overhang: number): string { + if (Math.abs(spec.to[2] - spec.from[2]) > 1e-6) return ''; + const bridge = pinBridge(spec); + // Nine knuckles is the most one hinge has: a longer line takes two hinges, which share their joint. + const sags = bridge > BRIDGE_SPAN ? ` The pin bridges ${String(Math.round(bridge * 10) / 10)} mm inside each knuckle of ${spec.moving}, more than prints clean (${String(BRIDGE_SPAN)} mm): ${spec.knuckles < 9 ? 'use more knuckles' : 'build two shorter hinges on the same line instead of this one'}.` : ''; + const support = depths?.support; + if (!support) return ` PartBuddy could not measure the bed and the walls beside the line, so no knuckle got a web: those that start above the bed need support.${sags}`; + if (!hangs(spec, support.bed)) return sags; + const count = { fixed: (spec.knuckles + 1) / 2, moving: (spec.knuckles - 1) / 2 }; + const bare = (['fixed', 'moving'] as const).filter((key) => !support[key]); + const webs = `stand on webs at 45° that run from under the barrel down to their own wall`; + if (bare.length === 0) { + const all = ` All ${String(spec.knuckles)} knuckles start above the bed and ${webs}, and the pin is flat underneath where it bridges from knuckle to knuckle`; + if (overhang < 45) return `${all}. ${leans('Webs, barrels and the bore', overhang)}${sags}`; + return `${all}${sags ? '.' : ', so the hinge prints as it stands, without support.'}${sags}`; + } + const hung = bare.map((key) => `the ${String(count[key])} of ${spec[key]}`).join(' and '); + const held = bare.length === 1 ? ` The ${String(count[bare[0] === 'fixed' ? 'moving' : 'fixed'])} knuckles of ${spec[bare[0] === 'fixed' ? 'moving' : 'fixed']} ${webs}.` : ''; + return ` The knuckles start above the bed, and ${hung} got no web: a web meets its wall ${String(webFoot(spec.diameter, spec.diameter / 2 - 0.5))} mm below the line at the most, and there is no wall there. They need support, or a wall that reaches down that far before the hinge is built.${held}${sags}`; +} + +/** + * Builds the rail and the groove of a guide or a dovetail. Whether the moving body is solid where the groove's roof + * goes is tried first, with rods along the groove over the ridge and over both flanks, two lines of plastic above + * them: only then does the groove get its roof. The body's box does not say it: a plate with a knob on it is high + * enough by its box, and the roof cut it in two along the rail under "Neither needs support" (review of PR #358). + * Without a roof the ceiling stays flat and `print` says what that means (audit 2026-11-12, T5: "printed in one piece + * without support" over a ceiling of 13.9 mm). + */ +async function buildGuide(site: Site, spec: SlideSpec, nozzle: number, overhang: number, joins?: 'slider'): Promise<{ warnings: string; measure: unknown; print: string }> { + const roof = slideRoof(spec); + const skin = nozzle * 2 * 1.125; + const needed = Math.round((roof.top + skin) * 10) / 10; + let measure: unknown; + // Why the groove got no roof, when it is not that the body was found short of material there. + // 'partly': every rod lies mostly in the body, and `short` mm of the worst one do not. + const roofless: { why: 'failed' | 'askew' | 'partly' | null; short: number } = { why: null, short: 0 }; + const warnings = await build( + site, + spec, + () => slideSteps(spec), + `Put the line from–to on a face of ${spec.fixed}, with up as that face's normal, so the rail stands on it.`, + false, + async () => { + [measure] = await site.run([{ command: 'inspect.measure', params: { bodies: [spec.moving] } }]); + const travel = railTravel(measure, spec.from, spec.to); + if (!travel) throw new Error(`${spec.moving} could not be measured, so neither the room for the groove's roof nor how far the guide may travel is known`); + const axis = spec.to.map((value, i) => value - (spec.from[i] ?? 0)) as Vec3; + const length = Math.hypot(...axis); + const unit = (vector: number[]) => vector.map((value) => value / Math.hypot(...vector)); + const [along, up] = [unit(axis), unit(spec.up)]; + const across = [0, 1, 2].map((i) => (up[(i + 1) % 3] ?? 0) * (along[(i + 2) % 3] ?? 0) - (up[(i + 2) % 3] ?? 0) * (along[(i + 1) % 3] ?? 0)); + // The stretch of the line the body lies over: its travel either way is what of the rail it leaves free. + const [start, covered] = [-travel[0], length + travel[0] - travel[1]]; + // Over the ridge and its two edges, and over the middle of each flank: each rod's top where the skin over the roof ends. + const flank = (roof.ceiling + roof.ridge) / 4; + const [ridge, slope] = [roof.top + skin - ROD / 2, (spec.height + spec.gap + roof.top) / 2 + skin * Math.SQRT2 - ROD / 2]; + const over: [number, number][] = [[0, ridge], [roof.ridge / 2, ridge], [-roof.ridge / 2, ridge], [flank, slope], [-flank, slope]]; + const rods = over.map(([x, y], n) => ({ name: `${PROBE} ${String(n + 1)}`, body: spec.moving, base: spec.from.map((value, i) => value + (along[i] ?? 0) * start + (across[i] ?? 0) * x + (up[i] ?? 0) * y) })); + const shares = await rodsInside(site.upstream, rods, axis, covered, site.callId).catch(() => null); + // No more than a rod's width of any rod may lie outside the body: through a cross groove the roof would break out. + spec.roof = shares !== null && covered > 0 && rods.every((rod) => (1 - (shares.get(rod.name) ?? 0)) * covered <= ROD); + // shortcut: the rods are as long as the body's box along the line, so over a line askew to the axes (or a body + // turned on its face) they reach past the body and no roof is built; lay them by the body itself when that turns up. + // shortcut: ends that are not flat (a round or chamfered body) leave the rods short there, and the groove gets + // no roof though the body is full over it; the answer says so. Try only the stretch each rod can lie in when it turns up. + const least = shares === null ? 0 : Math.min(...rods.map((rod) => shares.get(rod.name) ?? 0)); + roofless.short = Math.round((1 - least) * covered * 10) / 10; + roofless.why = spec.roof ? null : shares === null ? 'failed' : along.filter((value) => Math.abs(value) > 1e-6).length > 1 ? 'askew' : least >= 0.5 ? 'partly' : null; + }, + joins, + ); + const untried = roofless.why; + const apart = `The two print apart, each with up pointing up, and ${spec.moving} is then pushed onto the rail from an end.`; + const wide = String(Math.round(roof.ceiling * 10) / 10); + const flanks = spec.roof ? "The rail's flanks and the roof over the groove" : "The rail's flanks"; + const steep = overhang < 45 ? leans(flanks, overhang) : null; + const solid = `solid up to ${String(needed)} mm over the face the rail is on`; + const box = "the line runs askew to the axes, where the trial goes by the body's box and can reach past the body"; + const ends = `material is missing at that height along up to ${String(roofless.short)} mm of it: at ends that are round, chamfered or turned, or in a gap across the groove`; + const print = spec.roof + ? `${apart} ${steep ?? `Neither needs support: the rail's flanks and the roof over the groove lean 45°, and the roof's ridge is a bridge of ${String(roof.ridge)} mm.`}` + : roof.ceiling <= BRIDGE_SPAN + ? `${apart} The groove's ceiling is flat and ${wide} mm wide, which prints as a bridge (up to ${String(BRIDGE_SPAN)} mm)${steep ? `. ${steep}` : ", and the rail's flanks lean 45°: neither needs support."} ${untried === 'failed' ? `Whether ${spec.moving} is ${solid} could not be tried (SolidCraft's trial failed)` : untried === 'askew' ? `The trial did not find ${spec.moving} ${solid} all along the groove (${box})` : untried === 'partly' ? `${spec.moving} is ${solid} along most of the groove only (${ends})` : `${spec.moving} is not ${solid} all along the groove`}, so the groove got no roof.` + : `${apart} The groove's ceiling is flat and ${wide} mm wide, more than prints as a bridge (${String(BRIDGE_SPAN)} mm): a roof at 45° over it needs ${spec.moving} to be ${solid}, all along the groove, and ${untried === 'failed' ? "whether it is could not be tried (SolidCraft's trial failed)" : untried === 'askew' ? `the trial did not find it so (${box})` : untried === 'partly' ? `it is along most of it only (${ends})` : 'it is not'}. Print ${spec.moving} turned over instead, lying on its top with the groove open upwards (set that face as its print face), or ${untried ? 'make sure it is' : 'make it'} that high there and build this again.${steep ? ` ${steep}` : ''}`; + return { warnings, measure, print }; +} + +/** + * A call ends in one of two states: built, with what is missing said in the answer, or not built and the design as it + * was. A failure after the first step (the joint refused, the travel not measurable) left knuckles and cuts behind under + * "Not built", and the next call built them twice (audit 2026-11-12, H-02). + */ +async function buildMechanism(upstream: UpstreamTools, args: Record, nozzle: number, callId: string | undefined, keepFace: KeepFace, overhang: number): Promise { const run: Run = async (commands) => { - const reply = jsonOf(await upstream.callTool('batch', { commands }, callId)) as { results?: unknown[]; failed?: { command?: unknown; error?: unknown } }; - if (reply.failed) throw new Error(`SolidCraft refused ${String(reply.failed.command)}: ${String(reply.failed.error).slice(0, 200)}`); + const result = await upstream.callTool('batch', { commands }, callId); + const text = result.content.find((part) => part.type === 'text')?.text; + let reply: { results?: unknown[]; failed?: { command?: unknown; error?: unknown } } | undefined; + try { + reply = JSON.parse(typeof text === 'string' ? text : '') as typeof reply; + } catch { + reply = undefined; + } + // SolidCraft marks a batch with a failed step as an error and still answers in JSON: only the step and its reason go on. + if (reply?.failed) throw new Error(`SolidCraft refused ${String(reply.failed.command)}: ${String(reply.failed.error).slice(0, 200)}`); + if (result.isError || !reply) throw new Error(typeof text === 'string' ? text.slice(0, 300) : 'SolidCraft gave no answer'); return reply.results ?? []; }; + const site: Site = { upstream, run, callId, mark: {} }; + try { + return await mechanism(site, args, nozzle, keepFace, overhang); + } catch (cause) { + const message = (cause as Error).message; + // Unset: the call was refused before anything was built. + if (!('state' in site.mark)) throw cause; + throw new Error(`${message.replace(/\.?$/, '.')} ${(await takeBack(run, site.mark.state ?? null)) ? 'Everything was taken back.' : 'What was begun could not be taken back: undo it before you call again.'}`, { cause }); + } +} + +async function mechanism(site: Site, args: Record, nozzle: number, keepFace: KeepFace, overhang: number): Promise { + const { upstream, run, callId } = site; if (args.kind === 'hinge') { const spec = hingeOf(args, nozzle); if (typeof spec === 'string') throw new Error(spec); const reach = String(Math.round((spec.diameter / 2 - 0.5) * 10) / 10); let depths: Awaited> = null; - let webs = 0; const least = String(Math.round(nozzle * 2 * 1.125 * 100) / 100); const rooms = () => depths ? ` The knuckles' rooms go ${String(depths.fixed)} mm into ${spec.fixed} and ${String(depths.moving)} mm into ${spec.moving}, measured from the face nearest the line: a wall there must be ${least} mm thicker than that, or thinner than it (the room then opens through the wall); anything between is left as a thin skin.` : ''; - const warnings = await build( - upstream, - run, - spec, + const warnings = await build(site, spec, // With the walls and the bed measured, knuckles that start in the air get their webs. - () => { - const steps = hingeSteps(depths?.support ? { ...spec, support: depths.support } : spec); - webs = steps.filter((step) => step.command === 'solid.extrude' && String(step.params.name).endsWith('◣')).length; - return steps; - }, + () => hingeSteps(depths?.support ? { ...spec, support: depths.support } : spec), `Put the hinge line within ${reach} mm of both bodies (the barrel is ${String(spec.diameter)} mm, so it must overlap each by at least 0.5 mm). PartBuddy cuts each knuckle's room in the other body itself.`, - callId, false, async () => { depths = await roomDepths(upstream, spec, callId); const thin = depths && typeof args.wall === 'number' ? skinProblem(depths, spec, args.wall, nozzle) : null; if (thin) throw new Error(thin); }, + 'revolute', ); - const range = await join(run, spec, 'revolute', [-180, 180], 1); - if (typeof range === 'string') return `Built the hinge "${spec.name}" between ${spec.fixed} (stays) and ${spec.moving} (turns) and joined them, but ${unswept(range)}${warnings}`; + // A hinge on the line of an earlier one only narrows what that joint allows: it is run through its limits again. + const range = await join(run, spec, 'revolute', site.shares?.limits ?? [-180, 180], 1, site.shares?.name); + const print = `${site.shares ? ` It lies on the line of the hinge "${site.shares.name}" and turns about that hinge's joint, which carries both now.` : ''}${hingePrint(spec, depths, overhang)}`; + if (typeof range === 'string') return `Built the hinge "${spec.name}" between ${spec.fixed} (stays) and ${spec.moving} (turns) and joined them, but ${unswept(range, site.shares?.name)}${print}${warnings}`; const [min, max] = range; return ( `Built the hinge "${spec.name}" between ${spec.fixed} (stays) and ${spec.moving} (turns): ${String(spec.knuckles)} knuckles, barrel ${String(spec.diameter)} mm, pin ${String(pinDiameter(spec.diameter))} mm, ` + `gap ${String(spec.gap)} mm everywhere. It turns from ${String(min)}° to ${String(max)}° before the bodies meet, and those are its limits now; 0° is as designed. ` + - `${MOVE} If it should open further, make room where the bodies meet and set joint.limits again.${webs > 0 ? ` ${String(webs)} knuckles start above the bed and stand on webs at 45° down to their own walls, so they print without support.` : ''}${rooms()}${warnings}` + `${MOVE} If it should open further, make room where the bodies meet and set joint.limits again.${print}${rooms()}${warnings}` ); } if (args.kind === 'slide') { const spec = slideOf(args, nozzle); if (typeof spec === 'string') throw new Error(spec); - const warnings = await build(upstream, run, spec, slideSteps(spec), `Put the line from–to on a face of ${spec.fixed}, with up as that face's normal, so the rail stands on it.`, callId); - const [measure] = await run([{ command: 'inspect.measure', params: { bodies: [spec.moving] } }]); + // The box along the line is the same before and after the groove is cut. + const { warnings, measure, print } = await buildGuide(site, spec, nozzle, overhang, 'slider'); const travel = railTravel(measure, spec.from, spec.to); - if (!travel) throw new Error('the guide is built, but the moving body could not be measured; join the bodies with joint.as_built {type: slider}'); + if (!travel) throw new Error(`${spec.moving} could not be measured, so how far the guide may travel is not known.`); const range = await join(run, spec, 'slider', travel, 0.1); - if (typeof range === 'string') return `Built the guide "${spec.name}" between ${spec.fixed} (stays) and ${spec.moving} (slides) and joined them, but ${unswept(range)}${warnings}`; + if (typeof range === 'string') return `Built the guide "${spec.name}" between ${spec.fixed} (stays) and ${spec.moving} (slides) and joined them, but ${unswept(range)} ${print}${warnings}`; const [min, max] = range; const mm = (value: number) => String(Math.round(value * 10) / 10); return ( `Built the guide "${spec.name}" between ${spec.fixed} (stays) and ${spec.moving} (slides): a dovetail rail ${String(spec.width)} mm wide at its foot and ${String(spec.height)} mm high, ` + `gap ${String(spec.gap)} mm everywhere. It slides from ${mm(min)} mm to ${mm(max)} mm while ${spec.moving} stays on the rail and the bodies do not meet, and those are its limits now; 0 mm is as designed. ` + - `${MOVE} Nothing holds it at the ends of the rail; add stops if it must not come off.${warnings}` + `${MOVE} Nothing holds it at the ends of the rail; add stops if it must not come off. ${print}${warnings}` ); } if (args.kind === 'closure') { const spec = lipOf(args); if (typeof spec === 'string') throw new Error(spec); - const warnings = await build(upstream, run, spec, lipSteps(spec), spec.flat ? `Give from and to as opposite corners of the inner room of ${spec.fixed} on the face of ${spec.moving} towards it, so the lip stands on the plate.` : `Give from and to as opposite corners of the inner room of ${spec.moving} at its rim, so the lip lies on its wall.`, callId); + const warnings = await build(site, spec, lipSteps(spec), spec.flat ? `Give from and to as opposite corners of the inner room of ${spec.fixed} on the face of ${spec.moving} towards it, so the lip stands on the plate.` : `Give from and to as opposite corners of the inner room of ${spec.moving} at its rim, so the lip lies on its wall.`); if (spec.flat) { return `Built the closure "${spec.name}" between ${spec.fixed} (the box) and ${spec.moving} (the flat lid): a lip ${String(LIP.thick)} mm thick and ${String(LIP.high)} mm high on the plate, ${String(LIP.clear)} mm clear of the box's wall, and a bead that snaps ${String(LIP.bead - LIP.clear)} mm into a rille of that wall. The plate has no notch to open it: let it stand out over the box by a millimetre or two on the side of the catch. It prints with the lip up: set the print face of the plate on its outer face. It makes no joint. Keep the box's wall straight and at least 1.6 mm thick where the lip goes in.${warnings}`; } @@ -531,7 +798,7 @@ async function buildMechanism(upstream: UpstreamTools, args: Record []); @@ -540,14 +807,14 @@ async function buildMechanism(upstream: UpstreamTools, args: Record 1 ? 's' : ''}, ` + `and the same inside the lid, ${String(spec.gap)} mm clear; it closes in ${String(round2(thread.turns))} turn${thread.turns > 1 ? 's' : ''} and stops with its inner top on the rim. ` + - `The lid is ${String(round2(spec.diameter + 2 * (thread.depth + spec.gap + LID.wall)))} mm wide and covers the top ${String(skirt)} mm of the neck, has flutes to grip and prints standing on its top (its print face is set); both parts print without support. ` + + `The lid is ${String(round2(spec.diameter + 2 * (thread.depth + spec.gap + LID.wall)))} mm wide and covers the top ${String(skirt)} mm of the neck, has flutes to grip and prints standing on its top (its print face is set); ${overhang < 45 ? leans("the threads' flanks on both parts", overhang).replace(/\.$/, '') : 'both parts print without support'}. ` + `Keep the neck a plain cylinder of ${String(spec.diameter)} mm there, with a wall of at least 2 mm.${warnings}` ); } if (args.kind === 'screw') { const spec = screwOf(args); if (typeof spec === 'string') throw new Error(spec); - const warnings = await build(upstream, run, spec, screwSteps(spec), `Give from on the outer face of ${spec.moving} and to on the face where it meets ${spec.fixed}.`, callId); + const warnings = await build(site, spec, screwSteps(spec), `Give from on the outer face of ${spec.moving} and to on the face where it meets ${spec.fixed}.`); const count = spec.more.length + 1; const length = screwLength(spec); const insert = INSERT[spec.size]; @@ -563,7 +830,7 @@ async function buildMechanism(upstream: UpstreamTools, args: Record 1 ? 's' : ''} "${spec.name}" on ${spec.fixed}, each with its window through the ${String(spec.wall)} mm wall of ${spec.moving}: an arm ${String(SNAP.length)} mm long, ${String(SNAP.thickness)} mm thick and ${String(SNAP.width)} mm wide, ${String(SNAP.gap)} mm off the wall, with a catch of ${String(SNAP.catch)} mm that lies over the window's edge. ` + @@ -575,28 +842,28 @@ async function buildMechanism(upstream: UpstreamTools, args: Record number, keepFace: KeepFace = () => undefined): UpstreamTools { +/** The upstream tools plus `partbuddy_mechanism`; `nozzle` is the printer's nozzle in use, for the default gap, and `overhang` how far the material in use leans without support (degrees), for what the answers promise. */ +export function withMechanismTool(upstream: UpstreamTools, nozzle: () => number, keepFace: KeepFace = () => undefined, overhang: () => number = () => 45): UpstreamTools { return { instructions: () => upstream.instructions(), listTools: async () => [...(await upstream.listTools()), mechanismTool], callTool: async (name, args, callId): Promise => { if (name !== MECHANISM_TOOL) return upstream.callTool(name, args, callId); try { - return { content: [{ type: 'text', text: await buildMechanism(upstream, args, nozzle(), callId, keepFace) }] }; + return { content: [{ type: 'text', text: await buildMechanism(upstream, args, nozzle(), callId, keepFace, overhang()) }] }; } catch (cause) { return { content: [{ type: 'text', text: `Not built: ${(cause as Error).message}` }], isError: true }; } @@ -625,4 +892,4 @@ export function withMechanismTool(upstream: UpstreamTools, nozzle: () => number, } /** Added to the role prompt of every route, after the joint guidance. */ -export const MECHANISM_GUIDANCE = `For a hinge (a lid that folds, a flap, a clamp that opens) or a guide (a drawer, a slider, a carriage on a rail) call ${MECHANISM_TOOL} once both bodies exist instead of modelling knuckles, a pin or a rail yourself: it builds the print-in-place geometry with the right gaps, joins the bodies and sets the limits. For a lid that should hold shut by itself (a box, a case, a container) call it with kind closure instead of modelling a latch, a hook or a clip: a lip with a snap bead inside the lid, nothing standing out and no wall cut through. For parts that are screwed together (a lid on a housing, a bracket on a base, a cover) call it with kind screw instead of drilling the holes yourself: it sizes the clearance hole, the room for the head and the seat of the heat-set insert, and says which screws to buy. For a round container that should screw shut (a jar, a canister, a bottle cap) build the container with a plain cylindrical neck and call it with kind lid: it threads the neck and makes the lid, instead of modelling a thread yourself. For a cover or a housing half that should clip on and come off again by pressing on catches (an electronics housing, a battery cover) call it with kind snap: hooks on one part, their windows through the other part's wall. For a lid that should be held down by a latch one sees and opens with a finger (a tool box, a case with a hinged lid) call it with kind latch: a snap latch outside, on the wall opposite the hinge. For a part split in two to fit the printer, or two parts that are plugged or glued together, call it with kind plug: pegs with their pockets (two or more), or shape dovetail to push one part onto the other.`; +export const MECHANISM_GUIDANCE = `For a hinge (a lid that folds, a flap, a clamp that opens) or a guide (a drawer, a slider, a carriage on a rail) call ${MECHANISM_TOOL} once both bodies exist instead of modelling knuckles, a pin or a rail yourself: it builds the geometry with the right gaps, joins the bodies and sets the limits. For a lid that should hold shut by itself (a box, a case, a container) call it with kind closure instead of modelling a latch, a hook or a clip: a lip with a snap bead inside the lid, nothing standing out and no wall cut through. For parts that are screwed together (a lid on a housing, a bracket on a base, a cover) call it with kind screw instead of drilling the holes yourself: it sizes the clearance hole, the room for the head and the seat of the heat-set insert, and says which screws to buy. For a round container that should screw shut (a jar, a canister, a bottle cap) build the container with a plain cylindrical neck and call it with kind lid: it threads the neck and makes the lid, instead of modelling a thread yourself. For a cover or a housing half that should clip on and come off again by pressing on catches (an electronics housing, a battery cover) call it with kind snap: hooks on one part, their windows through the other part's wall. For a lid that should be held down by a latch one sees and opens with a finger (a tool box, a case with a hinged lid) call it with kind latch: a snap latch outside, on the wall opposite the hinge. For a part split in two to fit the printer, or two parts that are plugged or glued together, call it with kind plug: pegs with their pockets (two or more), or shape dovetail to push one part onto the other.`; diff --git a/apps/desktop/src/main/agents/takeBack.ts b/apps/desktop/src/main/agents/takeBack.ts new file mode 100644 index 0000000..c9ae853 --- /dev/null +++ b/apps/desktop/src/main/agents/takeBack.ts @@ -0,0 +1,76 @@ +/** + * Taking back what a tool built before it failed, shared by PartBuddy's own tools. The state before is kept as a mark: + * what `document.inspect` and `joint.list` name of the design (features, sketches, bodies, joints). Taking back undoes + * until the design reads like the mark again. + * + * Not by the difference of SolidCraft's undo depth alone: that depth stops at 200 (measured on 0.40.1: 260 steps read + * `undo: 200`), a hinge with webs costs over 50 steps, and from the fourth hinge on the difference came out too small + * or 0, so half a hinge or all of it stayed under "Everything was taken back" (review of PR #358). + */ + +type Step = { command: string; params: Record }; +/** Runs SolidCraft commands in turn and gives each one's result; throws when one fails. */ +export type Run = (commands: Step[]) => Promise; + +export interface Mark { + /** What the design consists of, as one string to compare. */ + state: string; + /** How many features its timeline has: undoing never goes below that, into what was there before. */ + features: number; + /** SolidCraft's undo depth then; only a first guess at how many steps came since. */ + undo: number | null; +} + +/** More single steps than any tool builds: the search for the mark ends there. */ +const MOST = 400; + +const READ: Step[] = [ + { command: 'document.inspect', params: { timeline: 'summary', bodies: 'names' } }, + { command: 'joint.list', params: {} }, +]; + +function markFrom(results: unknown[]): Mark | null { + const [design, joints] = results as [{ timeline?: unknown; sketches?: unknown; bodies?: unknown; undo?: unknown } | undefined, { joints?: unknown } | undefined]; + // Without the timeline there is nothing to know the state by. + if (!Array.isArray(design?.timeline)) return null; + const ids = (list: unknown): unknown[] => (Array.isArray(list) ? (list as unknown[]).map((entry) => (entry as { id?: unknown } | null)?.id ?? entry) : []); + return { features: design.timeline.length, state: JSON.stringify([ids(design.timeline), ids(design.sketches), design.bodies ?? null, ids(joints?.joints)]), undo: typeof design.undo === 'number' ? design.undo : null }; +} + +/** The design as it is now, to come back to; null when SolidCraft does not say enough to know it again. */ +export async function markOf(run: Run): Promise { + try { + return markFrom(await run(READ)); + } catch { + return null; + } +} + +/** + * Undoes until the design is at `mark` again, and makes the design level active (activating a component is no undo + * step: after the undos it stays active, and what is built next lands in it). True only when the mark was reached; + * false when it could not be (no mark, nothing left to undo, more than `MOST` steps), and the caller must say so. + */ +export async function takeBack(run: Run, mark: Mark | null): Promise { + const level = async () => { + await run([{ command: 'component.activate', params: { component: 0 } }]).catch(() => undefined); + }; + if (!mark) { + await level(); + return false; + } + try { + let now = markFrom(await run(READ)); + // Where the depth has not hit its ceiling the difference is the number of steps: all at once, then looked at. + if (now && now.state !== mark.state && mark.undo !== null && now.undo !== null && now.undo > mark.undo) { + now = markFrom((await run([...Array.from({ length: now.undo - mark.undo }, () => ({ command: 'edit.undo', params: {} })), ...READ])).slice(-READ.length)); + } + // One by one from there, as long as features built since are left: a step further would take what was there before. + for (let step = 0; now && now.state !== mark.state && now.features > mark.features && step < MOST; step += 1) now = markFrom((await run([{ command: 'edit.undo', params: {} }, ...READ])).slice(-READ.length)); + await level(); + return now?.state === mark.state; + } catch { + await level(); + return false; + } +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 7bb66f1..d9bf5e6 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -590,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, log), keepMechanismFace)), (trial) => supervisor.trialSteps(trial)), () => viewState, () => notesService.forView(), checkForView), + withViewTool(guardNotes(withStandardTool(withPrintFaces(withBlockTool(withMechanismTool(withDrawings(upstream, drawings), () => printProfiles.part().printer.nozzle, keepMechanismFace, () => printProfiles.part().material.overhang), keepMechanismFace, log), keepMechanismFace)), (trial) => supervisor.trialSteps(trial)), () => viewState, () => notesService.forView(), checkForView), () => viewState?.bodies.map((body) => body.name) ?? [], saveAssembly, ), diff --git a/apps/desktop/src/shared/mechanisms.ts b/apps/desktop/src/shared/mechanisms.ts index 488cafa..1c58a3f 100644 --- a/apps/desktop/src/shared/mechanisms.ts +++ b/apps/desktop/src/shared/mechanisms.ts @@ -27,10 +27,11 @@ export interface HingeSpec { /** The hinge's name, in the user's language; the features carry it. */ name: string; /** - * Where a lying hinge hangs: the height (z) of the bed, and for each body the level way from the line to its wall - * and how far that is. With it every knuckle that starts in the air gets a web at 45° down to its own wall. + * Where a lying hinge hangs: the height (z) of the bed, and for each body whose wall can carry a web (it reaches down + * to `webFoot`, or the web ends on the bed) the level way from the line to that wall and how far that is. With it + * every knuckle of such a body that starts in the air gets a web at 45° down to its own wall. */ - support?: { bed: number; fixed: HingeSide; moving: HingeSide }; + support?: { bed: number; fixed?: HingeSide; moving?: HingeSide }; } export interface HingeSide { @@ -105,40 +106,82 @@ export function hingeSteps(spec: HingeSpec): Step[] { cylinder(pin + 2 * spec.gap, -spec.gap, length + spec.gap, 'cut', spec.moving, `${spec.name} ○`), ...boreRoof(spec, axis, at(-spec.gap), length + 2 * spec.gap, pin / 2 + spec.gap), cylinder(pin, 0, length, 'join', spec.fixed, `${spec.name} ●`), + ...spans.flatMap((span) => (span.owner === spec.moving ? pinFlat(spec, axis, at, span, pin / 2) : [])), + ]; +} + +/** How far the pin of a lying hinge bridges inside a knuckle of the moving body, from one fixed knuckle to the next (mm). */ +export function pinBridge(spec: Pick): number { + const length = Math.hypot(...spec.to.map((value, i) => value - (spec.from[i] ?? 0))); + return round((length + (spec.knuckles + 1) * spec.gap) / spec.knuckles); +} + +/** + * The flat under the pin of a lying hinge, inside one knuckle of the moving body: there the pin has nothing below it + * but the gap, and its round underside starts in the air (audit 2026-11-12: 25 mm² of overhang per knuckle left on every + * hinge). Cut off where it leans 45°, what is left below is a level strip from one fixed knuckle to the next, a bridge. + */ +function pinFlat(spec: HingeSpec, axis: Vec3, at: (distance: number) => Vec3, span: { start: number; end: number; label: string }, radius: number): Step[] { + if (Math.abs(axis[2]) > 1e-6) return []; + const top = -radius / Math.SQRT2; + // Down to inside the bore's gap: below the pin the fixed body has nothing there. + const points: [number, number][] = [[-radius, top], [radius, top], [radius, -radius - spec.gap / 2], [-radius, -radius - spec.gap / 2]]; + const name = `${span.label} ▬`; + return [ + { command: 'sketch.create', params: { plane: { origin: at(span.start - spec.gap), x_dir: cross([0, 0, 1], axis).map(round), y_dir: [0, 0, 1] }, project_edges: false, name } }, + { command: 'sketch.line', params: { points: points.map(([x, y]) => [round(x), round(y)]), closed: true } }, + { command: 'solid.extrude', params: { distance: `${String(round(span.end - span.start + 2 * spec.gap))} mm`, operation: 'cut', targets: [spec.fixed], name } }, ]; } /** A web stands this far in its wall, and one lower than this is left out (mm). */ -const WEB = { into: 0.5, least: 0.3 }; +export const WEB = { into: 0.5, least: 0.3 }; + +/** How far below the hinge line the web of a knuckle meets its wall, `distance` from the line (mm): the wall must reach down there. */ +export function webFoot(diameter: number, distance: number): number { + return round((diameter / 2) * Math.SQRT2 + distance); +} + +/** Whether the knuckles of a lying hinge start above the bed, so that each needs a web under it. */ +export function hangs(spec: Pick, bed: number): boolean { + return Math.abs(spec.to[2] - spec.from[2]) < 1e-6 && spec.from[2] - spec.diameter / 2 - bed >= WEB.least; +} /** * The web under one knuckle of a lying hinge that starts above the bed: the round underside of a barrel begins in the * air (practice runs 2026-11-11 and -12: the assistant added such webs by hand every time). In the plane square to - * the line, x towards the knuckle's own wall and y up from the axis: from under the barrel a face at 45° runs down to - * the wall, or to the bed where that comes first. Towards the other body it ends a gap before that body's face, at - * most where the barrel itself leans 45°; what is left of the round there is a small overhang next to material. + * the line, x towards the knuckle's own wall and y up from the axis: from where the barrel leans 45° on the far side, + * over the other body, a face at 45° runs down under the barrel to the knuckle's own wall, or to the bed where that + * comes first. So nothing of the round is left hanging: a web that ended a gap before the other body left the far half + * of every knuckle as an overhang of up to 89° (audit 2026-11-12, N-mechanik-4). Where the web passes through the + * other body, that body is cut back by the same profile a gap further out, as it is for the barrel itself. + * shortcut: a wall that ends above the web's foot gets no web at all, though a shorter one from further under the + * barrel would carry most of it; add that when hinges on shallow walls turn up. */ -function web(spec: HingeSpec, axis: Vec3, at: (distance: number) => Vec3, span: { start: number; end: number; owner: string; label: string }): Step[] { +function web(spec: HingeSpec, axis: Vec3, at: (distance: number) => Vec3, span: { start: number; end: number; owner: string; other: string; label: string }): Step[] { const support = spec.support; - if (!support || Math.abs(axis[2]) > 1e-6) return []; - const [own, other] = span.owner === spec.fixed ? [support.fixed, support.moving] : [support.moving, support.fixed]; - const radius = spec.diameter / 2; - const x0 = Math.max(-radius / Math.SQRT2, spec.gap - other.distance); - if (x0 >= radius || own.distance - x0 < WEB.least) return []; - const under = -Math.sqrt(radius * radius - x0 * x0); + const own = span.owner === spec.fixed ? support?.fixed : support?.moving; + if (!support || !own || !hangs(spec, support.bed)) return []; const bed = support.bed - spec.from[2]; - // A barrel that lies on the bed is carried by it. - if (-radius - bed < WEB.least) return []; const wall = own.distance + WEB.into; - const foot = under - (own.distance - x0); - const points: [number, number][] = foot < bed ? [[x0, 0], [x0, under], [x0 + (under - bed), bed], [wall, bed], [wall, 0]] : [[x0, 0], [x0, under], [own.distance, foot], [wall, foot], [wall, 0]]; + const room = Math.max(0, own.distance - spec.gap); + // The face at 45° that touches a barrel of this radius, from where it touches down to x. + const slope = (radius: number, x: number): [number, number][] => [[-radius / Math.SQRT2, 0], [-radius / Math.SQRT2, -radius / Math.SQRT2], [x, -radius * Math.SQRT2 - x]]; + const radius = spec.diameter / 2; + const foot = -webFoot(spec.diameter, own.distance); + const points: [number, number][] = foot < bed ? [...slope(radius, -radius * Math.SQRT2 - bed), [wall, bed], [wall, 0]] : [...slope(radius, own.distance), [wall, foot], [wall, 0]]; // x × y of the sketch is the way the profile is extruded: start at the end of the knuckle from which that runs along it. const forward = cross(own.toward, [0, 0, 1]).reduce((sum, value, i) => sum + value * (axis[i] ?? 0), 0) > 0; - const name = `${span.label} ◣`; + const profile = (outline: [number, number][], start: number, end: number, operation: 'cut' | 'join', target: string, name: string): Step[] => [ + { command: 'sketch.create', params: { plane: { origin: at(forward ? start : end), x_dir: own.toward.map((value) => round(value) + 0), y_dir: [0, 0, 1] }, project_edges: false, name } }, + { command: 'sketch.line', params: { points: outline.map(([x, y]) => [round(x), round(y)]), closed: true } }, + { command: 'solid.extrude', params: { distance: `${String(round(end - start))} mm`, operation, targets: [target], name } }, + ]; return [ - { command: 'sketch.create', params: { plane: { origin: at(forward ? span.start : span.end), x_dir: own.toward.map((value) => round(value) + 0), y_dir: [0, 0, 1] }, project_edges: false, name } }, - { command: 'sketch.line', params: { points: points.map(([x, y]) => [round(x), round(y)]), closed: true } }, - { command: 'solid.extrude', params: { distance: `${String(round(span.end - span.start))} mm`, operation: 'join', targets: [span.owner], name } }, + // As long as the knuckle's room, and up to a gap before the knuckle's own wall: a line that lies inside the other + // body's wall has that wall on both sides of it. + ...profile([...slope(radius + spec.gap, room), [room, 0]], span.start - spec.gap, span.end + spec.gap, 'cut', span.other, `${span.label} ◤`), + ...profile(points, span.start, span.end, 'join', span.owner, `${span.label} ◣`), ]; } @@ -189,9 +232,24 @@ export interface SlideSpec { height: number; /** The gap between rail and groove in mm. */ gap: number; + /** The moving body is high enough over the rail's face for the roof over the groove (`slideRoof`); without it the groove's ceiling is flat. */ + roof?: boolean; name: string; } +/** The flat ridge of the roof over a groove, in mm: a bridge that short does not sag (Protolabs Network, Xometry Pro; overhang.ts). */ +const RIDGE = 5; + +/** + * The ceiling of a guide's groove and the roof that takes its place, in mm. The ceiling is flat, as wide as the rail's + * head and a gap more on each side, and lies only a gap over the head: 13.9 mm at the defaults and up to 102 mm, where + * a bridge prints up to 10 mm (audit 2026-11-12, T5). The roof rises at 45° from both edges of the ceiling to a ridge + * `ridge` wide, `top` above the face the rail stands on. + */ +export function slideRoof(spec: Pick): { ceiling: number; ridge: number; top: number } { + const half = spec.width / 2 + spec.gap * Math.SQRT2 + spec.height + spec.gap; + return { ceiling: round(2 * half), ridge: RIDGE, top: round(spec.height + spec.gap + half - RIDGE / 2) }; +} /** What is wrong with a guide before anything is built; null when it can be built. */ export function slideProblem(spec: SlideSpec): string | null { @@ -210,8 +268,9 @@ export function slideProblem(spec: SlideSpec): string | null { /** * The solids of a dovetail guide along the line `from`–`to`: a rail on the fixed body that widens - * upwards at 45°, and its groove in the moving body, a gap wider all round. Both are a sketch at - * `from`, square to the line, extruded along it. + * upwards at 45°, and its groove in the moving body, a gap wider all round, with a roof instead of + * a flat ceiling where the body is high enough. Both are a sketch at `from`, square to the line, + * extruded along it. */ export function slideSteps(spec: SlideSpec): Step[] { const delta = spec.to.map((value, i) => value - (spec.from[i] ?? 0)) as Vec3; @@ -225,6 +284,7 @@ export function slideSteps(spec: SlideSpec): Step[] { const { height, gap } = spec; // The flank moved out by the gap, measured square to it. const out = gap * Math.SQRT2; + const roof = slideRoof(spec); const profile = (points: [number, number][], operation: 'cut' | 'join', target: string, name: string): Step[] => [ { command: 'sketch.create', params: { plane, project_edges: false, name } }, { command: 'sketch.line', params: { points: points.map(([x, y]) => [round(x), round(y)]), closed: true } }, @@ -238,6 +298,7 @@ export function slideSteps(spec: SlideSpec): Step[] { [half + out, -gap], [half + out, 0], [half + out + height + gap, height + gap], + ...(spec.roof ? ([[roof.ridge / 2, roof.top], [-roof.ridge / 2, roof.top]] as [number, number][]) : []), [-(half + out + height + gap), height + gap], [-(half + out), 0], ], @@ -361,6 +422,20 @@ export function lipSteps(spec: LipSpec): Step[] { ...(spec.flat ? [] : ring(`${name} ·`, by(spec.rim, up > 0 ? -LIP.foot : 0), -0.2, LIP.foot)), ...ring(name, by(spec.rim, up > 0 ? 0 : -LIP.high), LIP.clear, LIP.high), ...(spec.flat ? [] : [{ command: 'solid.chamfer', params: { edges: [xyz(middleAt, at(wallAt) - out * inner, at(spec.rim) - up * LIP.foot)], distance: mm(inner - 0.05), body: spec.moving, name: `${name} ◢` } }]), + // The notch before the bead: it reaches below the rim, where the bead stands past the wall's plane, and cut after + // it took that part away, so the bead ended on the plane and caught nothing (audit 2026-11-12, N-mechanik-2). + ...(spec.flat ? [] : [{ + command: 'solid.cylinder', + params: { + diameter: mm(LIP.notch), + height: ex(spec.wall), + base: xyz(middle, out > 0 ? ex(wallAt) : `${ex(wallAt)} - ${ex(spec.wall)}`, by(spec.rim, up * (LIP.notch / 2 - 2.5))), + axis: alongX ? 'Y' : 'X', + operation: 'cut', + targets: [spec.moving], + name: `${name} ◠`, + }, + }]), { command: 'solid.box', params: { @@ -384,18 +459,6 @@ export function lipSteps(spec: LipSpec): Step[] { name: `${name} ○`, }, }, - ...(spec.flat ? [] : [{ - command: 'solid.cylinder', - params: { - diameter: mm(LIP.notch), - height: ex(spec.wall), - base: xyz(middle, out > 0 ? ex(wallAt) : `${ex(wallAt)} - ${ex(spec.wall)}`, by(spec.rim, up * (LIP.notch / 2 - 2.5))), - axis: alongX ? 'Y' : 'X', - operation: 'cut', - targets: [spec.moving], - name: `${name} ◠`, - }, - }]), ]; } diff --git a/apps/desktop/src/shared/templates.ts b/apps/desktop/src/shared/templates.ts index bcb72fa..1f9fa00 100644 --- a/apps/desktop/src/shared/templates.ts +++ b/apps/desktop/src/shared/templates.ts @@ -2,7 +2,8 @@ * Templates (plan step F3): parametric starting points built with SolidCraft's own commands. Every * size is a user parameter (`parameters.add`) and the geometry refers to it by expression, so the * parameter panel changes a template without the assistant. Each was built and changed with - * `solidcraft-cli eval` and stays printable as designed, Z up, without support. + * `solidcraft-cli eval` and stays printable as designed, Z up, without support (the stop of the slide stop on its top: + * its print face is set). * * `solid.hole` keeps a position given as expressions but does not move with it (0.40.0-rc.2: the * hole stays where it was placed and warns), so plain holes are cut cylinders, which follow, and @@ -43,7 +44,8 @@ const joint = (type: 'revolute' | 'slider', fixed: string, moving: string, on: { /** * Two halves of one height side by side, open side up, with a print-in-place hinge on the rims * between them: the lid turns half a turn onto the box. The hinge keeps its length and stays in the - * middle of the long side and on the rim when the sizes change. + * middle of the long side and on the rim when the sizes change. Its knuckles start 12 mm above the + * bed and stand on the webs of the mechanism tool, down to the walls of their halves. */ function hingedBox(): Step[] { // The room between the halves; the hinge line runs in its middle, within reach of both walls. @@ -69,7 +71,7 @@ function hingedBox(): Step[] { return [ parameter('length', '60 mm', 'Outer length of box and lid; at least 54 mm, the hinge is 40 mm long'), parameter('width', '40 mm', 'Outer width of box and lid'), - parameter('height', '16 mm', 'Height of each half; closed, the box is twice as high'), + parameter('height', '16 mm', 'Height of each half; closed, the box is twice as high. At least 8 mm: the webs under the hinge reach 6.2 mm below the rim'), parameter('wall', '2.4 mm', 'Wall and floor thickness'), parameter('corner', '5 mm', 'Radius of the outer corners of box and lid; at least 2 mm more than the wall'), ...shell('Box', '-width', -width, (name) => `Box ${name}`), @@ -90,17 +92,25 @@ function hingedBox(): Step[] { name: 'Closure', }), // The hinge of the mechanism tool, built at the starting sizes, then tied to the parameters. - ...hingeSteps({ fixed: 'Box', moving: 'Lid', from: [start, between / 2, height], to: [start + hinge, between / 2, height], diameter, gap, knuckles, name: 'Hinge' }).map((step) => { - // The bore's roof is drawn in the world's own y and z and moved along x, so it follows the sizes as the cylinders do. - if (step.command === 'sketch.create') return { ...step, params: { ...step.params, plane: 'YZ', offset: `length / 2 + ${String(start - gap - length / 2)} mm` } }; - if (step.command === 'sketch.line') return { ...step, params: { ...step.params, points: (step.params.points as [number, number][]).map(([across, up]) => [`${String(between / 2 + across)} mm`, `height + ${String(up)} mm`]) } }; + // The walls of both halves stand half the room between them from the line, down to the bed: every knuckle gets its web. + ...hingeSteps({ fixed: 'Box', moving: 'Lid', from: [start, between / 2, height], to: [start + hinge, between / 2, height], diameter, gap, knuckles, name: 'Hinge', support: { bed: 0, fixed: { toward: [0, -1, 0], distance: between / 2 }, moving: { toward: [0, 1, 0], distance: between / 2 } } }).map((step, i, steps) => { + // The profiles (the bore's roof, the webs and their rooms, the pin's flat) are drawn in the world's own y and z and + // moved along x, so they follow the sizes as the cylinders do. One drawn looking back along x is turned round: + // it starts where its extrusion ended. No web is drawn below the bed, however low the halves are set. + const drawn = (steps[step.command === 'sketch.create' ? i : i - 1]?.params.plane ?? {}) as { origin?: Vec3; x_dir?: Vec3 }; + const back = (drawn.x_dir?.[1] ?? 1) < 0; + if (step.command === 'sketch.create') return { ...step, params: { ...step.params, plane: 'YZ', offset: `length / 2 + ${String(Math.round(((drawn.origin?.[0] ?? 0) - (back ? parseFloat(String(steps[i + 2]?.params.distance)) : 0) - length / 2) * 1000) / 1000)} mm` } }; + // A web's third point is the foot of its face at 45°: where the bed cuts the web short, the foot comes in along + // that face by as much, so the face keeps its 45° (at height 5 mm it leant 57° with the foot only lifted). + const web = String(steps[i + 1]?.params.name).endsWith('◣'); + if (step.command === 'sketch.line') return { ...step, params: { ...step.params, points: (step.params.points as [number, number][]).map(([across, up], n) => [web && n === 2 ? `${String(between / 2)} mm ${back ? '-' : '+'} min(${String(across)} mm, height + ${String(Math.round((across + up) * 1000) / 1000)} mm)` : `${String(between / 2 + (back ? -across : across))} mm`, up < 0 && steps[i + 1]?.params.operation === 'join' ? `max(height + ${String(up)} mm, 0 mm)` : `height + ${String(up)} mm`]) } }; if (step.command !== 'solid.cylinder') return step; const [x, y] = step.params.base as Vec3; return { ...step, params: { ...step.params, base: [`length / 2 + ${String(x - length / 2)} mm`, y, 'height'] } }; }), components('Box', 'Lid'), - // It closes all the way: the bead pressing past the box's rim on the last degrees is the snap. The joint is kept under the pin and on the bore round it, inside the lid's first knuckle: faces no wall or lip cuts, whatever the sizes. - joint('revolute', 'Box', 'Lid', { fixed: { circle: [far, between / 2, height - pin / 2], x: [0, 0, 1] }, moving: { circle: [far, between / 2, height - pin / 2 - gap], x: [0, 0, 1] } }, { flip: true, offset: -gap, limits: [[0, 180]] }, 'Hinge'), + // It closes all the way: the bead pressing past the box's rim on the last degrees is the snap. The joint is kept on top of the pin (underneath it is flat) and on the bore under it, inside the lid's first knuckle: faces no wall or lip cuts, whatever the sizes. + joint('revolute', 'Box', 'Lid', { fixed: { circle: [far, between / 2, height + pin / 2], x: [0, 0, 1] }, moving: { circle: [far, between / 2, height - pin / 2 - gap], x: [0, 0, 1] } }, { flip: true, offset: -gap, limits: [[0, 180]] }, 'Hinge'), ]; } @@ -312,7 +322,7 @@ const GERMAN: Record = { 'Thickness of the plate': 'Stärke der Platte', 'Height of the capital letters': 'Höhe der Großbuchstaben', 'Holes for M3 screws (ISO 273, normal)': 'Löcher für M3-Schrauben (ISO 273, mittel)', - 'Height of each half; closed, the box is twice as high': 'Höhe jeder Hälfte; geschlossen ist die Box doppelt so hoch', + 'Height of each half; closed, the box is twice as high. At least 8 mm: the webs under the hinge reach 6.2 mm below the rim': 'Höhe jeder Hälfte; geschlossen ist die Box doppelt so hoch. Mindestens 8 mm: die Stege unter dem Scharnier reichen 6,2 mm unter den Rand', 'Wall and floor thickness': 'Stärke von Wand und Boden', 'Wall, floor and lid thickness': 'Stärke von Wand, Boden und Deckel', 'Outer length of housing and lid': 'Außenlänge von Gehäuse und Deckel', @@ -385,7 +395,7 @@ const GERMAN_FEATURES: Record = { /** "Ear slot 2 end 1" → "Laschenschlitz 2 Ende 1": the base from the list, numbers and a mechanism's mark (·, ○, ● …) kept. */ export function germanFeatureName(name: string): string { - const match = /^(.*?)((?: \d+)?)((?: end \d+)?)((?: [·○●◢◆◠△])?)$/.exec(name); + const match = /^(.*?)((?: \d+)?)((?: end \d+)?)((?: [·○●◢◆◠△◣◤▬])?)$/.exec(name); const base = match?.[1] ?? name; const german = GERMAN_FEATURES[base]; if (german === undefined) return name; diff --git a/apps/desktop/test/mechanism.test.ts b/apps/desktop/test/mechanism.test.ts index f806fe6..e774c0d 100644 --- a/apps/desktop/test/mechanism.test.ts +++ b/apps/desktop/test/mechanism.test.ts @@ -4,7 +4,7 @@ import type { UpstreamTools } from '../src/main/agents/gateProxy'; import { activated, hingeOf, skinProblem, latchOf, lidOf, lipOf, occurrenceOf, pegOf, railTravel, screwOf, slideOf, snapOf, withMechanismTool } from '../src/main/agents/mechanismTool'; import { decide } from '../src/main/agents/policy'; import { describeToolCall } from '../src/shared/stepPhrases'; -import { hingeProblem, hingeSteps, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, screwLength, screwProblem, screwSteps, slideProblem, slideSteps, snapProblem, snapSteps, latchReach, latchSteps, LATCH, pegLength, pegSteps, PEG, SNAP, type HingeSpec, type LidSpec, type LipSpec, type ScrewSpec, type SlideSpec } from '../src/shared/mechanisms'; +import { hingeProblem, hingeSteps, pinBridge, webFoot, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, screwLength, screwProblem, screwSteps, slideProblem, slideRoof, slideSteps, snapProblem, snapSteps, latchReach, latchSteps, LATCH, pegLength, pegSteps, PEG, SNAP, type HingeSpec, type LidSpec, type LipSpec, type ScrewSpec, type SlideSpec } from '../src/shared/mechanisms'; const spec: HingeSpec = { fixed: 'Base', moving: 'Flap', from: [42, 0, 3], to: [42, 30, 3], diameter: 6, gap: 0.4, knuckles: 3, name: 'Hinge' }; @@ -14,11 +14,17 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { const steps = all.filter((step) => step.command === 'solid.cylinder'); // Lying, the bore gets a roof: flanks at 45° from the bore (radius 1.9 mm) and a flat 1.05 radii up, cut through the flap along the whole line. const roof = all.filter((step) => step.command !== 'solid.cylinder'); - expect(roof.map((step) => step.command)).toEqual(['sketch.create', 'sketch.line', 'solid.extrude']); + expect(roof.map((step) => step.command)).toEqual(['sketch.create', 'sketch.line', 'solid.extrude', 'sketch.create', 'sketch.line', 'solid.extrude']); expect(roof[0]?.params.plane).toEqual({ origin: [42, -0.4, 3], x_dir: [-1, 0, 0], y_dir: [0, 0, 1] }); expect(roof[1]?.params.points).toEqual([[-1.344, 1.344], [1.344, 1.344], [0.692, 1.995], [-0.692, 1.995]]); expect(roof[2]?.params).toMatchObject({ distance: '30.8 mm', operation: 'cut', targets: ['Flap'], name: 'Hinge △' }); - // Upright, a round bore prints as it is. + // Lying, the pin is flat underneath inside the flap's knuckle, where it has only the gap below it: cut off where it leans 45° + // (1.061 mm under the axis of a 3 mm pin), from one of the base's knuckles to the other, 10.533 mm. + expect(roof[3]?.params.plane).toEqual({ origin: [42, 9.733, 3], x_dir: [-1, 0, 0], y_dir: [0, 0, 1] }); + expect(roof[4]?.params.points).toEqual([[-1.5, -1.061], [1.5, -1.061], [1.5, -1.7], [-1.5, -1.7]]); + expect(roof[5]?.params).toMatchObject({ distance: '10.533 mm', operation: 'cut', targets: ['Base'], name: 'Hinge 2 ▬' }); + expect(pinBridge(spec)).toBe(10.533); + // Upright, a round bore prints as it is, and so does the pin. expect(hingeSteps({ ...spec, from: [0, 0, 0], to: [0, 0, 30] }).every((step) => step.command === 'solid.cylinder')).toBe(true); expect(steps.map((step) => [step.params.operation, (step.params.targets as string[])[0], step.params.diameter, step.params.height])).toEqual([ // Room for the knuckles: a gap wider all round and a gap longer at both ends, past the ends of the hinge line too. @@ -54,7 +60,11 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { const args = { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [0, 0, 3], to: [0, 60, 3] }; // 60 mm of line: seven knuckles, so none is longer than 10 mm. expect(hingeOf(args, 0.4)).toMatchObject({ diameter: 6, gap: 0.4, knuckles: 7, name: 'Hinge' }); - expect(hingeOf({ ...args, to: [0, 30, 3] }, 0.4)).toMatchObject({ knuckles: 3 }); + // 30 mm of line: three knuckles would have the pin bridge 10.5 mm inside the middle one (9.7 mm and a gap on each side), so five. + expect(hingeOf({ ...args, to: [0, 30, 3] }, 0.4)).toMatchObject({ knuckles: 5 }); + expect(hingeOf({ ...args, to: [0, 28, 3] }, 0.4)).toMatchObject({ knuckles: 3 }); + // Upright, the pin stands and bridges nothing: three knuckles of 9.7 mm. + expect(hingeOf({ ...args, to: [0, 0, 33] }, 0.4)).toMatchObject({ knuckles: 3 }); expect(hingeOf({ ...args, to: [0, 200, 3] }, 0.4)).toMatchObject({ knuckles: 9 }); expect(hingeOf(args, 0.2)).toMatchObject({ gap: 0.3 }); expect(hingeOf({ ...args, to: [0, 0, 3] }, 0.4)).toBe('from and to must be two points'); @@ -107,39 +117,52 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { const built = await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42, 0, 3], to: [42, 30, 3], name: 'Lid hinge' }); expect(built.isError).toBeUndefined(); expect(built.content[0]).toMatchObject({ - text: expect.stringContaining('Built the hinge "Lid hinge" between Base (stays) and Flap (turns): 3 knuckles, barrel 6 mm, pin 3 mm, gap 0.4 mm everywhere. It turns from -145° to 67° before the bodies meet') as string, + text: expect.stringContaining('Built the hinge "Lid hinge" between Base (stays) and Flap (turns): 5 knuckles, barrel 6 mm, pin 3 mm, gap 0.4 mm everywhere. It turns from -145° to 67° before the bodies meet') as string, }); expect(batches.map((batch) => batch.map((step) => step.command).join(' '))).toEqual([ 'component.list joint.list', // Where the line lies, measured on trial with a rod that is taken back. 'solid.cylinder inspect.clearance inspect.measure sketch.inspect', - [...Array.from({ length: 7 }, () => 'solid.cylinder'), 'sketch.create', 'sketch.line', 'solid.extrude', 'solid.cylinder'].join(' '), - 'document.inspect inspect.interference', + // The design as it is, to come back to should a later step fail. + 'document.inspect joint.list', + [...Array.from({ length: 11 }, () => 'solid.cylinder'), 'sketch.create', 'sketch.line', 'solid.extrude', 'solid.cylinder', ...Array.from({ length: 2 }, () => ['sketch.create', 'sketch.line', 'solid.extrude']).flat()].join(' '), + 'inspect.interference', 'component.list', 'component.from_bodies component.list', 'joint.as_built', 'joint.sweep joint.sweep', 'joint.limits', ]); - expect(batches[6]?.[0]?.params).toMatchObject({ type: 'revolute', a: 'Base:1', b: 'Flap:1', at: { occurrence: 'Base:1', point: [42, 15, 3], z: [0, 30, 0] }, name: 'Lid hinge' }); - expect(batches[8]?.[0]?.params).toEqual({ joint: 'Lid hinge', min: -145, max: 67 }); + expect(batches[7]?.[0]?.params).toMatchObject({ type: 'revolute', a: 'Base:1', b: 'Flap:1', at: { occurrence: 'Base:1', point: [42, 15, 3], z: [0, 30, 0] }, name: 'Lid hinge' }); + expect(batches[9]?.[0]?.params).toEqual({ joint: 'Lid hinge', min: -145, max: 67 }); - // Webs: with the line 16 mm above the bed and 2.5 mm from either wall, every knuckle gets a profile from under the barrel (a gap - // short of the other wall) at 45° down to its own wall, 0.5 mm into it; on the bed (the line a radius above it) none is needed. + // Webs: with the line 16 mm above the bed and 2.5 mm from either wall, every knuckle gets a profile from where the barrel leans + // 45° on the far side (2.121 mm from the axis) at 45° down to its own wall, 0.5 mm into it; on the bed (the line a radius above it) none is needed. const sides = { fixed: { toward: [-1, 0, 0] as [number, number, number], distance: 2.5 }, moving: { toward: [1, 0, 0] as [number, number, number], distance: 2.5 } }; const hung = hingeSteps({ ...spec, from: [42, 0, 16], to: [42, 30, 16], support: { bed: 0, ...sides } }); const webs = hung.filter((step) => String(step.params.name).endsWith('◣')); expect(webs.map((step) => step.command)).toEqual(Array.from({ length: 3 }, () => ['sketch.create', 'solid.extrude']).flat()); const profiles = hung.filter((step) => step.command === 'sketch.line' && (step.params.points as number[][]).length === 5); expect(profiles).toHaveLength(3); - expect(profiles[0]?.params.points).toEqual([[-2.1, 0], [-2.1, -2.142], [2.5, -6.742], [3, -6.742], [3, 0]]); + expect(profiles[0]?.params.points).toEqual([[-2.121, 0], [-2.121, -2.121], [2.5, -6.743], [3, -6.743], [3, 0]]); + expect(webFoot(6, 2.5)).toBe(6.743); + // Audit 2026-11-12, N-mechanik-4: a web that ended a gap before the other body left the far half of each barrel hanging + // (90 mm² of overhang at 6 mm, 619 mm² at 40 mm). It now passes under the whole barrel, and the other body is cut back + // for it by the same profile a gap further out, up to a gap before the knuckle's own wall and as long as the knuckle's room. + const rooms = hung.filter((step) => String(step.params.name).endsWith('◤')); + expect(rooms.filter((step) => step.command === 'solid.extrude').map((step) => [step.params.operation, (step.params.targets as string[])[0], step.params.distance])).toEqual([['cut', 'Flap', '10.533 mm'], ['cut', 'Base', '10.533 mm'], ['cut', 'Flap', '10.533 mm']]); + expect(hung[hung.findIndex((step) => step.params.name === 'Hinge 1 ◤') + 1]?.params.points).toEqual([[-2.404, 0], [-2.404, -2.404], [2.1, -6.908], [2.1, 0]]); + expect(hung.findIndex((step) => step.params.name === 'Hinge 1 ◤')).toBeLessThan(hung.findIndex((step) => step.params.name === 'Hinge 1 ◣')); + // N-mechanik-5: a body whose wall does not reach down to the web's foot is left out of `support`, and gets neither web nor room for one. + const half = hingeSteps({ ...spec, from: [42, 0, 16], to: [42, 30, 16], support: { bed: 0, fixed: sides.fixed } }); + expect(half.filter((step) => step.command === 'solid.extrude' && /[◣◤]$/.test(String(step.params.name))).map((step) => step.params.name)).toEqual(['Hinge 1 ◤', 'Hinge 1 ◣', 'Hinge 3 ◤', 'Hinge 3 ◣']); expect(webs.filter((step) => step.command === 'solid.extrude').map((step) => (step.params.targets as string[])[0])).toEqual(['Base', 'Flap', 'Base']); // The fixed body lies towards -x: its profile is drawn from the far end of the knuckle, so it is extruded back along the line. expect(webs[0]?.params.plane).toMatchObject({ x_dir: [-1, 0, 0], y_dir: [0, 0, 1] }); // The webs come before the bore, which goes through those of the flap. expect(hung.findIndex((step) => step.params.name === 'Hinge ○')).toBeGreaterThan(hung.findIndex((step) => step.params.name === 'Hinge 2 ◣')); // Near the bed the web ends on it instead of going below. - expect(hingeSteps({ ...spec, from: [42, 0, 6], to: [42, 30, 6], support: { bed: 0, ...sides } }).find((step) => step.command === 'sketch.line' && (step.params.points as number[][]).length === 5)?.params.points).toEqual([[-2.1, 0], [-2.1, -2.142], [1.758, -6], [3, -6], [3, 0]]); + expect(hingeSteps({ ...spec, from: [42, 0, 6], to: [42, 30, 6], support: { bed: 0, ...sides } }).find((step) => step.command === 'sketch.line' && (step.params.points as number[][]).length === 5)?.params.points).toEqual([[-2.121, 0], [-2.121, -2.121], [1.757, -6], [3, -6], [3, 0]]); expect(hingeSteps({ ...spec, support: { bed: 0, ...sides } }).some((step) => String(step.params.name).endsWith('◣'))).toBe(false); // The rooms of the knuckles: 3.4 mm about a line 2.5 mm from a face go 0.9 mm into it. A 1.8 mm wall keeps two lines, a 1.6 mm wall // would keep a skin of 0.7 mm and is refused with what to change; a wall the room goes through is a window, and fine. @@ -157,18 +180,21 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { it('takes everything back when a knuckle does not touch its body, and says where the line goes', async () => { const calls: string[] = []; + let depth = 3; const upstream: UpstreamTools = { instructions: () => undefined, listTools: () => Promise.resolve([]), callTool: (name, args) => { calls.push(name); - // Before: three steps to undo. Of the cylinders the five knuckles (48 mm of hinge line) do not touch their body, as SolidCraft 0.40.0-rc.3 warns. + // Before: three steps to undo. Of the cylinders the seven knuckles (48 mm of hinge line) do not touch their body, as SolidCraft 0.40.0-rc.3 warns. if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Box' }, { name: 'Lid' }], undo: 3 }) }] }); - if (name === 'undo') return Promise.resolve({ content: [{ type: 'text', text: '{}' }] }); const commands = args.commands as { command: string; params: { operation?: string; diameter?: string } }[]; + calls.push(...commands.filter((step) => step.command === 'edit.undo' || step.command === 'component.activate').map((step) => step.command)); + if (commands.some((step) => step.params.operation === 'join')) depth = 11; + depth -= commands.filter((step) => step.command === 'edit.undo').length; const results = commands.map((step) => step.command === 'document.inspect' - ? { bodies: [{ name: 'Box' }, { name: 'Lid' }], undo: 11 } + ? { bodies: ['Box', 'Lid'], timeline: Array.from({ length: depth }, (_, i) => ({ id: i + 1 })), undo: depth } : step.params.operation === 'join' && step.params.diameter === '6 mm' ? { warning: "the joined piece doesn't touch `Box`, so `Box` is now in 2 separate pieces (New Body makes it a body of its own)" } : { count: 0, warning: null }, @@ -179,11 +205,175 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { const built = await withMechanismTool(upstream, () => 0.4).callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Box', moving: 'Lid', from: [0, 43.4, 3], to: [48, 43.4, 3] }); expect(built.isError).toBe(true); expect(built.content[0]).toMatchObject({ - text: 'Not built: 5 piece(s) did not touch their body and stand apart from it. Put the hinge line within 2.5 mm of both bodies (the barrel is 6 mm, so it must overlap each by at least 0.5 mm). ' + - "PartBuddy cuts each knuckle's room in the other body itself. Everything was taken back; call again with the line moved.", + text: 'Not built: 7 piece(s) did not touch their body and stand apart from it. Put the hinge line within 2.5 mm of both bodies (the barrel is 6 mm, so it must overlap each by at least 0.5 mm). ' + + "PartBuddy cuts each knuckle's room in the other body itself. Everything was taken back.", }); - // Every step SolidCraft counted since (11 − 3) is undone, and no joint was made. - expect(calls).toEqual(['inspect_design', 'batch', 'batch', 'batch', 'batch', ...Array.from({ length: 8 }, () => 'undo')]); + // Every step SolidCraft counted since (11 − 3) is undone in one batch, the design level is made active again, and no joint was made. + expect(calls.filter((call) => call !== 'batch')).toEqual(['inspect_design', ...Array.from({ length: 8 }, () => 'edit.undo'), 'component.activate']); + expect(depth).toBe(3); + }); + + // Audit 2026-11-12, H-02: the joint was refused after the knuckles were built, the answer said "Not built" with + // SolidCraft's JSON, and the knuckles stayed (2 → 11 features; the next call built them again). + it('takes the knuckles back when the joint cannot be made, and passes on SolidCraft\'s reason in one sentence', async () => { + const refused = { applied: [], completed: 0, failed: { command: 'joint.as_built', error: 'invalid parameters for `joint.create`: a joint needs two different occurrences', index: 0 }, results: [] }; + const tools = (readable: boolean) => { + const sent: string[] = []; + let depth = 2; + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve([]), + callTool: (name, args) => { + if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Base' }, { name: 'Flap' }] }) }] }); + const commands = args.commands as { command: string; params: { operation?: string } }[]; + sent.push(commands.map((step) => step.command).join(' ')); + // As SolidCraft 0.40.1 answers a batch whose step fails: marked as an error, the reason inside its JSON. + if (commands[0]?.command === 'joint.as_built') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify(refused, null, 2) }], isError: true }); + // The probe's batch fails on purpose and SolidCraft rolls it back. + if (!commands.some((step) => step.command === 'sketch.inspect')) depth += commands.filter((step) => step.params.operation !== undefined).length - commands.filter((step) => step.command === 'edit.undo').length; + const results = commands.map((step) => (step.command === 'document.inspect' ? (readable ? { timeline: Array.from({ length: depth }, (_, i) => ({ id: i + 1 })), undo: depth } : {}) : step.command === 'component.list' ? { components: [{ id: 3, parent: 0, bodies: ['Base', 'Flap'], occurrences: [{ id: 4, name: 'Both:1' }] }] } : {})); + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }] }); + }, + }; + return { sent, depth: () => depth, call: () => withMechanismTool(upstream, () => 0.4).callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42, 0, 3], to: [42, 30, 3], knuckles: 3 }) }; + }; + const taken = tools(true); + expect(await taken.call()).toEqual({ isError: true, content: [{ type: 'text', text: 'Not built: SolidCraft refused joint.as_built: invalid parameters for `joint.create`: a joint needs two different occurrences. Everything was taken back.' }] }); + expect(taken.sent.slice(-2)).toEqual([`${Array.from({ length: 10 }, () => 'edit.undo').join(' ')} document.inspect joint.list`, 'component.activate']); + expect(taken.depth()).toBe(2); + // A second call starts from the same design: it does not build on top of the first. + await taken.call(); + expect(taken.depth()).toBe(2); + // Where SolidCraft does not say what the design consists of nothing is taken back, and the answer says so instead of "as it was". + const stuck = tools(false); + expect((await stuck.call()).content[0]).toMatchObject({ text: expect.stringMatching(/^Not built: SolidCraft refused joint\.as_built: .* What was begun could not be taken back: undo it before you call again\.$/) as string }); + expect(stuck.sent.join(' ')).not.toContain('edit.undo'); + }); + + // Audit 2026-11-12, N-mechanik-3: a second hinge with the default name answered with the first joint's limits and + // had none itself; the same call again built a third joint between the same two parts. + it('names a second hinge apart from the first, and refuses a second one between the same two bodies before it builds', async () => { + const batches: { command: string; params: Record }[][] = []; + let stuck = false; + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve([]), + callTool: (name, args) => { + if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Base' }, { name: 'Flap' }, { name: 'Flap2' }], undo: 12 }) }] }); + const commands = args.commands as { command: string; params: Record }[]; + batches.push(commands); + if (stuck && commands.some((step) => step.command === 'joint.sweep')) return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results: [], failed: { command: 'joint.sweep', error: 'motion exceeds contact sweep collision budget' } }) }] }); + const results = commands.map((step) => { + // As SolidCraft 0.40.1 lists them: a joint's sides by the ids of their occurrences. + if (step.command === 'joint.list') return { joints: [{ name: 'Hinge', type: 'revolute', a: 15, b: 17, frame_a: { origin: [42, 15, 3], z: [0, 1, 0] }, values: [{ value: 0, limits: [-145, 67] }] }] }; + if (step.command === 'component.list') return { components: [{ id: 14, parent: 0, bodies: ['Base'], occurrences: [{ id: 15, name: 'Base:1' }] }, { id: 16, parent: 0, bodies: ['Flap'], occurrences: [{ id: 17, name: 'Flap:1' }] }, { id: 31, parent: 0, bodies: ['Flap2'], occurrences: [{ id: 32, name: 'Flap2:1' }] }] }; + // As SolidCraft 0.40.1 answers: the first hinge's pin from y 0 to 30, and that of a second one, which has no joint, from 40 to 70. + if (step.command === 'document.inspect') return { timeline: [{ id: 9, name: 'Hinge ●', type: 'CylinderFeature' }, { id: 30, name: 'Hinge (2) ●', type: 'CylinderFeature' }], undo: 12 }; + if (step.command === 'timeline.inspect') return { definition: { axis: [0, 1, 0], base: [42, step.params.feature === 9 ? 0 : 40, 3], height: '30 mm', operation: 'join', participants: ['Base'], radius: '(3 mm) / 2' }, suppressed: false }; + return step.command === 'joint.sweep' ? { stopped_at: Number(step.params.to) > 0 ? 67.4 : -145.8 } : {}; + }); + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }] }); + }, + }; + const tools = withMechanismTool(upstream, () => 0.4); + const again = await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Flap', moving: 'Base', from: [42, 40, 3.5], to: [42, 70, 3.5], name: 'Another' }); + // Half a millimetre off the first hinge's line it is a second axis, which would bind the first. + expect(again).toEqual({ isError: true, content: [{ type: 'text', text: 'Not built: the joint "Hinge" already joins Flap and Base as a hinge on another line, and a second axis between the same two bodies would bind it. Put this hinge on the line of the first, or change the first: take it back with undo, or delete its features and its joint, then call again.' }] }); + expect(batches.map((batch) => batch.map((step) => step.command).join(' '))).toEqual(['component.list joint.list']); + // Review 2 of PR #358: on the first hinge's own stretch (the joint lies in its middle, y 15) it is the first hinge called again, + // with seven knuckles for five: they would be cut into the old ones (H-02). Refused before anything is built. + const over = await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42, 0, 3], to: [42, 30, 3], knuckles: 7 }); + expect(over).toEqual({ isError: true, content: [{ type: 'text', text: 'Not built: the hinge "Hinge" already joins Base and Flap on this stretch of the line, and new knuckles would be cut into its own. Change that one instead: take it back with undo, or delete its features and its joint, then call again. A second hinge goes on the same line beside the first.' }] }); + expect(batches).toHaveLength(2); + // Review 3 of PR #358: half over the first (y 16 to 46 holds no joint's point), and over the second, which has no joint: + // both are known by their pins, and nothing is built. Beside both (y 90 to 120, below) it is built. + const refusal = async (from: number, to: number) => String((await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42, from, 3], to: [42, to, 3] })).content[0]?.text); + expect(await refusal(16, 46)).toContain('Not built: the hinge "Hinge" already joins Base and Flap on this stretch of the line'); + expect(await refusal(60, 90)).toContain('Not built: the hinge "Hinge (2)" already joins Base and Flap on this stretch of the line'); + expect(batches.slice(2).map((batch) => batch.map((step) => step.command).join(' '))).toEqual(['component.list joint.list', 'document.inspect', 'timeline.inspect timeline.inspect', 'component.list joint.list', 'document.inspect', 'timeline.inspect timeline.inspect']); + // Review of PR #358: on the line of the first it is the usual second hinge of a long flap. It gets its knuckles and no joint of + // its own: the first joint is run through its limits again, since more knuckles can only narrow them. + const before = batches.length; + const coaxial = await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42, 90, 3], to: [42, 120, 3] }); + expect(coaxial.isError).toBeUndefined(); + expect(String(coaxial.content[0]?.text)).toContain('Built the hinge "Hinge (2)" between Base (stays) and Flap (turns): 5 knuckles, barrel 6 mm, pin 3 mm, gap 0.4 mm everywhere. It turns from -145° to 67°'); + expect(String(coaxial.content[0]?.text)).toContain(' It lies on the line of the hinge "Hinge" and turns about that hinge\'s joint, which carries both now.'); + const shared = batches.slice(before).flat(); + expect(shared.some((step) => step.command === 'joint.as_built')).toBe(false); + expect(shared.filter((step) => step.command === 'joint.sweep').map((step) => [step.params.joint, step.params.to])).toEqual([['Hinge', -145], ['Hinge', 67]]); + expect(shared.find((step) => step.command === 'joint.limits')?.params).toEqual({ joint: 'Hinge', min: -145, max: 67 }); + // When that sweep cannot be run, the first hinge's limits are still set: the answer does not say there are none. + stuck = true; + const unrun = String((await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42, 130, 3], to: [42, 160, 3] })).content[0]?.text); + expect(unrun).toContain('so the limits of "Hinge" stay as they were, untried with this hinge.'); + expect(unrun).not.toContain('no limits are set'); + stuck = false; + // Another pair of bodies: built, under a name of its own, which the joint, its sweeps and its limits all carry. + const second = await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap2', from: [-2, 0, 3], to: [-2, 30, 3] }); + expect(second.isError).toBeUndefined(); + expect(second.content[0]).toMatchObject({ text: expect.stringContaining('Built the hinge "Hinge (2)" between Base (stays) and Flap2 (turns)') as string }); + const sent = batches.flat(); + expect(sent.filter((step) => step.command === 'joint.as_built').map((step) => step.params)).toMatchObject([{ a: 'Base:1', b: 'Flap2:1', name: 'Hinge (2)' }]); + expect(sent.filter((step) => step.command === 'joint.sweep' || step.command === 'joint.limits').map((step) => step.params.joint).slice(-3)).toEqual(['Hinge (2)', 'Hinge (2)', 'Hinge (2)']); + // A guide between the two bodies of the hinge is another kind of joint: it is not refused for the hinge. + const guide = await tools.callTool('partbuddy_mechanism', { kind: 'slide', fixed: 'Base', moving: 'Flap', from: [0, 15, 4], to: [40, 15, 4] }); + expect(guide.content[0]).toMatchObject({ text: expect.not.stringContaining('already joins') as string }); + }); + + // Audit 2026-11-12, N-mechanik-4 and -5: "stand on webs …, so they print without support" stood over 90 to 619 mm² of + // overhang the print check still found, and over a web that hung 4.6 mm under a lid plate with no wall below it. + it('gives a web only to a body whose wall reaches the web\'s foot, and says what prints without support only where it holds', async () => { + // Two blocks 16 mm high, 5 mm apart, the line on their rims: the rod on the line is 2.45 mm from either wall. + const answer = async (walls: string[], args: Record = {}, overhang = 45) => { + const sent: { command: string; params: Record }[] = []; + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve([]), + callTool: (name, call) => { + if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Base' }, { name: 'Flap' }], undo: 2 }) }] }); + const commands = call.commands as { command: string; params: Record }[]; + sent.push(...commands); + const rods = commands.filter((step) => step.command === 'solid.cylinder' && String(step.params.body_name).startsWith('PartBuddy probe ')); + const results = commands.map((step) => { + if (step.command === 'inspect.measure') return { bodies: [{ bbox: { min: [0, 0, 0] } }, { bbox: { min: [45, 0, 0] } }] }; + if (step.command === 'component.list') return { components: [{ id: 3, parent: 0, bodies: ['Base'], occurrences: [{ id: 4, name: 'Base:1' }] }, { id: 5, parent: 0, bodies: ['Flap'], occurrences: [{ id: 6, name: 'Flap:1' }] }] }; + if (step.command !== 'inspect.clearance') return {}; + // The second trial: each rod lies in its body where that body has a wall under the web's foot (a rod 0.2 mm thick and 30 mm long is 0.942 mm³). + if (rods.length > 0) return { overlaps: rods.flatMap((rod) => (walls.some((body) => String(rod.params.body_name).endsWith(body === 'Base' ? 'fixed' : 'moving')) ? [{ a: rod.params.body_name, b: String(rod.params.body_name).endsWith('fixed') ? 'Base' : 'Flap', volume_mm3: 0.94 }] : [])), near: [] }; + return { near: [{ a: 'PartBuddy probe', b: 'Base', distance_mm: 2.45, from: [42.5, 15, 16], to: [40, 15, 16] }, { a: 'PartBuddy probe', b: 'Flap', distance_mm: 2.45, from: [42.5, 15, 16], to: [45, 15, 16] }] }; + }); + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }] }); + }, + }; + const built = await withMechanismTool(upstream, () => 0.4, undefined, () => overhang).callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42.5, 0, 16], to: [42.5, 30, 16], ...args }); + return { text: String(built.content[0]?.text), sent, webs: sent.filter((step) => step.command === 'solid.extrude' && String(step.params.name).endsWith('◣')).map((step) => (step.params.targets as string[])[0]) }; + }; + const both = await answer(['Base', 'Flap']); + expect(both.webs).toEqual(['Base', 'Flap', 'Base', 'Flap', 'Base']); + expect(both.text).toContain(' All 5 knuckles start above the bed and stand on webs at 45° that run from under the barrel down to their own wall, and the pin is flat underneath where it bridges from knuckle to knuckle, so the hinge prints as it stands, without support. '); + // Each rod lies 0.25 mm inside its wall and 0.2 mm under the web's foot, 6.743 mm below the line: at z = 9.057. + const rods = both.sent.filter((step) => String(step.params.body_name).startsWith('PartBuddy probe ')); + expect(rods.map((step) => (step.params.base as number[]).map((value) => Math.round(value * 1000) / 1000))).toEqual([[39.75, 0, 9.057], [45.25, 0, 9.057]]); + // The flap's wall ends above that: no web is drawn into the air under it, and the answer names what is left. + const one = await answer(['Base']); + expect(one.webs).toEqual(['Base', 'Base', 'Base']); + expect(one.text).not.toContain('without support'); + expect(one.text).toContain(' The knuckles start above the bed, and the 2 of Flap got no web: a web meets its wall 6.743 mm below the line at the most, and there is no wall there. They need support, or a wall that reaches down that far before the hinge is built. The 3 knuckles of Base stand on webs at 45° that run from under the barrel down to their own wall. '); + const none = await answer([]); + expect(none.webs).toEqual([]); + expect(none.text).toContain('the 3 of Base and the 2 of Flap got no web'); + // Three knuckles asked for on 30 mm: the pin bridges 10.5 mm inside the middle one, and the answer does not call that clean. + const wide = await answer(['Base', 'Flap'], { knuckles: 3 }); + expect(wide.text).not.toContain('without support'); + expect(wide.text).toContain('where it bridges from knuckle to knuckle. The pin bridges 10.5 mm inside each knuckle of Flap, more than prints clean (10 mm): use more knuckles.'); + // Nine knuckles is the most: from there the way on is a second hinge on the same line, which is not refused. + const long = await answer(['Base', 'Flap'], { to: [42.5, 100, 16] }); + expect(long.text).toContain('The pin bridges 11.6 mm inside each knuckle of Flap, more than prints clean (10 mm): build two shorter hinges on the same line instead of this one.'); + // A material that prints 40° (PETG): the webs are there, but they lean 45°, and the answer does not promise them to it. + const petg = await answer(['Base', 'Flap'], {}, 40); + expect(petg.webs).toHaveLength(5); + expect(petg.text).not.toContain('without support.'); + expect(petg.text).toContain('where it bridges from knuckle to knuckle. Webs, barrels and the bore lean 45°, more than the 40° the material in use prints without support: they need support there, or a material that prints 45°.'); }); it('builds onto a body that is a component with that component active, so the knuckles move with it', () => { @@ -192,7 +382,7 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { const steps = activated(hingeSteps(spec), listing); const target = (step: { command: string; params: Record }) => (step.command === 'component.activate' ? `activate ${String(step.params.component)}` : ((step.params.targets as string[] | undefined)?.[0] ?? step.command)); // The roof's sketch is drawn with the flap active, as the bore before it and the cut after it. - expect(steps.map(target)).toEqual(['activate 5', 'Flap', 'activate 0', 'Base', 'activate 5', 'Flap', 'activate 0', 'Base', 'activate 5', 'Flap', 'activate 0', 'Base', 'activate 5', 'Flap', 'sketch.create', 'sketch.line', 'Flap', 'activate 0', 'Base']); + expect(steps.map(target)).toEqual(['activate 5', 'Flap', 'activate 0', 'Base', 'activate 5', 'Flap', 'activate 0', 'Base', 'activate 5', 'Flap', 'activate 0', 'Base', 'activate 5', 'Flap', 'sketch.create', 'sketch.line', 'Flap', 'activate 0', 'Base', 'sketch.create', 'sketch.line', 'Base']); // Nothing is a component: the steps as they are. expect(activated(hingeSteps(spec), { components: [{ id: 0, parent: null, bodies: ['Base', 'Flap'] }] })).toEqual(hingeSteps(spec)); // A guide's sketch belongs to the component of the extrusion that uses it, and the design level is active again at the end. @@ -251,6 +441,12 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { }); }); +/** The tool's description, as the model reads it. */ +const withMechanismDescription = async (): Promise => { + const tools = await withMechanismTool({ instructions: () => undefined, listTools: () => Promise.resolve([]), callTool: () => Promise.resolve({ content: [] }) }, () => 0.4).listTools(); + return String(tools[0]?.description); +}; + describe('the dovetail guide (ADR 0027, G3)', () => { const guide: SlideSpec = { fixed: 'Base', moving: 'Carriage', from: [0, 15, 4], to: [60, 15, 4], up: [0, 0, 1], width: 6, height: 3, gap: 0.4, name: 'Guide' }; @@ -267,6 +463,101 @@ describe('the dovetail guide (ADR 0027, G3)', () => { expect(steps[1]?.params.points).toEqual([[-3.566, -0.4], [3.566, -0.4], [3.566, 0], [6.966, 3.4], [-6.966, 3.4], [-3.566, 0]]); }); + // Audit 2026-11-12, T5: that roof is a level ceiling 13.9 mm wide (102 mm at the largest rail), a gap over the rail's + // head, and the tool called the guide "printed in one piece without support". + it('puts a roof at 45° with a 5 mm ridge over the groove where the moving body is high enough, and says how the parts print', async () => { + expect(slideRoof(guide)).toEqual({ ceiling: 13.931, ridge: 5, top: 7.866 }); + expect(slideRoof({ width: 60, height: 20, gap: 0.4 })).toMatchObject({ ceiling: 101.931, top: 68.866 }); + // From both edges of the old ceiling (±6.966 at 3.4) up at 45° to the ridge: 4.466 mm in, 4.466 mm up. + const roofed = slideSteps({ ...guide, roof: true }); + expect(roofed[1]?.params.points).toEqual([[-3.566, -0.4], [3.566, -0.4], [3.566, 0], [6.966, 3.4], [2.5, 7.866], [-2.5, 7.866], [-6.966, 3.4], [-3.566, 0]]); + // The rail is the same. + expect(roofed.slice(3)).toEqual(slideSteps(guide).slice(3)); + // The tool measures the moving body first: its top lies `top` over the base's face at z = 4. + const answer = async (top: number, args: Record = {}, { solid = top, wide = 0, hole = 0, broken = false, refused = false, overhang = 45, split = false } = {}) => { + const sent: { command: string; params: Record }[] = []; + const probes: number[][] = []; + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve([]), + callTool: (name, call) => { + if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Base' }, { name: 'Carriage' }], undo: 2 }) }] }); + const commands = call.commands as { command: string; params: Record }[]; + sent.push(...commands); + // The trial rods lie in the carriage where it is solid: up to `solid` over the base's face, along its 20 mm of the line (0.628 mm³ each). + const rods = commands.filter((step) => step.command === 'solid.cylinder' && String(step.params.body_name).startsWith('PartBuddy probe ')); + // Not checked in here: what throws in the trial is taken as a trial that failed. + probes.push(...rods.map((rod) => [...(rod.params.base as number[]).map((value) => Math.round(value * 1000) / 1000), rod.params.height as number])); + if (rods.length > 0 && broken) return Promise.resolve({ content: [{ type: 'text', text: 'no answer' }], isError: true }); + // Above `solid` the carriage goes on as a rib `wide` over the line, up to its top; `hole` mm of each rod lie in a gap. + const inBody = (base: number[]) => (base[2] ?? 0) + 0.1 <= 4 + solid || (Math.abs((base[1] ?? 0) - 15) <= wide / 2 && (base[2] ?? 0) + 0.1 <= 4 + top); + const results = commands.map((step) => (step.command === 'inspect.clearance' ? { overlaps: rods.flatMap((rod) => (inBody(rod.params.base as number[]) ? [{ a: rod.params.body_name, b: 'Carriage', volume_mm3: 0.628 * (1 - hole / 20) }] : [])) } : step.command === 'solid.extrude' && step.params.operation === 'cut' && split ? { created_bodies: ['Carriage (1)'], warning: null } : step.command === 'document.inspect' ? { timeline: [{ id: 1 }, { id: 2 }], undo: 2 } : step.command === 'inspect.measure' ? { bodies: [{ name: 'Carriage', bbox: { min: [20, 0, 4.4], max: [40, 30, 4 + top] } }] } : step.command === 'component.list' ? { components: [{ id: 3, parent: 0, bodies: ['Base'], occurrences: [{ id: 4, name: 'Base:1' }] }, { id: 5, parent: 0, bodies: ['Carriage'], occurrences: [{ id: 6, name: 'Carriage:1' }] }] } : {})); + // SolidCraft refuses the third rod: the batch ends there, with the two results before it and no comparison. + if (rods.length > 0 && refused) return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results: results.slice(0, 2), failed: { command: 'solid.cylinder', error: 'refused', index: 2 } }) }], isError: true }); + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }] }); + }, + }; + const built = await withMechanismTool(upstream, () => 0.4, undefined, () => overhang).callTool('partbuddy_mechanism', { kind: 'slide', fixed: 'Base', moving: 'Carriage', from: [0, 15, 4], to: [60, 15, 4], ...args }); + return { text: String(built.content[0]?.text), groove: sent.find((step) => step.command === 'sketch.line')?.params.points as number[][], sent: sent.map((step) => step.command), probes }; + }; + // 7.866 mm of roof and two lines over its ridge: 8.8 mm. + const high = await answer(8.8); + expect(high.groove).toHaveLength(8); + // The rods lie along the carriage's 20 mm of the line: over the ridge's middle and edges (±2.5 mm) with their tops two lines + // over it, and over the middle of each flank (±4.733 mm, where the roof is 5.633 mm high), not in the groove beside the ridge. + expect(high.probes).toEqual([[20, 15, 12.666, 20], [20, 17.5, 12.666, 20], [20, 12.5, 12.666, 20], [20, 19.733, 10.806, 20], [20, 10.267, 10.806, 20]]); + expect(high.text).toContain("The two print apart, each with up pointing up, and Carriage is then pushed onto the rail from an end. Neither needs support: the rail's flanks and the roof over the groove lean 45°, and the roof's ridge is a bridge of 5 mm."); + // The body is measured before the first step is built. + expect(high.sent.indexOf('inspect.measure')).toBeLessThan(high.sent.indexOf('sketch.create')); + // Lower than that the ceiling stays flat, and the answer says what that means instead of "without support". + const low = await answer(8.5); + expect(low.groove).toHaveLength(6); + expect(low.text).not.toContain('Neither needs support'); + expect(low.text).toContain("The groove's ceiling is flat and 13.9 mm wide, more than prints as a bridge (10 mm): a roof at 45° over it needs Carriage to be solid up to 8.8 mm over the face the rail is on, all along the groove, and it is not. Print Carriage turned over instead, lying on its top with the groove open upwards (set that face as its print face), or make it that high there and build this again."); + // Review of PR #358: a plate 6 mm thick with a knob on it is 16.4 mm high by its box, and the roof (ridge at 7.9 mm) cut it in + // two along the rail under "Neither needs support". The rods find the plate, not the box. + const knob = await answer(16.4, {}, { solid: 6 }); + expect(knob.groove).toHaveLength(6); + expect(knob.text).not.toContain('Neither needs support'); + // Review 2 of PR #358: a rib 6.4 mm wide on that plate holds the three rods over the ridge, and nothing lies over the flanks: + // with the flank rods in the groove beside the ridge the roof was built and cut the plate along both sides of the rib. + const rib = await answer(9, {}, { solid: 6, wide: 6.4 }); + expect(rib.groove).toHaveLength(6); + expect(rib.text).toContain('and it is not. Print Carriage turned over'); + // A cross groove 1.5 mm wide in the carriage (under the old tenth of its length): the roof would break out into it. + const gap = await answer(12, {}, { hole: 1.5 }); + expect(gap.groove).toHaveLength(6); + // Review 3 of PR #358: so do the ends of a round or chamfered body, which is full over the groove: the answer does not call it "not solid". + expect(gap.text).toContain('all along the groove, and it is along most of it only (material is missing at that height along up to 1.5 mm of it: at ends that are round, chamfered or turned, or in a gap across the groove). Print Carriage turned over'); + // Review 3 of PR #358: a rod SolidCraft refuses ends the batch before the comparison, which read as rods outside the body ("it is not"). + expect((await answer(12, {}, { refused: true })).text).toContain("whether it is could not be tried (SolidCraft's trial failed)"); + // A trial that fails says nothing about the body, and a line askew to the axes is tried by the body's box: the answer says which. + const failed = await answer(12, {}, { broken: true }); + expect(failed.groove).toHaveLength(6); + expect(failed.text).toContain("all along the groove, and whether it is could not be tried (SolidCraft's trial failed). Print Carriage turned over"); + expect(failed.text).toContain('or make sure it is that high there and build this again.'); + const askew = await answer(12, { from: [0, 0, 4], to: [60, 30, 4] }, { hole: 4 }); + expect(askew.groove).toHaveLength(6); + expect(askew.text).toContain("and the trial did not find it so (the line runs askew to the axes, where the trial goes by the body's box and can reach past the body). Print"); + // Whatever cuts a body apart, SolidCraft says so only in created_bodies: that fails the call and takes it back. + const cut = await answer(12, {}, { split: true }); + expect(cut.text).toBe('Not built: the step "Guide ·" cut "Carriage" apart ("Carriage (1)" came of it): the body is too thin or too small where the mechanism goes. Everything was taken back.'); + // A small rail under a low body: its flat ceiling of 6.9 mm is a bridge that prints, and the answer says that, not "turn it over". + const small = await answer(2.9, { width: 2, height: 1.5 }); + expect(small.groove).toHaveLength(6); + expect(small.text).toContain("The groove's ceiling is flat and 6.9 mm wide, which prints as a bridge (up to 10 mm), and the rail's flanks lean 45°: neither needs support."); + // A material that prints 40° (PETG): flanks and roof lean 45°, so nothing is promised for it. + const petg = await answer(12, {}, { overhang: 40 }); + expect(petg.groove).toHaveLength(8); + expect(petg.text).not.toContain('Neither needs support'); + expect(petg.text).toContain("The rail's flanks and the roof over the groove lean 45°, more than the 40° the material in use prints without support: they need support there, or a material that prints 45°."); + // The dovetail to plug together is the same groove: the same roof, the same words. + const plug = await answer(12, { kind: 'plug', shape: 'dovetail' }); + expect(plug.groove).toHaveLength(8); + expect(plug.text).toContain('nothing stops it. The two print apart, each with up pointing up'); + expect(await withMechanismDescription()).not.toContain('printed in one piece without support'); + }); + it('says what is wrong before anything is built, and fills in the defaults', () => { expect(slideProblem(guide)).toBeNull(); expect(slideProblem({ ...guide, up: [1, 0, 0] })).toContain('up must be square to the line'); @@ -302,11 +593,16 @@ describe('the snap closure of a lid (G3)', () => { ['sketch.rectangle.two_point', ''], ['solid.extrude', 'Closure'], ['solid.chamfer', 'Closure ◢'], + ['solid.cylinder', 'Closure ◠'], ['solid.box', 'Closure ●'], ['solid.chamfer', 'Closure ◆'], ['solid.cylinder', 'Closure ○'], - ['solid.cylinder', 'Closure ◠'], ]); + // The notch is cut before the bead is built: its cylinder (16 mm wide, from the wall's plane outwards, 2.5 mm into the lid + // above the rim) also covers the bead's height below the rim, and would take the 0.25 mm of bead past that plane with it. + expect(steps[9]?.params).toMatchObject({ diameter: '16 mm', base: ['(2 mm + 58 mm) / 2', '2 mm - 2 mm', '20 mm - 5.5 mm'], axis: 'Y', operation: 'cut', targets: ['Lid'] }); + const [notch, bead] = [steps.findIndex((step) => step.params.name === 'Closure ◠'), steps.findIndex((step) => step.params.name === 'Closure ●')]; + expect(notch).toBeLessThan(bead); // The foot lies above the rim in the lid, 0.2 mm into its wall; the lip hangs 4 mm below the rim, 0.25 mm clear, 1.2 mm thick. expect(steps[0]?.params.offset).toBe('20 mm'); expect(steps[1]?.params).toEqual({ p0: ['2 mm - 0.2 mm', '2 mm - 0.2 mm'], p1: ['58 mm + 0.2 mm', '38 mm + 0.2 mm'] }); @@ -316,8 +612,8 @@ describe('the snap closure of a lid (G3)', () => { // Only the rille is cut from the box; everything else belongs to the lid. expect(steps.filter((step) => JSON.stringify(step.params.targets ?? step.params.body ?? '').includes('Box')).map((step) => step.params.name)).toEqual(['Closure ○']); // The bead stands 0.25 mm past the wall's face (0.5 mm proud of the lip), 14 mm long in the wall's middle; its rille sits where it lands. - expect(steps[9]?.params).toMatchObject({ length: '14 mm', width: '0.5 mm', height: '1 mm', corner: ['(2 mm + 58 mm) / 2 - 7 mm', '2 mm - 0.25 mm', '20 mm - 3 mm'] }); - expect(steps[11]?.params).toMatchObject({ diameter: '1.4 mm', axis: 'X', base: ['(2 mm + 58 mm) / 2 - 7.5 mm', '2 mm + 0.25 mm', '20 mm - 2.5 mm'] }); + expect(steps[10]?.params).toMatchObject({ length: '14 mm', width: '0.5 mm', height: '1 mm', corner: ['(2 mm + 58 mm) / 2 - 7 mm', '2 mm - 0.25 mm', '20 mm - 3 mm'] }); + expect(steps[12]?.params).toMatchObject({ diameter: '1.4 mm', axis: 'X', base: ['(2 mm + 58 mm) / 2 - 7.5 mm', '2 mm + 0.25 mm', '20 mm - 2.5 mm'] }); // A flat plate as the lid gets the lip alone on its face: no foot that would come out of its top, no notch in a wall it does not have. expect(lipSteps({ ...lid, flat: true }).map((step) => step.params.name ?? '').filter(Boolean)).toEqual(['Closure', 'Closure', 'Closure ●', 'Closure ◆', 'Closure ○']); expect(lipOf({ fixed: 'Box', moving: 'Lid', from: [2, 2, 20], to: [58, 38, 20], side: 'y-', flat: true })).toMatchObject({ flat: true }); @@ -327,7 +623,7 @@ describe('the snap closure of a lid (G3)', () => { it('puts the rille where the bead lands when the lid is built open and closes on a hinge, and follows a template\'s parameters', () => { const open = lipSteps({ ...lid, up: 1, side: 'y+', rim: { at: 16, expr: 'height' }, hinge: { along: 'x', across: 0.5, z: { at: 16, expr: 'height' } } }); - expect(open[11]?.params.base).toEqual(['(2 mm + 58 mm) / 2 - 7.5 mm', '2 * 0.5 mm - (38 mm - 0.25 mm)', '2 * (height) - ((height) + 2.5 mm)']); + expect(open[12]?.params.base).toEqual(['(2 mm + 58 mm) / 2 - 7.5 mm', '2 * 0.5 mm - (38 mm - 0.25 mm)', '2 * (height) - ((height) + 2.5 mm)']); expect(open[0]?.params.offset).toBe('(height) - 3 mm'); }); @@ -361,7 +657,7 @@ describe('the snap closure of a lid (G3)', () => { const built = await withMechanismTool(upstream, () => 0.4).callTool('partbuddy_mechanism', { kind: 'closure', fixed: 'Box', moving: 'Lid', from: [2, 2, 20], to: [58, 38, 20], up: [0, 0, -1], side: 'y-' }); expect(built.isError).toBeUndefined(); expect(built.content[0]).toMatchObject({ text: expect.stringContaining('Built the closure "Closure" between Box (the box) and Lid (the lid): a lip 1.2 mm thick and 4 mm high inside the lid, 0.25 mm clear') as string }); - expect(batches).toHaveLength(3); + expect(batches).toHaveLength(4); expect(batches.join(' ')).not.toContain('joint.as_built'); }); }); @@ -554,7 +850,7 @@ describe('a plugged connection (standards)', () => { if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Left' }, { name: 'Right' }] }) }] }); const commands = input.commands as { command: string }[]; calls.push(commands.map((step) => step.command)); - return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results: commands.map((step) => (step.command === 'inspect.interference' ? { count: 0 } : step.command === 'component.list' ? { components: [] } : {})) }) }] }); + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results: commands.map((step) => (step.command === 'inspect.interference' ? { count: 0 } : step.command === 'component.list' ? { components: [] } : step.command === 'inspect.measure' ? { bodies: [{ bbox: { min: [0, 0, 4.2], max: [40, 30, 16] } }] } : {})) }) }] }); }, }); diff --git a/apps/desktop/test/takeBack.test.ts b/apps/desktop/test/takeBack.test.ts new file mode 100644 index 0000000..a3abde3 --- /dev/null +++ b/apps/desktop/test/takeBack.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +import { markOf, takeBack, type Run } from '../src/main/agents/takeBack'; + +/** + * A design as SolidCraft 0.40.1 answers for it: every built step is a feature and an undo step, a joint is an undo + * step that is no feature, the undo depth stops at 200, and activating a component is not undone. + */ +function design(features: number, { timeline = true } = {}) { + const steps: ('feature' | 'joint')[] = Array.from({ length: features }, () => 'feature'); + let next = features + 1; + const ids = Array.from({ length: features }, (_, i) => i + 1); + const joints: number[] = []; + const sent: string[][] = []; + let active = 0; + const run: Run = (commands) => { + sent.push(commands.map((step) => step.command)); + return Promise.resolve( + commands.map((step) => { + if (step.command === 'edit.undo') { + const last = steps.pop(); + if (last === undefined) throw new Error('nothing to undo'); + if (last === 'feature') ids.pop(); + else joints.pop(); + return { undone: last }; + } + if (step.command === 'component.activate') active = step.params.component as number; + if (step.command === 'document.inspect') return { ...(timeline ? { timeline: ids.map((id) => ({ id })) } : {}), sketches: [], bodies: ['Base', 'Flap'], undo: Math.min(steps.length, 200) }; + if (step.command === 'joint.list') return { joints: joints.map((id) => ({ id })) }; + return {}; + }), + ); + }; + return { + run, + sent, + build: (count: number, joint = false) => { + for (let i = 0; i < count; i += 1) { + steps.push('feature'); + ids.push(next++); + } + if (joint) { + steps.push('joint'); + joints.push(next++); + } + active = 5; + }, + state: () => ({ features: ids.length, joints: joints.length, active }), + undos: () => sent.flat().filter((command) => command === 'edit.undo').length, + }; +} + +describe('taking back what a tool built before it failed', () => { + it('undoes every step since the mark and leaves the design level active', async () => { + const d = design(12); + const mark = await markOf(d.run); + d.build(54, true); + expect(await takeBack(d.run, mark)).toBe(true); + expect(d.state()).toEqual({ features: 12, joints: 0, active: 0 }); + // Below the ceiling of the undo depth the difference is the number of steps: one batch, looked at afterwards. + expect(d.undos()).toBe(55); + expect(d.sent.at(-2)?.filter((command) => command === 'edit.undo')).toHaveLength(55); + expect(d.sent.at(-1)).toEqual(['component.activate']); + }); + + // Review of PR #358, measured on SolidCraft 0.40.1: 260 steps read `undo: 200`. With the depth at 200 before and + // after, the difference was 0 and nothing was undone; at 170 before, 30 of 54 steps were, and half a hinge stayed. + it('goes on past the ceiling of the undo depth until the design is as it was', async () => { + for (const before of [170, 200, 240]) { + const d = design(before); + const mark = await markOf(d.run); + expect(mark?.undo).toBe(Math.min(before, 200)); + d.build(54, true); + expect(await takeBack(d.run, mark), String(before)).toBe(true); + expect(d.state(), String(before)).toEqual({ features: before, joints: 0, active: 0 }); + expect(d.undos(), String(before)).toBe(55); + } + }); + + it('undoes nothing where nothing was built, and still leaves the design level active', async () => { + const d = design(7); + const mark = await markOf(d.run); + d.build(0); + expect(await takeBack(d.run, mark)).toBe(true); + expect(d.undos()).toBe(0); + expect(d.state().active).toBe(0); + }); + + it('says so when the design cannot be known again, and takes nothing that was there before', async () => { + // No timeline in SolidCraft's answer: no mark, nothing undone. + const blind = design(5, { timeline: false }); + expect(await markOf(blind.run)).toBeNull(); + blind.build(9); + expect(await takeBack(blind.run, null)).toBe(false); + expect(blind.undos()).toBe(0); + expect(blind.state().active).toBe(0); + // A mark the design never comes back to (another feature in its place): the undoing ends at the mark's number of + // features, not at the bottom of the timeline. + const other = design(5); + const mark = await markOf(other.run); + other.build(3); + expect(await takeBack(other.run, mark ? { ...mark, state: '[]', undo: 200 } : null)).toBe(false); + expect(other.state().features).toBe(5); + expect(other.undos()).toBe(3); + }); +}); diff --git a/apps/desktop/test/templates.test.ts b/apps/desktop/test/templates.test.ts index cc253d1..d00cc79 100644 --- a/apps/desktop/test/templates.test.ts +++ b/apps/desktop/test/templates.test.ts @@ -26,7 +26,7 @@ describe('templates (F3)', () => { for (const step of steps.slice(declared.length)) { for (const [key, value] of Object.entries(step.params)) { if (!EXPRESSION_FIELDS.has(key)) continue; - for (const text of expressions(value)) for (const word of text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) if (word !== 'mm') used.add(word); + for (const text of expressions(value)) for (const word of text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) if (!['mm', 'max', 'min'].includes(word)) used.add(word); } } expect([...used].filter((word) => !names.has(word)), id).toEqual([]); @@ -115,6 +115,26 @@ describe('templates with moving parts', () => { for (const step of knuckles) expect(step.params.base).toEqual([expect.stringMatching(/^length \/ 2 \+ -?[\d.]+ mm$/), 0.5, 'height']); } expect(germanFeatureName('Hinge 2 ·')).toBe('Scharnier 2 ·'); + // Audit 2026-11-12, T3: the template called the hinge without the webs of the tool, so its knuckles began 12 mm above the + // bed in the air (189 mm² of overhang, up to 87°) under a file that promises "without support". + const steps = templateSteps('hingedBox'); + const webs = steps.filter((step) => step.command === 'solid.extrude' && String(step.params.name).endsWith('◣')); + expect(webs.map((step) => [step.params.name, (step.params.targets as string[])[0], step.params.distance])).toEqual([['Hinge 1 ◣', 'Box', '7.68 mm'], ['Hinge 2 ◣', 'Lid', '7.68 mm'], ['Hinge 3 ◣', 'Box', '7.68 mm'], ['Hinge 4 ◣', 'Lid', '7.68 mm'], ['Hinge 5 ◣', 'Box', '7.68 mm']]); + // Each is drawn on the world's YZ plane at the knuckle's low end in x, from the rim down, so it follows length and height: + // the box's from the line (y 0.5) towards -y to 0.5 mm inside its wall, the lid's towards +y. + const drawn = (name: string) => steps.slice(steps.findIndex((step) => step.command === 'sketch.create' && step.params.name === name)).slice(0, 2).map((step) => step.params); + expect(drawn('Hinge 1 ◣')).toEqual([{ plane: 'YZ', offset: 'length / 2 + -20 mm', project_edges: false, name: 'Hinge 1 ◣' }, { points: [['3.328 mm', 'height + 0 mm'], ['3.328 mm', 'max(height + -2.828 mm, 0 mm)'], ['0.5 mm - min(0.5 mm, height + -5.657 mm)', 'max(height + -6.157 mm, 0 mm)'], ['-0.5 mm', 'max(height + -6.157 mm, 0 mm)'], ['-0.5 mm', 'height + 0 mm']], closed: true }]); + // Review of PR #358: "at least 8 mm" stood only in the parameter's comment, and at 5 mm the webs reached 1.16 mm below the + // floor. A web ends on the bed at the latest; its room in the other half is a cut and may go on. + expect(JSON.stringify(drawn('Hinge 1 ◤')[1]?.points)).not.toContain('max('); + expect(drawn('Hinge 2 ◣')[0]).toMatchObject({ plane: 'YZ', offset: 'length / 2 + -11.92 mm' }); + // Review 2 of PR #358: with the foot only lifted to the bed the web's face leant 57° at 5 mm. The foot comes in along the face + // (to height - 5.657 mm from the line where that is less than the 0.5 mm to the wall), so the face keeps 45°. + expect((drawn('Hinge 2 ◣')[1]?.points as string[][])[2]).toEqual(['0.5 mm + min(0.5 mm, height + -5.657 mm)', 'max(height + -6.157 mm, 0 mm)']); + expect(germanFeatureName('Hinge 1 ◣')).toBe('Scharnier 1 ◣'); + expect(germanFeatureName('Hinge 2 ▬')).toBe('Scharnier 2 ▬'); + // The joint is kept on the pin's round top: underneath the pin is flat now. + expect((steps.find((step) => step.command === 'joint.create')?.params.a as { circle: number[] }).circle.map((value) => Math.round(value * 100) / 100)).toEqual([25.46, 0.5, 18]); }); it('joins the stop as a slider, by name in either language', () => {