Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/desktop/e2e/practice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ const PARTS: Part[] = [
'Baue eine flache Dose 60 × 40 × 20 mm mit einem Deckel, der über ein Scharnier an einer langen Seite aufklappt. ' +
'Dose und Deckel sollen zusammen in einem Stück druckbar sein, ohne Stützmaterial.',
},
// The standards of 2026-11-11: no request names a tool; the work log shows whether the block or mechanism was taken,
// and for the last two the answer shows whether partbuddy_standard was read.
{ id: '7-poti-knopf', request: 'Bau mir einen Drehknopf für ein Poti mit 6-mm-D-Welle.' },
{ id: '8-schnapphaken', request: 'Bau ein flaches Gehäuse 60 × 40 × 20 mm aus Boden und Haube, die mit Schnapphaken aufgeklipst wird.' },
{ id: '9-steckverbindung', request: 'Teile einen Block 60 × 30 × 20 mm in zwei Hälften, die ich zusammenstecken und verkleben kann.' },
{ id: '10-magnetdeckel', request: 'Bau einen Deckel 50 × 50 × 4 mm mit vier Taschen für 6×2-mm-Magnete in den Ecken.' },
{ id: '11-schnapplasche', request: 'Bau eine Dose 60 × 40 × 30 mm mit aufgesetztem Deckel und einem Verschluss außen an der Vorderseite.' },
{ id: '12-pi-gehaeuse', request: 'Bau ein Gehäuse für einen Raspberry Pi 4 mit Lüftungsschlitzen.' },
{ id: '13-rohrschelle', request: 'Bau eine Schelle für ein 22-mm-Rohr zum Aufschnappen.' },
];

