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
43 changes: 43 additions & 0 deletions apps/desktop/e2e/solidcraft.contract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2181,6 +2181,10 @@ test('a template from the start page becomes a project whose sizes are its param
await page.keyboard.press('End');
await expect(page.getByTestId('joint-panel').getByRole('slider', { name: 'Hinge' })).toHaveValue('180', { timeout: 30_000 });
await page.screenshot({ path: test.info().outputPath('template-hingedBox-closed.png') });
// While SolidCraft drives the joint the slider takes no keys: back once it is free again.
const closed = page.getByTestId('joint-panel').getByRole('slider', { name: 'Hinge' });
await expect(closed).toBeEnabled({ timeout: 30_000 });
await closed.focus();
await page.keyboard.press('Home');
await expect(page.getByTestId('joint-panel').getByRole('slider', { name: 'Hinge' })).toHaveValue('0', { timeout: 30_000 });
}
Expand Down Expand Up @@ -3318,6 +3322,45 @@ test('the assistant builds a dovetail guide with one call: joined as a slider th
}
});

test('the assistant builds a screw joint with one call: holes on one line in both parts and the screws as standard parts', async () => {
test.setTimeout(240_000);
const { mkdtempSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const work = mkdtempSync(join(tmpdir(), 'partbuddy-screw-'));
const env = {
SOLIDCRAFT_CONTROL_DIR: join(work, 'control'),
PARTBUDDY_SOLIDCRAFT_PORT: '7954',
PARTBUDDY_PROJECTS_DIR: join(work, 'projects'),
PARTBUDDY_KEYRING_SERVICE: 'PartBuddy-e2e',
PARTBUDDY_CLAUDE_COMMAND: JSON.stringify([process.execPath, join(__dirname, '..', 'test', 'fakeClaude.mjs')]),
};
const launched = await launchApp({ isolated: false, work, env });
const { page } = launched;
try {
await expect(page.getByTestId('connection-status')).toHaveAttribute('data-engine', 'headless', { timeout: 30_000 });
await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a screwed plate');
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByText(/Built 2 screw joints "Plate screws" that hold Plate on Block: .* To buy: 2 × M3×8 socket head cap screws \(ISO 4762\) and 2 M3 heat-set inserts 5\.7 mm long/)).toBeVisible({ timeout: 60_000 });
await expect(page.getByText(/Warning:/)).toHaveCount(0);
// Block, plate and two screws, which the tree knows as standard parts.
await expect(page.getByTestId('mesh-summary')).toContainText('4 bodies', { timeout: 30_000 });
await expect(page.getByText('standard part', { exact: true })).toHaveCount(2);
await expect(page.getByText('Screw joint “Plate screws” between two parts')).toHaveCount(1);
await expect(page.locator('[data-testid="timeline-feature"][data-state="error"]')).toHaveCount(0);
// The screws overlap nothing: every hole is where its screw is.
await page.getByTestId('print-check').click();
const findings = page.getByTestId('print-check-panel');
await expect(findings).toBeVisible();
await page.waitForTimeout(1500);
await expect(findings.locator('[data-kind="overlap"]')).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath('screw-joint-built.png') });
} finally {
await launched.close();
rmSync(work, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 });
}
});

