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
10 changes: 9 additions & 1 deletion apps/desktop/e2e/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -793,7 +793,15 @@ test('a gear\'s mate follows its driver in the panel, the playback and what is k
await panel.getByTestId('joint-play').click();
await expect.poll(async () => Math.abs(await shown('Small axle')()), { timeout: 30_000 }).toBeGreaterThan(10);
await panel.getByTestId('joint-play').click();
const driver = await shown('Small axle')();
// The pause takes a frame: read the position once it stands (found 2026-11-11: read 39.2, kept 39.1).
let driver = Number.NaN;
await expect
.poll(async () => {
const before = driver;
driver = await shown('Small axle')();
return driver === before;
})
.toBe(true);
expect(await shown('Large axle (follows Small axle)')()).toBeCloseTo(-0.5 * driver, 1);
await page.getByTestId('viewer').screenshot({ path: test.info().outputPath('gear-paused.png') });
expect(fake.joints.map((joint) => joint.values[0]?.value)).toEqual([0, 0]);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/agents/claudeCode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CLAUDE_RESEARCH_TOOLS, RESEARCH_GUIDANCE, sourceOfSearchResult, sourceO
import { VIEW_GUIDANCE } from './viewContext';
import { ASSEMBLY_GUIDANCE, JOINT_GUIDANCE } from './assemblyTool';
import { BLOCK_GUIDANCE } from './blockTool';
import { STANDARD_GUIDANCE } from './standardTool';
import { MECHANISM_GUIDANCE } from './mechanismTool';

/**
Expand Down Expand Up @@ -297,6 +298,7 @@ function roleOf(research: boolean): string {
JOINT_GUIDANCE,
MECHANISM_GUIDANCE,
BLOCK_GUIDANCE,
STANDARD_GUIDANCE,
QUESTION_GUIDANCE,
ANSWER_GUIDANCE,
...(research ? [RESEARCH_GUIDANCE] : []),
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/agents/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { withToolNotes } from './toolNotes';
import { VIEW_GUIDANCE } from './viewContext';
import { ASSEMBLY_GUIDANCE, JOINT_GUIDANCE } from './assemblyTool';
import { BLOCK_GUIDANCE } from './blockTool';
import { STANDARD_GUIDANCE } from './standardTool';
import { MECHANISM_GUIDANCE } from './mechanismTool';

/**
Expand All @@ -28,6 +29,7 @@ const INSTRUCTIONS = [
JOINT_GUIDANCE,
MECHANISM_GUIDANCE,
BLOCK_GUIDANCE,
STANDARD_GUIDANCE,
QUESTION_GUIDANCE,
ANSWER_GUIDANCE,
].join(' ');
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/agents/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ const ALLOWED = new Set([
'partbuddy_mechanism',
// Builds a function block or a wall mounting from the standard tables (blockTool.ts), answered by PartBuddy.
'partbuddy_block',
// The rules and sizes for a kind of part (standardTool.ts), answered by PartBuddy; it builds nothing.
'partbuddy_standard',
]);

/**
Expand Down
40 changes: 40 additions & 0 deletions apps/desktop/src/main/agents/standardTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { STANDARDS, STANDARD_TOPICS, standardText, type StandardTopic } from '../../shared/standards';
import type { ToolResult, UpstreamTool, UpstreamTools } from './gateProxy';

/**
* `partbuddy_standard` (decided 2026-11-11): the rules and sizes for a kind of part, read by the
* assistant before it designs one itself. They stay out of the role prompt, which every request
* carries, and come from one table (`standards.ts`) instead of the model's memory.
*/

export const STANDARD_TOOL = 'partbuddy_standard';

const standardTool: UpstreamTool = {
name: STANDARD_TOOL,
description: `Read PartBuddy's rules and sizes for a kind of part before you design one yourself: ${STANDARD_TOPICS.map((topic) => `${topic} (${STANDARDS[topic].title.toLowerCase()})`).join(', ')}. It builds nothing.`,
inputSchema: {
type: 'object',
properties: { topic: { type: 'string', enum: [...STANDARD_TOPICS] } },
required: ['topic'],
additionalProperties: false,
},
};

/** The upstream tools plus `partbuddy_standard`. */
export function withStandardTool(upstream: UpstreamTools): UpstreamTools {
return {
instructions: () => upstream.instructions(),
listTools: async () => [...(await upstream.listTools()), standardTool],
callTool: (name, args, callId): Promise<ToolResult> => {
if (name !== STANDARD_TOOL) return upstream.callTool(name, args, callId);
const topic = args.topic;
if (typeof topic !== 'string' || !(STANDARD_TOPICS as readonly string[]).includes(topic)) {
return Promise.resolve({ content: [{ type: 'text', text: `topic must be one of ${STANDARD_TOPICS.join(', ')}` }], isError: true });
}
return Promise.resolve({ content: [{ type: 'text', text: standardText(topic as StandardTopic) }] });
},
};
}

/** Added to the role prompt of every route. */
export const STANDARD_GUIDANCE = `Before you design a sliding lid, a clip or clamp, feet, a hook or an eye, a cable entry, vents or a fan opening, a mount for a circuit board or a star knob, call ${STANDARD_TOOL} with that topic and follow what it says: the sizes come from data sheets and standards, not from your memory.`;
3 changes: 2 additions & 1 deletion apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { keepPrintFace, readAssembly, readPrintFaces, writeAssembly } from './pr
import { Questions } from './agents/questions';
import { WorkLog } from './agents/workLog';
import { withBlockTool } from './agents/blockTool';
import { withStandardTool } from './agents/standardTool';
import { withMechanismTool } from './agents/mechanismTool';
import { blockPhrase, describeUserAction } from '../shared/stepPhrases';
import { ChatGptAccount } from './chatgpt/account';
Expand Down Expand Up @@ -583,7 +584,7 @@ if (!app.requestSingleInstanceLock()) {
steps.tools(
withDesignTool(
withAssemblyTool(
withViewTool(guardNotes(withBlockTool(withMechanismTool(withDrawings(upstream, drawings), () => printProfiles.part().printer.nozzle, keepMechanismFace), keepMechanismFace), (trial) => supervisor.trialSteps(trial)), () => viewState, () => notesService.forView(), checkForView),
withViewTool(guardNotes(withStandardTool(withBlockTool(withMechanismTool(withDrawings(upstream, drawings), () => printProfiles.part().printer.nozzle, keepMechanismFace), keepMechanismFace)), (trial) => supervisor.trialSteps(trial)), () => viewState, () => notesService.forView(), checkForView),
() => viewState?.bodies.map((body) => body.name) ?? [],
saveAssembly,
),
Expand Down
105 changes: 105 additions & 0 deletions apps/desktop/src/shared/standards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* The standards the assistant looks up before it designs a part of a kind itself (decided
* 2026-11-11: no fixed block per kind, whose shape depends on the part; the rules and sizes are
* what stays). Each line says where its figure comes from: a data sheet or standard, guides for
* printed parts (which disagree, so a range), or PartBuddy's own choice. Nothing here has been
* printed by PartBuddy; a line changes when a print shows it wrong.
*/

export const STANDARD_TOPICS = ['slidingLid', 'clamp', 'feet', 'hook', 'cableEntry', 'vent', 'boardMount', 'starKnob'] as const;
export type StandardTopic = (typeof STANDARD_TOPICS)[number];

export const STANDARDS: Record<StandardTopic, { title: string; rules: string[] }> = {
slidingLid: {
title: 'A lid that slides into grooves of a box',
rules: [
'Grooves with 45° flanks (a dovetail) in the two long walls and the back wall, open at the front: they print without support with the box standing. A square groove is a bridge and sags from 5 mm (Hubs).',
'Lid 3 to 4 mm thick, its edges chamfered at 45° to match; groove 1.5 mm deep, at most half the wall; 2 mm of wall over the groove; walls 3 mm (guides).',
'Room 0.3 mm per side and 0.3 mm above and below the lid (guides say 0.2 to 0.4; Prusa asks at least 0.3 for moving parts, Hubs 0.5).',
'Hold it shut with a bump 0.3 mm high across the lid near the open end and a matching dent, and give the lid a finger dent about 12 mm round and 2 mm deep, or a raised ridge (guides, one source each).',
'Print the box standing and the lid lying flat. The front wall of the box is lower by the lid, so the lid closes it.',
'For a rail pushed onto another part use partbuddy_mechanism with kind plug and shape dovetail instead.',
],
},
clamp: {
title: 'A clip or clamp round a tube, a rod or a cable',
rules: [
'A C clip to snap on: inner diameter the tube and 0.3 to 0.5 mm, the opening 75 to 85 % of the tube, arms 1.5 to 2.5 mm thick, 8 to 12 mm wide (guides). Round the ends of the arms outwards so the tube finds its way in.',
'A clamp closed by a screw: bore the measured diameter to grip (0.1 to 0.2 mm more to slide), a slot 2 to 5 mm wide, wall about 4 mm at 25 mm, and lugs either side of the slot for the screw; make the screw holes with partbuddy_block or partbuddy_mechanism kind screw.',
'A clamp of two halves: 0.4 mm of room on the tube, a gap of 1 mm between the halves so the screws can pull them tight (guides).',
'Always print it lying, the bore upright: the layers then run round the ring. Standing, the arms break along their layers (guides, and measured for hooks).',
'Ask for the measured diameter; tubes are sold in many series and none is assumed here.',
],
},
feet: {
title: 'Feet and what a part stands on',
rules: [
'Stick-on rubber feet hold on a table; printed feet slide. Common ones (3M Bumpon data sheet, ±0.5 mm): SJ5302 7.9 × 2.2 mm, SJ5076 8.0 × 2.8, SJ5306 9.5 × 3.8, SJ5003 11.1 × 5.0, SJ5012 12.7 × 3.5, SJ5744 19.1 × 4.1.',
'A recess for such a foot is not needed (3M) but keeps it from being sheared off: 0.5 mm wider than the foot, 0.5 to 1 mm deep, never deeper than half the foot, so it still stands proud (own choice from that tolerance).',
'Four feet near the corners for anything that is pressed on or may tip; three never rock on an uneven table (guides).',
'Chamfer every edge that lies on the print bed by 0.4 mm at 45°: the first layer is squeezed wider (the slicers compensate about 0.2 mm; Hubs asks for a chamfer; Gridfinity uses 0.8 mm).',
'Feet printed on: at least 8 mm across and 2 mm high, with a chamfered rim. Feet of TPU cushion more than they grip (guides).',
],
},
hook: {
title: 'Hooks and eyes that carry a load',
rules: [
'Print a hook lying on its side, so the load runs along the layers: measured on one test hook (CNC Kitchen), lying held 119 kg in PLA and 106 kg in PETG until it broke, standing 69 kg and 36 kg. These are breaking loads of one shape, not what a hook may carry for years: plastic creeps, so allow a tenth.',
'Layers of at most 0.2 mm and four or more wall lines; at 0.3 mm the standing hook held half (CNC Kitchen).',
'Round the inside corner where the hook leaves its plate, with a radius of at least the hook\'s thickness (Hubs asks for a fillet without a size; the size is our choice), and keep the section at least 6 × 6 mm for anything heavier than a coat.',
'Hang the plate with partbuddy_block keyhole or woodScrew, two of them level from 60 mm of width.',
'An eye: hole 4 mm for a key ring (5 mm for a heavy one), 3.5 mm for a ball chain, twice the wire and 1 mm for a carabiner, each 0.2 mm more for print; 2 to 3 mm of plastic round the hole (guides say 1.5 to 3).',
'A slot for a cable tie: ties are 2.5, 3.5 and 4.6 mm wide (HellermannTyton T18R, T30R, T50R) and about 1 mm thick; make the slot 1 mm wider and 1.5 mm high (own choice), and print it so that it stands upright: a lying slot sags.',
],
},
cableEntry: {
title: 'A cable into a housing, held against pulling',
rules: [
'Cables: USB-C 2 to 4 mm thick (charging) or 4 to 6 mm (full-featured) (USB-IF); mains flex H03VV-F 2 × 0.75 about 5.8 mm, H05VV-F 3 × 0.75 from 6.0 to 7.6 mm by maker. Ask for the measured cable.',
'A plain hole: the cable and 1 mm, its edges rounded or chamfered by 0.5 mm so they do not cut (own choice). A cable with its plug moulded on needs a slot from the rim of the wall down to the hole, closed by the lid, or the plug\'s own size and 1 mm per side (Hubs for cut-outs).',
'Inside, do not bend the cable tighter than four times its diameter (Helukabel for H05VV-F, fixed).',
'Strain relief, simplest first: a cable tie round the cable just inside the wall, wider than the hole; or a bridge on the floor with two slots for a tie (2.5 or 3.5 mm wide, slots 1 mm wider, 1.5 mm high, printed upright); or a clamp bar with two screws over the cable, its saddle 0.5 mm less than the cable so it grips (own choice).',
'A bought cable gland seals and holds at once. Holes by EN 60423: M12 12.2 mm for cables of 3 to 6.5 mm, M16 16.2 mm for 4 to 8 or 5 to 10 mm, M20 20.2 mm for 6 to 12 mm; PG7 12.7 to 13 mm, PG9 15.4 to 15.7 mm (makers differ). The wall needs to be flat round the hole for the nut.',
'Mains voltage in a printed housing is the user\'s responsibility: say so, and never present the part as safe for it.',
],
},
vent: {
title: 'Vents, grilles and fan openings',
rules: [
'In an upright wall make slots that stand upright, 2 to 3 mm wide with bars at least 1.2 mm between them (three lines of a 0.4 mm nozzle; Prusa lists 0.45, 0.9, 1.35 and 1.8 mm for one to four lines); a lying slot is a bridge, fine up to 5 mm long (Hubs). Close a long upright slot with a 45° point at its top.',
'In a flat top or bottom any pattern prints, as the holes are upright. Hexagons 6 to 8 mm across flats with 1.2 mm bars are stiff and open (own choice; no source gives sizes).',
'To keep fingers out, no opening over 12 mm (IP2X stops what is larger than 12.5 mm); against wire and small parts 1 mm (IP4X).',
'Fans, mounting holes centre to centre: 25 mm fan 20, 30 fan 24, 40 fan 32, 50 fan 40, 60 fan 50, 80 fan 71.5, 92 fan 82.5, 120 fan 105 mm. Screws by the fan: commonly M3 up to 60 mm and M4 above; check the fan in hand, sources differ on the hole.',
'A fan opening: a round hole 2 mm less than the fan, or a grille of bars; leave 3 mm of wall between hole and screw.',
'Air must get in as well as out: openings low on one side and high on the other, about as much area as the fan sweeps.',
],
},
boardMount: {
title: 'Holding a circuit board',
rules: [
'Standoffs: outer diameter 2 to 2.5 times the hole, at least 5 mm; high enough for what is soldered under the board, 5 mm as a rule and 10 mm over pin headers (guides). Join each to the floor with a 1 mm fillet.',
'Screwed straight into plastic: pilot hole 0.25 mm less than the screw for print (Hubs), which is 2.0 mm for M2.5 and 2.5 mm for M3 after print; at least twice the screw\'s diameter deep. For a board taken out often use partbuddy_block insertSeat or boss instead.',
'Without screws: two pins 0.3 mm less than the board\'s holes to locate it and two snap hooks over its edge. The board is 1.6 mm thick as a rule; a hook 1 mm thick at its root and 5 mm wide, 0.5 mm of room (Hubs).',
'A slot the board slides into: 2.0 mm wide for a 1.6 mm board (own choice, 0.2 mm per side), 2 mm deep.',
'Leave 0.5 mm round the board and 1 mm per side round every plug in the wall (Hubs).',
'Boards (from the makers\' drawings, to be checked against the board in hand): Raspberry Pi 4 and 5 are 85 × 56 mm with four M2.5 holes of 2.7 mm in a rectangle 58 × 49 mm, 3.5 mm from the edges; Pi Zero 65 × 30 mm, holes 58 × 23 mm; Arduino Uno 68.58 × 53.34 mm with 3.2 mm holes at (13.97, 2.54), (15.24, 50.80), (66.04, 7.62) and (66.04, 35.56). For any other board ask for its drawing or measured holes.',
],
},
starKnob: {
title: 'A star knob that clamps with a screw or a nut',
rules: [
'Sizes of DIN 6336 in plastic (Ganter): diameter 25 mm for M5 (hub 12, height 16), 32 for M6 (hub 14, height 20), 40 for M8 (hub 18, height 25), 50 for M10 (hub 22, height 32), 63 for M12 (hub 26, height 40).',
'Five lobes are usual (the standard\'s count could not be confirmed); round them generously, lobe radius about a sixth of the diameter.',
'To hold a hexagon screw: a hexagonal pocket from the top, across flats 0.3 mm more than the head and as deep as the head, with a through hole of the thread and 0.4 mm under it. Heads by ISO 4017, across flats and height: M5 8 and 3.5 mm, M6 10 and 4, M8 13 and 5.3.',
'To hold a nut: the same pocket from below the hub; nuts by ISO 4032 are as wide as the heads and up to 4.7 (M5), 5.2 (M6) and 6.8 mm (M8) high. For M3 to M6 partbuddy_block nutTrap makes the pocket.',
'Keep plastic round the pocket as thick as the thread is wide, and glue the screw or nut in with a drop of superglue or close the pocket with a pressed-in cap.',
'Print it with the pocket opening upwards, flat side down. For a knob on a shaft (potentiometer, encoder) use partbuddy_block knob.',
],
},
};

/** What the assistant reads for a topic. */
export function standardText(topic: StandardTopic): string {
const { title, rules } = STANDARDS[topic];
return `${title}. Design it yourself by these rules, and tell the user where a figure is a range or our own choice:\n${rules.map((rule) => `- ${rule}`).join('\n')}\nNone of this has been printed by PartBuddy yet. The print check in partbuddy_view still judges what you build.`;
}
43 changes: 43 additions & 0 deletions apps/desktop/test/standardTool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';

import type { UpstreamTools } from '../src/main/agents/gateProxy';
import { decide } from '../src/main/agents/policy';
import { STANDARD_GUIDANCE, withStandardTool } from '../src/main/agents/standardTool';
import { STANDARDS, STANDARD_TOPICS, standardText } from '../src/shared/standards';

describe('the standards the assistant looks up', () => {
const upstream: UpstreamTools = {
instructions: () => undefined,
listTools: () => Promise.resolve([]),
callTool: (name) => Promise.resolve({ content: [{ type: 'text', text: `upstream ${name}` }] }),
};

it('answers a topic itself with its rules, builds nothing and passes other tools on', async () => {
const tools = withStandardTool(upstream);
const [tool] = await tools.listTools();
expect(tool?.name).toBe('partbuddy_standard');
expect(tool?.description).toContain('slidingLid (a lid that slides into grooves of a box)');
const feet = String((await tools.callTool('partbuddy_standard', { topic: 'feet' })).content[0]?.text);
expect(feet).toContain('Feet and what a part stands on. Design it yourself by these rules');
expect(feet).toContain('SJ5003 11.1 × 5.0');
expect(feet).toContain('None of this has been printed by PartBuddy yet.');
expect(await tools.callTool('partbuddy_standard', { topic: 'gear' })).toMatchObject({ isError: true, content: [{ text: `topic must be one of ${STANDARD_TOPICS.join(', ')}` }] });
expect(await tools.callTool('inspect_design', {})).toMatchObject({ content: [{ text: 'upstream inspect_design' }] });
expect(decide('partbuddy_standard', { topic: 'feet' })).toEqual({ allow: true });
});

it('has rules for every topic, names each in the guidance and points to the blocks that exist', () => {
for (const topic of STANDARD_TOPICS) {
expect(STANDARDS[topic].rules.length).toBeGreaterThanOrEqual(5);
expect(standardText(topic).split('\n- ')).toHaveLength(STANDARDS[topic].rules.length + 1);
}
expect(STANDARD_GUIDANCE).toContain('call partbuddy_standard with that topic');
// Where a block builds the thing, the standard sends the assistant there instead of repeating its sizes.
expect(standardText('starKnob')).toContain('partbuddy_block knob');
expect(standardText('hook')).toContain('partbuddy_block keyhole');
expect(standardText('slidingLid')).toContain('kind plug and shape dovetail');
// Figures from the sheets: fan hole spacing and the Raspberry Pi's hole pattern.
expect(standardText('vent')).toContain('80 fan 71.5');
expect(standardText('boardMount')).toContain('58 × 49 mm');
});
});
Loading