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
50 changes: 50 additions & 0 deletions apps/desktop/e2e/solidcraft.contract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2165,6 +2165,11 @@ test('a template from the start page becomes a project whose sizes are its param
await expect(page.getByTestId('print-check-panel').locator('[data-kind="blocked"]')).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath('template-slideStop-check.png') });
await page.keyboard.press('Escape');
// On the plate the stop lies on its top, as its print face says: taller than wide it would stand otherwise.
await page.getByRole('radio', { name: 'Plate' }).click();
await page.waitForTimeout(1500);
await page.screenshot({ path: test.info().outputPath('template-slideStop-plate.png') });
await page.getByRole('radio', { name: 'Assembled' }).click();
}
if (id !== 'hingedBox') continue;
// The hinge came joined and closes all the way: the bead of the closure pressing past the box's rim is a snap fit, no stop.
Expand Down Expand Up @@ -3322,6 +3327,51 @@ test('the assistant builds a dovetail guide with one call: joined as a slider th
}
});

test('the assistant makes a screw lid for a jar with one call: the threads mesh as built and the lid prints on its top', 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-lid-'));
const env = {
SOLIDCRAFT_CONTROL_DIR: join(work, 'control'),
PARTBUDDY_SOLIDCRAFT_PORT: '7955',
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 jar with a screw lid');
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByText(/Built the screw lid "Jar lid" for Jar: a thread with 45° flanks on the neck, pitch 4 mm, 1\.2 mm deep, 1 start, and the same inside the lid, 0\.3 mm clear; it closes in 1\.75 turns/)).toBeVisible({ timeout: 90_000 });
await expect(page.getByText(/Warning:/)).toHaveCount(0);
await expect(page.getByTestId('mesh-summary')).toContainText('2 bodies', { timeout: 30_000 });
await expect(page.getByText('Screw lid “Jar lid” with its thread on the container')).toHaveCount(1);
await expect(page.locator('[data-testid="timeline-feature"][data-state="error"]')).toHaveCount(0);
// The two threads share no volume: they were built to mesh.
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-lid-built.png') });
await expect(async () => {
await page.keyboard.press('Escape');
await expect(findings).toBeHidden({ timeout: 1000 });
}).toPass({ timeout: 15_000 });
// On the plate the lid lies on its top, the jar stands: two parts side by side.
await page.getByRole('radio', { name: 'Plate' }).click();
await page.waitForTimeout(1500);
await page.screenshot({ path: test.info().outputPath('screw-lid-plate.png') });
} finally {
await launched.close();
rmSync(work, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 });
}
});

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');
Expand Down
73 changes: 59 additions & 14 deletions apps/desktop/src/main/agents/mechanismTool.ts

Large diffs are not rendered by default.