test('the assistant builds a snap closure with one call: a lip in the lid, its bead in a rille of the box, no overlap (G3)', async () => {
test.setTimeout(240_000);
const { mkdtempSync, rmSync } = await import('node:fs');
Expand Down
50 changes: 45 additions & 5 deletions apps/desktop/src/main/agents/mechanismTool.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LIP, hingeProblem, hingeSteps, lipProblem, lipSteps, pinDiameter, slideProblem, slideSteps, type HingeSpec, type LipSpec, type SlideSpec } from '../../shared/mechanisms';
import { LIP, hingeProblem, hingeSteps, lipProblem, lipSteps, pinDiameter, screwLength, screwProblem, screwSteps, slideProblem, slideSteps, type HingeSpec, type LipSpec, type ScrewSpec, type SlideSpec } from '../../shared/mechanisms';
import { BLOCK_SIZES, INSERT, type BlockSize } from '../../shared/screwSizes';
import { componentOf } from '../solidcraft/actions';
import type { ToolResult, UpstreamTool, UpstreamTools } from './gateProxy';

Expand All @@ -24,11 +25,13 @@ const mechanismTool: UpstreamTool = {
'closure: a snap closure between a lid and its box, nothing standing out: a lip inside the lid that goes into the box along its wall and centres the lid, a bead that snaps into a rille of the box\'s wall, and a notch for a fingernail. ' +
'Give from and to as opposite corners of the lid\'s inner room at its rim (both at the rim\'s height), up as the way the lip stands out of the lid (+Z when the lid lies open side up, -Z when it sits on the box), wall and radius of the lid, and the side with the catch (a long wall, opposite the hinge). ' +
'When the lid is built open beside the box and closes on a hinge, give the hinge line too (hinge_from, hinge_to), so the rille lands where the bead closes. It makes no joint. ' +
'Use this instead of modelling a hinge, a guide or a snap lid yourself.',
'screw: one or more screws that hold the moving body on the fixed one: a clearance hole with the room for the head in the moving body, and on the same line the seat of a heat-set insert (or a modelled thread from M5) in the fixed body; the screws go in as standard parts. ' +
'Give from where the screw goes in (on the outer face of the moving body) and to where its line passes into the fixed body (on the face the two meet at); more lists where further screws of the same kind go in. ' +
'Use this instead of modelling a hinge, a guide, a snap lid or the holes of a screw joint yourself.',
inputSchema: {
type: 'object',
properties: {
kind: { type: 'string', enum: ['hinge', 'slide', 'closure'], description: 'hinge: turns about a line; slide: moves along a line; closure: a lid snaps shut on its box.' },
kind: { type: 'string', enum: ['hinge', 'slide', 'closure', 'screw'], description: 'hinge: turns about a line; slide: moves along a line; closure: a lid snaps shut on its box; screw: one body screwed onto the other.' },
fixed: { type: 'string', description: 'The body that stays (exact name); it carries the pin or the rail. closure: the box.' },
moving: { type: 'string', description: 'The body that moves (exact name). closure: the lid.' },
from: { ...vector, description: 'One end of the line in mm. closure: one corner of the lid\'s inner room at its rim.' },
Expand All @@ -37,6 +40,11 @@ const mechanismTool: UpstreamTool = {
side: { type: 'string', enum: ['x-', 'x+', 'y-', 'y+'], description: 'closure: the wall of the lid\'s inner room with the catch and the notch.' },
wall: { type: 'number', minimum: 1.6, maximum: 10, description: 'closure: the lid\'s wall thickness in mm; default 2.' },
radius: { type: 'number', minimum: 0, maximum: 100, description: 'closure: the radius of the inner room\'s corners in mm; default 0 (sharp).' },
size: { type: 'string', enum: [...BLOCK_SIZES], description: 'screw: the screw; default M3.' },
head: { type: 'string', enum: ['cap', 'countersunk', 'none'], description: 'screw: socket head sunk in a counterbore (default), countersunk head, or a head lying on the face.' },
thread: { type: 'string', enum: ['insert', 'tapped'], description: 'screw: a heat-set insert (default, M2 to M6) or a thread modelled into the plastic (M5 and larger).' },
more: { type: 'array', items: vector, maxItems: 40, description: 'screw: where further screws like this one go in; they run the same way through the same thickness.' },
screws: { type: 'boolean', description: 'screw: put the screws in as standard parts; default true.' },
hinge_from: { ...vector, description: 'closure: one end of the hinge line the lid closes about, when it is built open.' },
hinge_to: { ...vector, description: 'closure: the other end of that line; it runs along X or along Y.' },
diameter: { type: 'number', minimum: 4, maximum: 40, description: 'hinge: outer diameter of the barrel in mm; default 6. The pin is half of it.' },
Expand Down Expand Up @@ -153,6 +161,22 @@ export function lipOf(args: Record<string, unknown>): LipSpec | string {
return lipProblem(spec) ?? spec;
}

/** The screw joint the model asked for, with PartBuddy's defaults; or what is wrong with the request. */
export function screwOf(args: Record<string, unknown>): ScrewSpec | string {
const base = common(args, 0.4, 'Screw');
if (typeof base === 'string') return base;
const size = args.size === undefined ? 'M3' : args.size;
if (typeof size !== 'string' || !(BLOCK_SIZES as readonly string[]).includes(size)) return `size must be one of ${BLOCK_SIZES.join(', ')}`;
const head = args.head === undefined ? 'cap' : args.head;
if (head !== 'cap' && head !== 'countersunk' && head !== 'none') return 'head must be cap, countersunk or none';
const thread = args.thread === undefined ? 'insert' : args.thread;
if (thread !== 'insert' && thread !== 'tapped') return 'thread must be insert or tapped';
const more = args.more === undefined ? [] : Array.isArray(args.more) ? args.more.map(vec3) : null;
if (!more || more.some((point) => point === null)) return 'more must be a list of points, each three numbers';
const spec: ScrewSpec = { fixed: base.fixed, moving: base.moving, from: base.from, to: base.to, more: more as Vec3[], size: size as BlockSize, head, thread, screws: args.screws !== false, name: base.name };
return screwProblem(spec) ?? spec;
}

/** The occurrence that places a body, from `component.list`; null while the body is still in the design itself. */
export function occurrenceOf(listing: unknown, body: string): string | null {
const components = (listing as { components?: unknown } | undefined)?.components;
Expand Down Expand Up @@ -336,7 +360,23 @@ async function buildMechanism(upstream: UpstreamTools, args: Record<string, unkn
`Built the closure "${spec.name}" between ${spec.fixed} (the box) and ${spec.moving} (the lid): a lip ${String(LIP.thick)} mm thick and ${String(LIP.high)} mm high inside the lid, ${String(LIP.clear)} mm clear of the box's wall, a bead that snaps ${String(LIP.bead - LIP.clear)} mm into a rille of that wall, and a notch in the lid's rim to open it. It makes no joint and needs no room outside the box. Keep the box's wall straight and at least 1.6 mm thick where the lip goes in.${warnings}`
);
}
throw new Error('kind must be hinge, slide or closure');
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 count = spec.more.length + 1;
const length = screwLength(spec);
const insert = INSERT[spec.size];
const screw = spec.head === 'countersunk' ? 'countersunk screws (ISO 10642)' : 'socket head cap screws (ISO 4762)';
return (
`Built ${String(count)} screw joint${count > 1 ? 's' : ''} "${spec.name}" that hold${count > 1 ? '' : 's'} ${spec.moving} on ${spec.fixed}: in ${spec.moving} a clearance hole for ${spec.size} (ISO 273, normal)` +
`${spec.head === 'cap' ? ' with a counterbore for the head' : spec.head === 'countersunk' ? ' with a 90° countersink' : ''}, in ${spec.fixed} ` +
`${spec.thread === 'insert' && insert ? `the seat of a heat-set insert (${String(insert.diameter)} mm wide, ${String(insert.length + 1)} mm deep)` : 'a modelled thread'}. ` +
`${length === null ? 'No standard screw length ends inside the thread at this thickness, so no screws were put in: choose the length yourself.' : `To buy: ${String(count)} × ${spec.size}×${String(length)} ${screw}${spec.thread === 'insert' && insert ? ` and ${String(count)} ${spec.size} heat-set inserts ${String(insert.length)} mm long` : ''}${spec.screws ? '; the screws are in the design as standard parts' : ''}.`} ` +
`${spec.thread === 'insert' ? 'Leave at least 2 mm of plastic around each insert (a boss where the wall is thinner).' : ''}${warnings}`
);
}
throw new Error('kind must be hinge, slide, closure or screw');
}

/** The upstream tools plus `partbuddy_mechanism`; `nozzle` is the printer's nozzle in use, for the default gap. */
Expand All @@ -356,4 +396,4 @@ export function withMechanismTool(upstream: UpstreamTools, nozzle: () => number)
}

/** Added to the role prompt of every route, after the joint guidance. */
export const MECHANISM_GUIDANCE = `For a hinge (a lid that folds, a flap, a clamp that opens) or a guide (a drawer, a slider, a carriage on a rail) call ${MECHANISM_TOOL} once both bodies exist instead of modelling knuckles, a pin or a rail yourself: it builds the print-in-place geometry with the right gaps, joins the bodies and sets the limits. For a lid that should hold shut by itself (a box, a case, a container) call it with kind closure instead of modelling a latch, a hook or a clip: a lip with a snap bead inside the lid, nothing standing out and no wall cut through.`;
export const MECHANISM_GUIDANCE = `For a hinge (a lid that folds, a flap, a clamp that opens) or a guide (a drawer, a slider, a carriage on a rail) call ${MECHANISM_TOOL} once both bodies exist instead of modelling knuckles, a pin or a rail yourself: it builds the print-in-place geometry with the right gaps, joins the bodies and sets the limits. For a lid that should hold shut by itself (a box, a case, a container) call it with kind closure instead of modelling a latch, a hook or a clip: a lip with a snap bead inside the lid, nothing standing out and no wall cut through. For parts that are screwed together (a lid on a housing, a bracket on a base, a cover) call it with kind screw instead of drilling the holes yourself: it sizes the clearance hole, the room for the head and the seat of the heat-set insert, and says which screws to buy.`;
51 changes: 2 additions & 49 deletions apps/desktop/src/shared/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,54 +9,9 @@
import { logoBase, TEXT_PREFIX, uniqueLogoName } from './drawingPlacement';
import { drawingColours, drawingPieces, drawingSize, drawingSvg, naturalWidth, parseDrawing, shapesOverlap, shapesSvg, type DrawingProblem, type Shape } from './svgDrawing';

export const BLOCK_SIZES = ['M2', 'M2.5', 'M3', 'M4', 'M5', 'M6', 'M8', 'M10'] as const;
export type BlockSize = (typeof BLOCK_SIZES)[number];
import { CAP_HEAD, CLEARANCE_NORMAL, COUNTERSUNK_HEAD, HEAD_ALLOWANCE, INSERT, NUT, NUT_ALLOWANCE, BLOCK_SIZES, type BlockSize } from './screwSizes';

/** Socket head cap screws, ISO 4762: head diameter and height in mm. */
const CAP_HEAD: Record<BlockSize, [number, number]> = {
M2: [3.8, 2],
'M2.5': [4.5, 2.5],
M3: [5.5, 3],
M4: [7, 4],
M5: [8.5, 5],
M6: [10, 6],
M8: [13, 8],
M10: [16, 10],
};

/** Countersunk screws, ISO 10642 (from M3): theoretical head diameter in mm, 90°. */
const COUNTERSUNK_HEAD: Partial<Record<BlockSize, number>> = { M3: 6.72, M4: 8.96, M5: 11.2, M6: 13.44, M8: 17.92, M10: 22.4 };

/**
* Heat-set threaded inserts: typical hole diameter and insert length in mm (values of the common
* ruthex inserts). The seat is one millimetre deeper than the insert for the displaced plastic.
*/
export const INSERT: Partial<Record<BlockSize, { diameter: number; length: number }>> = {
M2: { diameter: 3.2, length: 4 },
'M2.5': { diameter: 3.6, length: 5.7 },
M3: { diameter: 4, length: 5.7 },
M4: { diameter: 5.6, length: 8.1 },
M5: { diameter: 6.4, length: 9.5 },
M6: { diameter: 8, length: 12.7 },
};

/** Hex nuts, ISO 4032: width across flats and height in mm. */
const NUT: Record<BlockSize, [number, number]> = {
M2: [4, 1.6],
'M2.5': [5, 2],
M3: [5.5, 2.4],
M4: [7, 3.2],
M5: [8, 4.7],
M6: [10, 5.2],
M8: [13, 6.8],
M10: [16, 8.4],
};

/** Extra room around a printed nut: across flats and depth. */
export const NUT_ALLOWANCE = { flats: 0.3, depth: 0.4 };

/** Extra room around a printed screw head: holes print smaller than drawn. */
export const HEAD_ALLOWANCE = { diameter: 0.6, depth: 0.4 };
export { BLOCK_SIZES, HEAD_ALLOWANCE, INSERT, NUT_ALLOWANCE, type BlockSize };

type Point = [number, number, number];

Expand Down Expand Up @@ -159,8 +114,6 @@ export function hookVector(point: Point, normal: Point, hook: HookDirection): Po
return (hook.endsWith('-') ? axis.map((value) => (value === 0 ? 0 : -value)) : axis) as Point;
}

/** Normal clearance holes, ISO 273 medium series, in mm. */
const CLEARANCE_NORMAL: Record<BlockSize, number> = { M2: 2.4, 'M2.5': 2.9, M3: 3.4, M4: 4.5, M5: 5.5, M6: 6.6, M8: 9, M10: 11 };

/**
* A sketch plane at the point, flat on the face: x runs level where it can (text reads from the
Expand Down
Loading