const PRINT_CHECK =
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/e2e/solidcraft.contract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3382,7 +3382,7 @@ test('the assistant makes a knob for a D shaft with one call: a part of its own
await expect(page.getByTestId('connection-status')).toHaveAttribute('data-engine', 'headless', { timeout: 30_000 });
await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a volume knob');
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByText(/Built the knob "Volume" for a 6 mm D shaft \(flat at 4\.5 mm\): the bore is 6\.2 mm wide and 11 mm deep, flat over its last 7 mm .* with 11 flutes to grip/)).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/Built the knob "Volume" for a 6 mm D shaft \(flat at 4\.5 mm\): the bore is 6\.2 mm wide and 11 mm deep, flat over its last 7 mm .* with a chamfered mouth, to push on.* with 11 flutes to grip/)).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/SolidCraft warns:/)).toHaveCount(0);
await expect(page.getByTestId('mesh-summary')).toContainText('1 body', { timeout: 30_000 });
await expect(page.getByText('Knob “Volume” for a shaft')).toHaveCount(1);
Expand Down Expand Up @@ -3425,6 +3425,8 @@ test('the assistant latches a lid onto its box: the arm grows out of the lid and
await expect(findings).toBeVisible();
await page.waitForTimeout(1500);
await expect(findings.locator('[data-kind="overlap"]')).toHaveCount(0);
// Bead and hook end in a flat crest, not a knife edge the nozzle cannot print.
await expect(findings.locator('[data-kind="thinWall"]')).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath('latch-built.png') });
} finally {
await launched.close();
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/agents/blockTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ export function knobOf(args: Record<string, unknown>): { body: string; steps: St
const insert = knobInsert(spec);
const hold =
shaft === 'd'
? `for a ${String(shaftDiameter)} mm D shaft (flat at ${String(flat)} mm): the bore is ${String(bore)} mm wide and ${String(insert)} mm deep, flat over its last ${String(Math.min(KNOB.flat, insert))} mm on the side away from the pointer, to push on`
? `for a ${String(shaftDiameter)} mm D shaft (flat at ${String(flat)} mm): the bore is ${String(bore)} mm wide and ${String(insert)} mm deep, flat over its last ${String(Math.min(KNOB.flat, insert))} mm on the side away from the pointer, with a chamfered mouth, to push on`
: shaft === 'knurl'
? `for a split knurled shaft of ${String(shaftDiameter)} mm: a plain bore ${String(bore)} mm wide and ${String(insert)} mm deep, which the shaft springs into`
: `for a round ${String(shaftDiameter)} mm shaft: the bore is ${String(bore)} mm wide and ${String(insert)} mm deep, and an M3 set screw cuts its own thread in the ${String(KNOB.setScrew)} mm hole on the side away from the pointer`;
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/agents/mechanismTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ async function buildMechanism(upstream: UpstreamTools, args: Record<string, unkn
return (
`Built the latch "${spec.name}": an arm ${String(spec.width)} mm wide and ${String(LATCH.thick)} mm thick grows out of the wall of ${spec.moving} (the lid), hangs ${String(LATCH.off)} mm off the wall over the joint and snaps with its hook under a bead ${String(LATCH.bead)} mm high on ${spec.fixed} (the box); the two overlap by ${String(LATCH.bead - LATCH.clear)} mm on 45° faces. ` +
`Pressing the lid down snaps it shut (about 1.3 % strain at the arm's root); pulling the arm's lower end away from the box opens it, and so does a strong pull on the lid. It makes no joint. ` +
`It needs ${String(reach.above)} mm of flat wall on the lid above the joint and ${String(reach.below)} mm on the box below it. Print the lid on its top: the arm then grows out of it without support; the box prints standing. Not printed yet: how firmly it holds shows on the first print.${warnings}`
`It needs ${String(reach.above)} mm of flat wall on the lid above the joint and ${String(reach.below)} mm on the box below it. Print the lid on its top: the arm then grows out of it without support; the box prints standing. The arm stands upright that way and bends across its layers, the weak way: for a lid opened often use PETG or ABS rather than PLA. Not printed yet: how firmly it holds shows on the first print.${warnings}`
);
}
if (args.kind === 'plug' && args.shape === 'dovetail') {
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/src/shared/knob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ export type KnobShaft = (typeof KNOB_SHAFTS)[number];
/**
* In mm: the plastic over the shaft's end, the wall left round the bore under the flutes, how far
* the shaft goes in at the most, how much of that is flat on a D shaft (the shortest flat sold),
* the flutes (as a screw lid's), the plain band and its chamfer at the top, the pointer's groove,
* the flutes (as a screw lid's), the plain band and its chamfer at the top, the chamfer at the bore's
* mouth, the pointer's groove,
* and the pilot hole an M3 set screw cuts its own thread in.
*/
export const KNOB = { roof: 3, wall: 2.4, insert: 11, flat: 7, flute: 3, band: 2, chamfer: 0.6, pointer: { width: 1.2, depth: 0.6 }, setScrew: 2.6 };
export const KNOB = { roof: 3, wall: 2.4, insert: 11, flat: 7, flute: 3, band: 2, chamfer: 0.6, mouth: 0.5, pointer: { width: 1.2, depth: 0.6 }, setScrew: 2.6 };

/** The room added to the shaft's diameter: a D and a round shaft push in snugly; a split knurled shaft springs into a plain bore of its own size. */
export const KNOB_CLEARANCE: Record<KnobShaft, number> = { d: 0.2, round: 0.2, knurl: 0 };
Expand Down Expand Up @@ -106,6 +107,8 @@ export function knobSteps(spec: KnobSpec): Step[] {
{ command: 'solid.cylinder', params: { diameter: mm(KNOB.flute), height: mm(spec.height - KNOB.band), base: at(0, (radius + KNOB.flute / 4) * Math.cos(half), (radius + KNOB.flute / 4) * Math.sin(half)), axis: tidy(axis), operation: 'cut', targets: [name], name: `${name} ◆` } },
{ command: 'solid.pattern.circular', params: { features: [`${name} ◆`], axis: { origin: at(0), dir: tidy(axis) }, count: flutes } },
{ command: 'solid.cylinder', params: { diameter: mm(bore), height: mm(insert), base: at(0), axis: tidy(axis), operation: 'cut', targets: [name], name: `${name} ○` } },
// The shaft finds its way in; printed on its top, the mouth is the last layer and needs no support.
{ command: 'solid.cone', params: { diameter: mm(bore + 2 * KNOB.mouth), top_diameter: mm(bore), height: mm(KNOB.mouth), base: at(0), axis: tidy(axis), operation: 'cut', targets: [name], name: `${name} ◣` } },
];
if (spec.shaft === 'd') {
// The flat fills the bore's end again, on the side away from the pointer; the entry stays round, as the shaft is below its flat.
Expand Down
24 changes: 14 additions & 10 deletions apps/desktop/test/blockTool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ describe('a knob for a shaft (standards)', () => {
['solid.cylinder', 'Knob ◆'],
['solid.pattern.circular', ''],
['solid.cylinder', 'Knob ○'],
['solid.cone', 'Knob ◣'],
['sketch.create', 'Knob ▬'],
['sketch.line', ''],
['solid.extrude', 'Knob ▬'],
Expand All @@ -81,15 +82,17 @@ describe('a knob for a shaft (standards)', () => {
// The bore: the shaft and 0.2 mm, 11 mm deep under 5 mm of roof.
expect(steps[4]?.params).toMatchObject({ diameter: '6.2 mm', height: '11 mm', base: [0, 0, 0], operation: 'cut' });
// The flat over the bore's last 7 mm: sketch x runs to the pointer (+Y), and the flat lies at x = −(4.5 − 3 + 0.1).
expect(steps[5]?.params.plane).toEqual({ origin: [0, 0, 4], x_dir: [0, 1, 0], y_dir: [-1, 0, 0] });
expect(steps[6]?.params).toEqual({ points: [[-1.6, -3.1], [-1.6, 3.1], [-3.6, 3.1], [-3.6, -3.1]], closed: true });
expect(steps[7]?.params).toMatchObject({ distance: '7 mm', operation: 'join' });
// Its mouth opens by 0.5 mm, so the shaft finds its way in.
expect(steps[5]?.params).toMatchObject({ diameter: '7.2 mm', top_diameter: '6.2 mm', height: '0.5 mm', base: [0, 0, 0], operation: 'cut' });
expect(steps[6]?.params.plane).toEqual({ origin: [0, 0, 4], x_dir: [0, 1, 0], y_dir: [-1, 0, 0] });
expect(steps[7]?.params).toEqual({ points: [[-1.6, -3.1], [-1.6, 3.1], [-3.6, 3.1], [-3.6, -3.1]], closed: true });
expect(steps[8]?.params).toMatchObject({ distance: '7 mm', operation: 'join' });
// The pointer: a groove on the top from a third of the radius to the rim, towards +Y.
expect(steps[8]?.params.plane).toEqual({ origin: [0, 0, 16], x_dir: [-1, 0, 0], y_dir: [0, 1, 0] });
expect(steps[9]?.params).toEqual({ p0: [0, 4.375], p1: [0, 12.5], width: 1.2 });
expect(steps[10]?.params).toMatchObject({ distance: '0.6 mm', operation: 'cut' });
expect(steps[11]?.params).toEqual({ body: 'Knob', point: [0, 0, 16] });
expect(KNOB).toMatchObject({ roof: 3, wall: 2.4, insert: 11, flat: 7, setScrew: 2.6 });
expect(steps[9]?.params.plane).toEqual({ origin: [0, 0, 16], x_dir: [-1, 0, 0], y_dir: [0, 1, 0] });
expect(steps[10]?.params).toEqual({ p0: [0, 4.375], p1: [0, 12.5], width: 1.2 });
expect(steps[11]?.params).toMatchObject({ distance: '0.6 mm', operation: 'cut' });
expect(steps[12]?.params).toEqual({ body: 'Knob', point: [0, 0, 16] });
expect(KNOB).toMatchObject({ roof: 3, wall: 2.4, insert: 11, flat: 7, mouth: 0.5, setScrew: 2.6 });
});

it('gives a round shaft a set screw from the side away from the pointer, and a knurled one a plain bore', () => {
Expand All @@ -100,15 +103,16 @@ describe('a knob for a shaft (standards)', () => {
const knurl = knobSteps({ ...knob, shaft: 'knurl', clearance: 0 });
expect(knurl.map((step) => step.command)).not.toContain('sketch.line');
expect(knurl[4]?.params).toMatchObject({ diameter: '6 mm' });
expect(knurl[5]?.params).toMatchObject({ diameter: '7 mm', top_diameter: '6 mm' });
});

it('fills a knob in from the standard and refuses what cannot hold a shaft', () => {
const built = knobOf({ block: 'knob', body: 'Volume', point: [0, 0, 0] });
expect(built).toMatchObject({ body: 'Volume', fresh: true });
expect((built as { text: string }).text).toContain('Built the knob "Volume" for a 6 mm D shaft (flat at 4.5 mm): the bore is 6.2 mm wide and 11 mm deep, flat over its last 7 mm on the side away from the pointer, to push on. It is 25 mm wide and 16 mm high, with 11 flutes to grip');
expect((built as { text: string }).text).toContain('Built the knob "Volume" for a 6 mm D shaft (flat at 4.5 mm): the bore is 6.2 mm wide and 11 mm deep, flat over its last 7 mm on the side away from the pointer, with a chamfered mouth, to push on. It is 25 mm wide and 16 mm high, with 11 flutes to grip');
expect((knobOf({ block: 'knob', body: 'Volume', point: [0, 0, 0], shaft: 'knurl' }) as { text: string }).text).toContain('a plain bore 6 mm wide and 11 mm deep');
// A shaft along Y takes its pointer up.
expect((knobOf({ block: 'knob', body: 'Volume', point: [0, 0, 0], normal: [0, 1, 0] }) as { steps: { params: { plane?: { y_dir: number[] } } }[] }).steps[8]?.params.plane?.y_dir).toEqual([0, 0, 1]);
expect((knobOf({ block: 'knob', body: 'Volume', point: [0, 0, 0], normal: [0, 1, 0] }) as { steps: { params: { plane?: { y_dir: number[] } } }[] }).steps[9]?.params.plane?.y_dir).toEqual([0, 0, 1]);
expect(knobOf({ block: 'knob', body: 'Volume', point: [0, 0, 0], shaftDiameter: 6.35 })).toBe("a D shaft of 6.35 mm has no one flat: give flat, the shaft's thickness there, as measured");
expect(knobOf({ block: 'knob', body: 'Volume', point: [0, 0, 0], shaftDiameter: 6.35, flat: 5.54 })).toMatchObject({ body: 'Volume' });
expect(knobOf({ block: 'knob', body: 'Volume', point: [0, 0, 0], diameter: 10 })).toBe('diameter must be 13–80 mm for a 6 mm shaft');
Expand Down