20 changes: 18 additions & 2 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ import { safeDesignFileName } from './solidcraft/headless';
import { longPath, SolidCraftSupervisor } from './solidcraft/supervisor';
import { ChatArchive } from './chat/chatArchive';
import { printCheckNote } from './print/printCheckNote';
import { templateSteps } from '../shared/templates';
import { templatePrintFaces, templateSteps } from '../shared/templates';
import { testPieceSteps } from '../shared/testPieces';
import { sketchNote } from './agents/sketchNote';
import { photoFile, photoNote } from './agents/photoNote';
Expand Down Expand Up @@ -567,6 +567,13 @@ if (!app.requestSingleInstanceLock()) {
log(`kept the assembly order: ${String(plan.steps.length)} steps`);
bridge.emit(mainWindow?.webContents ?? null, eventChannels.assemblyChanged, plan);
};
// A mechanism that lays its part down (a screw lid on its top) says so here, so the plate view turns it as the slicer will.
const keepMechanismFace = (body: string, face: string) => {
const folder = assemblyFolder();
if (!folder) return;
keepPrintFace(folder, body, face);
bridge.emit(mainWindow?.webContents ?? null, eventChannels.printFacesChanged, readPrintFaces(folder));
};
// SVG drawings sent along; the model names them and PartBuddy puts the exact contours in (drawingTool.ts).
const drawings = new Drawings();
// The model reads notes only through PartBuddy; results lose the note data, undo may not take a note (ADR 0020).
Expand All @@ -575,7 +582,7 @@ if (!app.requestSingleInstanceLock()) {
steps.tools(
withDesignTool(
withAssemblyTool(
withViewTool(guardNotes(withMechanismTool(withDrawings(upstream, drawings), () => printProfiles.part().printer.nozzle), (trial) => supervisor.trialSteps(trial)), () => viewState, () => notesService.forView(), checkForView),
withViewTool(guardNotes(withMechanismTool(withDrawings(upstream, drawings), () => printProfiles.part().printer.nozzle, keepMechanismFace), (trial) => supervisor.trialSteps(trial)), () => viewState, () => notesService.forView(), checkForView),
() => viewState?.bodies.map((body) => body.name) ?? [],
saveAssembly,
),
Expand Down Expand Up @@ -1180,6 +1187,15 @@ if (!app.requestSingleInstanceLock()) {
throw error;
}
await switchProject(() => Promise.resolve(target));
// A part the template lays down for printing (the stop on its top) lies so on the plate as well.
for (const { body, point } of templatePrintFaces(template, german)) {
const face = await supervisor.printFaceName(body, point).catch((error: unknown) => {
log(`print face of ${body} not read: ${(error as Error).message}`);
return null;
});
if (face) keepMechanismFace(body, face);
else log(`the template's print face of ${body} is not kept for the plate view`);
}
log(`made a new project from the template ${template}`);
return { opened: true, imported: false };
});
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/main/projects/assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ export function writeAssembly(folder: string, plan: AssemblyPlan): void {
/**
* Print faces as PartBuddy set them (B8), by body, kept as `print-faces.json` so the plate view can
* lay each part on its face; SolidCraft keeps the face itself but does not report it back.
* shortcut: a print face the assistant sets through SolidCraft is not seen here; the part then lies
* as designed. Upgrade when SolidCraft reports print faces.
* A mechanism (a screw lid) and a template say theirs when they are built.
* shortcut: a print face the assistant sets itself with body.print_face is not seen here; the part
* then lies as designed. Upgrade when SolidCraft reports print faces.
*/
export const PRINT_FACES_FILE = 'print-faces.json';

Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/main/solidcraft/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,20 @@ export class SolidCraftSupervisor {
return found.name;
}

/**
* The name of the face a body's print face lies on: SolidCraft keeps print faces but does not list them, so the face
* is marked once more and the answer read. For print faces a script set (a template), which the plate view needs too.
*/
async printFaceName(body: string, point: [number, number, number]): Promise<string | null> {
await this.probing;
// Right after a project switch the engine is still connecting: wait for it, five seconds at most.
for (let tries = 0; tries < 20 && this.actor.getSnapshot().value !== 'connected'; tries += 1) await new Promise((resolve) => setTimeout(resolve, 250));
const reply = await this.requester().request<BatchResult>('engine.batch', { commands: [{ command: 'body.print_face', params: { body, point } }], rollback: false }, 60_000);
if (reply.failed) throw new Error(reply.failed.error);
const face = (reply.results[0] as { face?: unknown } | undefined)?.face;
return typeof face === 'string' ? face : null;
}

/** Marks or clears the body's print face (B8, `body.print_face`), which the slicer export uses. */
setPrintFace(body: string, point: [number, number, number] | null): Promise<void> {
return this.run({ command: 'body.print_face', params: { body, ...(point ? { point } : {}) } });
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ const api: PartBuddyApi = {
sendToSlicer: (slicer) => ipcRenderer.invoke(invokeChannels.designSendToSlicer, { slicer }),
setPrintFace: (body, point, face) => ipcRenderer.invoke(invokeChannels.designPrintFace, { body, point, ...(face ? { face } : {}) }),
printFaces: () => ipcRenderer.invoke(invokeChannels.designPrintFaces),
onPrintFaces: (listener) => subscribe(eventChannels.printFacesChanged, listener),
insertPart: (part) => ipcRenderer.invoke(invokeChannels.designInsertPart, part),
measure: (items) => ipcRenderer.invoke(invokeChannels.designMeasure, { items }),
clearance: (bodies, gap) => ipcRenderer.invoke(invokeChannels.designClearance, { bodies, gap }),
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/renderer/src/viewer/motion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ export function useProjectViewData(): void {
}),
[],
);
useEffect(
() =>
platform().design.onPrintFaces((faces) => {
useStudio.getState().setPrintFaces(faces);
}),
[],
);
}

/** How each body is turned to lie on its print face (or as designed), with its footprint and height that way. */
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/shared/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ export interface PartBuddyApi {
setPrintFace(body: string, point: [number, number, number] | null, face?: string): Promise<void>;
/** The print faces PartBuddy set in the open project, by body (for the plate view). */
printFaces(): Promise<Record<string, string>>;
/** The print faces changed without the window doing it: a mechanism of the assistant laid its part down. */
onPrintFaces(listener: (faces: Record<string, string>) => void): () => void;
/** Builds a function block (F1) on trial for the ghost preview. */
previewBlock(block: FeatureBlock): Promise<ActionPreviewResult>;
/** Builds a function block for real; one step of the work log. */
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/shared/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export const eventChannels = {
notesChanged: 'notes:changed',
printChanged: 'print:changed',
assemblyChanged: 'assembly:changed',
printFacesChanged: 'design:print-faces-changed',
} as const;

export type InvokeChannel = (typeof invokeChannels)[keyof typeof invokeChannels];
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/shared/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1291,6 +1291,7 @@ export const eventContract = {
[eventChannels.notesChanged]: notesStateSchema,
[eventChannels.printChanged]: partPrintSchema,
[eventChannels.assemblyChanged]: assemblyPlanSchema.nullable(),
[eventChannels.printFacesChanged]: printFacesSchema,
} as const satisfies Record<EventChannel, z.ZodType>;

export type FaceTableMessage = z.infer<typeof faceTableSchema>;
Expand Down
91 changes: 91 additions & 0 deletions apps/desktop/src/shared/mechanisms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,3 +447,94 @@ function headRoom(spec: ScrewSpec): Record<string, unknown> {
return {};
}


export interface LidSpec {
/** The container: its round neck gets the thread. */
fixed: string;
/** The lid, which is made: no body of that name may exist. */
moving: string;
/** The centre of the container's rim, at the rim's height; the neck stands upright (along Z). */
centre: Vec3;
/** The neck's outer diameter at the rim, in mm. */
diameter: number;
/** How many threads run side by side: 1, or 2 to 3 for a lid that closes in less than a turn. */
starts: number;
/** The room between the two threads, radially; all of it on the lid. */
gap: number;
name: string;
}

/**
* The thread of a printed screw lid, after the neck finishes of bottles and the usual printed jars: flat crests
* and roots with flanks at 45°, which print without support on the neck standing and in the lid lying on its top;
* pitch and depth by the neck's diameter; the threads engage over 1.75 turns of one start (at least three quarters
* of a turn each with several).
*/
export function lidThread(spec: Pick<LidSpec, 'diameter' | 'starts'>): { pitch: number; depth: number; lead: number; turns: number; length: number; crest: number; foot: number } {
const [pitch, depth] = spec.diameter <= 24 ? [3, 1.1] : spec.diameter <= 77 ? [4, 1.2] : [5, 1.5];
const lead = pitch * spec.starts;
const turns = Math.max(0.75, 1.75 / spec.starts);
return { pitch, depth, lead, turns, length: round(turns * lead), crest: round(pitch / 2 - depth), foot: round(pitch / 2 + depth) };
}

/** Plain neck above the thread, the lid's wall and top, and the room below the lid's thread, in mm. */
export const LID = { plain: 1, wall: 2.4, top: 2.4, mouth: 1, flute: 3 };

/** How far below the rim the neck must be a plain cylinder of the given diameter: the lid's skirt covers it. */
export function lidSkirt(spec: Pick<LidSpec, 'diameter' | 'starts'>): number {
const thread = lidThread(spec);
return round(LID.plain + thread.foot / 2 + thread.length + thread.pitch / 2 + thread.foot / 2 + LID.mouth);
}

/** What is wrong with a screw lid before anything is built; null when it can be built. */
export function lidProblem(spec: LidSpec): string | null {
if (spec.fixed === spec.moving) return 'moving is the name of the lid to make, not the container';
if (!(spec.diameter >= 16 && spec.diameter <= 200)) return 'diameter must be 16–200 mm: the outer diameter of the neck';
if (!Number.isInteger(spec.starts) || spec.starts < 1 || spec.starts > 3) return 'starts must be 1, 2 or 3';
if (!(spec.gap >= 0.2 && spec.gap <= 0.6)) return 'gap must be 0.2–0.6 mm';
return null;
}

/**
* The solids of a screw lid on a round neck: the thread coiled onto the neck, and a lid made for it, a cap whose
* inner top lands on the rim, with the same thread inside, half a pitch lower and the gap further out, so the two
* mesh as they are built; flutes round the lid for the fingers; the lid's print face is its top.
*/
export function lidSteps(spec: LidSpec): Step[] {
const thread = lidThread(spec);
const [cx, cy, rim] = spec.centre.map(round) as Vec3;
const radius = spec.diameter / 2;
const mm = (value: number) => `${String(round(value))} mm`;
// The middle of the neck's thread at its lower end, the lid's half a pitch below, and the lid's mouth.
const start = rim - LID.plain - thread.foot / 2 - thread.length;
const lidStart = start - thread.pitch / 2;
const mouth = rim - lidSkirt(spec);
const bore = radius + thread.depth + spec.gap;
const outer = bore + LID.wall;
// The section reaches 0.2 mm into its body, along its flanks, but never to the next turn.
const into = Math.min(thread.foot / 2 + 0.2, thread.pitch / 2 - 0.05);
const coil = (label: string, target: string, z: number, foot: number, crest: number, k: number): Step[] => {
const angle = (2 * Math.PI * k) / spec.starts;
const name = `${label} ${String(k + 1)}`;
return [
// The section's plane holds the axis, turned by the start: both threads begin at the same angles.
{ command: 'sketch.create', params: { plane: { origin: [cx, cy, round(z)], x_dir: [round(Math.cos(angle)), round(Math.sin(angle)), 0], y_dir: [0, 0, 1] }, project_edges: false, name } },
{ command: 'sketch.line', params: { points: [[foot, -into], [crest, -thread.crest / 2], [crest, thread.crest / 2], [foot, into]].map(([x, y]) => [round(x ?? 0), round(y ?? 0)]), closed: true } },
{ command: 'solid.coil', params: { sketch: name, pitch: mm(thread.lead), height: mm(thread.length), base: [cx, cy, round(z)], axis: 'Z', operation: 'join', targets: [target], name } },
];
};
const flutes = Math.max(8, Math.round((2 * Math.PI * outer) / 7));
return [
...Array.from({ length: spec.starts }, (_, k) => coil(spec.name, spec.fixed, start, radius - 0.2, radius + thread.depth, k)).flat(),
// The lid's top edge lies on the bed when it prints, so its last 0.6 mm are a cone, not a chamfer: SolidCraft
// 0.40.0-rc.4 refuses to chamfer the top edge of a cylinder from about 60 mm of diameter.
{ command: 'solid.cylinder', params: { diameter: mm(2 * outer), height: mm(rim + LID.top - 0.6 - mouth), base: [cx, cy, round(mouth)], operation: 'new', body_name: spec.moving, name: spec.moving } },
{ command: 'solid.cone', params: { diameter: mm(2 * outer), top_diameter: mm(2 * outer - 1.2), height: '0.6 mm', base: [cx, cy, round(rim + LID.top - 0.6)], operation: 'join', targets: [spec.moving], name: `${spec.name} ◢` } },
{ command: 'solid.cylinder', params: { diameter: mm(2 * bore), height: mm(rim - mouth), base: [cx, cy, round(mouth)], operation: 'cut', targets: [spec.moving], name: `${spec.name} ○` } },
...Array.from({ length: spec.starts }, (_, k) => coil(`${spec.name} ·`, spec.moving, lidStart, bore + 0.2, radius + spec.gap, k)).flat(),
// Flutes for the fingers, ending below the top so its edge stays whole for the bed.
{ command: 'solid.cylinder', params: { diameter: mm(LID.flute), height: mm(rim - mouth), base: [round(cx + outer + LID.flute / 4), cy, round(mouth)], operation: 'cut', targets: [spec.moving], name: `${spec.name} ◆` } },
{ command: 'solid.pattern.circular', params: { features: [`${spec.name} ◆`], axis: { origin: [cx, cy, 0], dir: [0, 0, 1] }, count: flutes } },
{ command: 'body.print_face', params: { body: spec.moving, point: [cx, cy, round(rim + LID.top)] } },
];
}
4 changes: 4 additions & 0 deletions apps/desktop/src/shared/stepPhrases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export const STEP_PHRASE_KEYS = [
'guide',
'closure',
'screwJoint',
'screwLid',
'command',
] as const;

Expand Down Expand Up @@ -298,6 +299,7 @@ export function describeCommands(steps: Step[]): StepPhrase[] {
/** What one tool call of the assistant did; a call PartBuddy cannot read gets one generic line. */
export function describeToolCall(tool: string, args: Params): StepPhrase[] {
// PartBuddy's own tool builds a mechanism from many commands: one line names it.
if (tool === 'partbuddy_mechanism' && args.kind === 'lid') return [{ key: 'screwLid', values: { name: text(args.moving, 'Lid').slice(0, 60) } }];
if (tool === 'partbuddy_mechanism' && args.kind === 'screw') return [{ key: 'screwJoint', values: { name: text(args.name, 'Screw').slice(0, 60) } }];
if (tool === 'partbuddy_mechanism' && args.kind === 'closure') return [{ key: 'closure', values: { name: text(args.name, 'Closure').slice(0, 60) } }];
if (tool === 'partbuddy_mechanism') return [args.kind === 'slide' ? { key: 'guide', values: { name: text(args.name, 'Guide').slice(0, 60) } } : { key: 'hinge', values: { name: text(args.name, 'Hinge').slice(0, 60) } }];
Expand Down Expand Up @@ -443,6 +445,7 @@ export const stepMessages: Record<'en' | 'de', Record<StepPhraseKey, string>> =
guide: 'Guide “{name}” between two parts, with its joint',
closure: 'Snap closure “{name}” between lid and box',
screwJoint: 'Screw joint “{name}” between two parts',
screwLid: 'Screw lid “{name}” with its thread on the container',
command: 'SolidCraft: {command}',
},
de: {
Expand Down Expand Up @@ -508,6 +511,7 @@ export const stepMessages: Record<'en' | 'de', Record<StepPhraseKey, string>> =
guide: 'Führung „{name}“ zwischen zwei Teilen, mit Gelenk',
closure: 'Rastverschluss „{name}“ zwischen Deckel und Dose',
screwJoint: 'Schraubverbindung „{name}“ zwischen zwei Teilen',
screwLid: 'Schraubdeckel „{name}“ mit Gewinde am Behälter',
command: 'SolidCraft: {command}',
},
};
Loading