Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4982f65
fix(geometry): the print check sizes each body by itself and lays a l…
ManuelStaggl Nov 12, 2026
1650c35
fix(geometry): the print check looks at what lies on the bed body by …
ManuelStaggl Nov 12, 2026
a187d19
fix(geometry): the print check measures floors in layers and walls ac…
ManuelStaggl Nov 12, 2026
73b24bf
fix(geometry): a ceiling or a wide bridge stays an overhang though it…
ManuelStaggl Nov 12, 2026
79fcd85
fix(geometry): the print check blocks a body that is an open shell
ManuelStaggl Nov 12, 2026
ad4a9b3
fix(geometry): a triangle drawn to a line does not make a closed body…
ManuelStaggl Nov 12, 2026
b67d3e3
fix(geometry): a bridge ends on standing material on both sides, with…
ManuelStaggl Nov 12, 2026
e294d82
fix(geometry): the print check drops no body, no floor and no finding…
ManuelStaggl Nov 12, 2026
6034e26
fix(desktop): a mechanism that fails after its first step is taken ba…
ManuelStaggl Nov 12, 2026
acac0d5
fix(desktop): a second hinge gets a name of its own, and none is buil…
ManuelStaggl Nov 12, 2026
63f4dfb
fix(desktop): the bead of a lid with walls reaches 0.25 mm into its r…
ManuelStaggl Nov 12, 2026
00e7dda
fix(desktop): the guide's groove gets a roof at 45° and its answer sa…
ManuelStaggl Nov 12, 2026
22137a0
fix(desktop): hinge webs carry the whole barrel and stand only on a w…
ManuelStaggl Nov 12, 2026
f37a9c7
fix(desktop): the hinged box template carries its knuckles on the too…
ManuelStaggl Nov 12, 2026
6b33dba
fix(geometry): a bridge is found from its anchors, whichever way the …
ManuelStaggl Nov 12, 2026
89274a3
fix(desktop): the report of the print check fits its contract without…
ManuelStaggl Nov 12, 2026
29786b8
fix(desktop): a failed tool call is taken back by what the design hol…
ManuelStaggl Nov 12, 2026
6623081
fix(desktop): mechanisms cut no body apart, share a joint on one line…
ManuelStaggl Nov 12, 2026
864e273
Merge remote-tracking branch 'origin/fix/print-check-audit' into fix/…
ManuelStaggl Nov 12, 2026
4a6a0ea
fix(desktop): the groove's roof is tried over its flanks, and a hinge…
ManuelStaggl Nov 12, 2026
8c8e1d2
fix(desktop): a hinge over another is known by its pin, and a failed …
ManuelStaggl Nov 12, 2026
ee59e9f
test(geometry): the cases of the three reviews of the print check, tu…
ManuelStaggl Nov 12, 2026
1546201
test(geometry): the run time of the overhang check is held by its gro…
ManuelStaggl Nov 12, 2026
560cd45
test(desktop): the text pick test sets the top view again before each…
ManuelStaggl Nov 12, 2026
29a3fed
Merge branch 'fix/print-check-audit' into fix/mechanism-audit
ManuelStaggl Nov 12, 2026
d2a9929
Merge remote-tracking branch 'origin/main' into fix/mechanism-audit
ManuelStaggl Nov 12, 2026
a9cd846
Merge remote-tracking branch 'origin/test/text-pick-focus' into fix/m…
ManuelStaggl Nov 12, 2026
8df84a8
test(desktop): the joint tests read the revision once no trial is reb…
ManuelStaggl Nov 12, 2026
a762eb4
Merge remote-tracking branch 'origin/test/text-pick-focus' into fix/m…
ManuelStaggl Nov 12, 2026
f7efaf1
Merge remote-tracking branch 'origin/main' into fix/mechanism-audit
ManuelStaggl Nov 12, 2026
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
12 changes: 6 additions & 6 deletions apps/desktop/e2e/solidcraft.contract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -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/);
Expand Down Expand Up @@ -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);
Expand Down
409 changes: 338 additions & 71 deletions apps/desktop/src/main/agents/mechanismTool.ts

Large diffs are not rendered by default.

76 changes: 76 additions & 0 deletions apps/desktop/src/main/agents/takeBack.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };
/** Runs SolidCraft commands in turn and gives each one's result; throws when one fails. */
export type Run = (commands: Step[]) => Promise<unknown[]>;

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<Mark | null> {
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<boolean> {
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;
}
}
2 changes: 1 addition & 1 deletion apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down
Loading