From 4982f658e1e1a3082bbe61cbbf2b9ad8c0214f3c Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:14:53 +0100 Subject: [PATCH 01/24] fix(geometry): the print check sizes each body by itself and lays a long one diagonally Audit D1/MG-03: width and depth were taken over all bodies together, so two 20 mm cubes designed 300 mm apart blocked as 320 x 20 mm. Audit D2/MG-02: only the axis-parallel box and its quarter turn were tried, so a 300 x 10 x 5 mm bar blocked on a 256 mm plate although it needs 219.2 mm a side across the diagonal. Every body is now measured as it lies and tried in steps of 0.1 degrees about Z; the finding names its body. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/e2e/app.spec.ts | 2 +- apps/desktop/src/main/print/printCheckNote.ts | 2 +- apps/desktop/src/renderer/src/i18n/de.json | 4 +- apps/desktop/src/renderer/src/i18n/en.json | 4 +- .../src/renderer/src/print/PrintCheckChip.tsx | 1 + apps/desktop/src/shared/contract.ts | 2 +- apps/desktop/test/printCheckNote.test.ts | 4 +- packages/geometry/src/printCheck.test.ts | 21 ++++ packages/geometry/src/printCheck.ts | 99 +++++++++++++------ 9 files changed, 102 insertions(+), 37 deletions(-) diff --git a/apps/desktop/e2e/app.spec.ts b/apps/desktop/e2e/app.spec.ts index 3c799f51..a17bcf47 100644 --- a/apps/desktop/e2e/app.spec.ts +++ b/apps/desktop/e2e/app.spec.ts @@ -1616,7 +1616,7 @@ test('the ready-to-print light follows the printer, points at its findings and a await light.click(); const panel = page.getByTestId('print-check-panel'); await expect(panel.getByTestId('check-finding').first()).toHaveAttribute('data-kind', 'size'); - await expect(panel).toContainText('Too large for the build volume: 64 × 32 × 20 mm, the printer takes 50 × 50 × 256 mm'); + await expect(panel).toContainText('Too large for the build volume: CableClip, 64 × 32 × 20 mm, the printer takes 50 × 50 × 256 mm'); expect(await axeAtRest(page, '[data-testid="print-check-panel"]')).toEqual([]); await page.keyboard.press('Escape'); diff --git a/apps/desktop/src/main/print/printCheckNote.ts b/apps/desktop/src/main/print/printCheckNote.ts index 6d5e2474..5affb2ab 100644 --- a/apps/desktop/src/main/print/printCheckNote.ts +++ b/apps/desktop/src/main/print/printCheckNote.ts @@ -20,7 +20,7 @@ function line(finding: Finding, nozzle: number): string { const tag = finding.severity === 'block' ? 'blocks printing' : 'warning'; switch (finding.kind) { case 'size': - return `- ${tag}: the part is ${finding.size.map(String).join(' × ')} mm, the printer takes ${finding.bed.map(String).join(' × ')} mm${finding.lying ? '; it would fit lying on another side' : ''}.`; + return `- ${tag}: body ${JSON.stringify(finding.body)} is ${finding.size.map(String).join(' × ')} mm, the printer takes ${finding.bed.map(String).join(' × ')} mm${finding.lying ? '; it would fit lying on another side' : ''}.`; case 'thinWall': return finding.severity === 'block' ? `- ${tag}: walls down to ${mm(finding.thickness)}, thinner than the ${mm(nozzle)} nozzle, so they do not print${where(finding.places)}.` diff --git a/apps/desktop/src/renderer/src/i18n/de.json b/apps/desktop/src/renderer/src/i18n/de.json index b0036ba4..1fae30a2 100644 --- a/apps/desktop/src/renderer/src/i18n/de.json +++ b/apps/desktop/src/renderer/src/i18n/de.json @@ -1115,8 +1115,8 @@ "faces": "{count, plural, one {# Fläche} other {# Flächen}}", "show": "Stelle zeigen", "finding": { - "size": "Zu groß für den Bauraum: {size} mm, Platz ist {bed} mm", - "sizeLying": "Zu groß für den Bauraum: {size} mm, Platz ist {bed} mm. Auf eine andere Seite gelegt würde es passen.", + "size": "Zu groß für den Bauraum: {body}, {size} mm, Platz ist {bed} mm", + "sizeLying": "Zu groß für den Bauraum: {body}, {size} mm, Platz ist {bed} mm. Auf eine andere Seite gelegt würde es passen.", "wallTooThin": "Wände nur {thickness} mm, dünner als die Düse ({nozzle} mm): werden nicht gedruckt", "wallThin": "Wände nur {thickness} mm, mindestens {minimum} mm (zwei Linien)", "overhang": "{area} mm² neigen sich über {limit}°: Ob sie Stütze brauchen, entscheidet der Slicer", diff --git a/apps/desktop/src/renderer/src/i18n/en.json b/apps/desktop/src/renderer/src/i18n/en.json index 9ddb47b9..cac419ef 100644 --- a/apps/desktop/src/renderer/src/i18n/en.json +++ b/apps/desktop/src/renderer/src/i18n/en.json @@ -1115,8 +1115,8 @@ "faces": "{count, plural, one {# face} other {# faces}}", "show": "Show the place", "finding": { - "size": "Too large for the build volume: {size} mm, the printer takes {bed} mm", - "sizeLying": "Too large for the build volume: {size} mm, the printer takes {bed} mm. Laid on another side it would fit.", + "size": "Too large for the build volume: {body}, {size} mm, the printer takes {bed} mm", + "sizeLying": "Too large for the build volume: {body}, {size} mm, the printer takes {bed} mm. Laid on another side it would fit.", "wallTooThin": "Walls only {thickness} mm, thinner than the nozzle ({nozzle} mm): they do not print", "wallThin": "Walls only {thickness} mm, at least {minimum} mm (two lines)", "overhang": "{area} mm² lean beyond {limit}°: the slicer decides whether they need support", diff --git a/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx b/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx index 112769e0..8fa08ca9 100644 --- a/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx +++ b/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx @@ -21,6 +21,7 @@ export function useFindingText(): (finding: PrintFinding) => string { switch (finding.kind) { case 'size': return t(finding.lying ? 'check.finding.sizeLying' : 'check.finding.size', { + body: finding.body, size: finding.size.map((value) => number.format(value)).join(' × '), bed: finding.bed.map((value) => number.format(value)).join(' × '), }); diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index 77f6acf3..67d50451 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -1143,7 +1143,7 @@ const findingPlacesSchema = z .max(20); const mmValue = finite.nonnegative().max(100_000); export const printFindingSchema = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('size'), severity: findingSeveritySchema, size: z.tuple([mmValue, mmValue, mmValue]), bed: z.tuple([mmValue, mmValue, mmValue]), lying: z.boolean() }).strict(), + z.object({ kind: z.literal('size'), severity: findingSeveritySchema, body: bodyName, size: z.tuple([mmValue, mmValue, mmValue]), bed: z.tuple([mmValue, mmValue, mmValue]), lying: z.boolean() }).strict(), z.object({ kind: z.literal('thinWall'), severity: findingSeveritySchema, thickness: mmValue, minimum: mmValue, area: finite.nonnegative(), places: findingPlacesSchema }).strict(), z.object({ kind: z.literal('overhang'), severity: findingSeveritySchema, area: finite.nonnegative(), limit: range(0, 90), places: findingPlacesSchema }).strict(), z.object({ kind: z.literal('contact'), severity: findingSeveritySchema, area: finite.nonnegative(), places: findingPlacesSchema }).strict(), diff --git a/apps/desktop/test/printCheckNote.test.ts b/apps/desktop/test/printCheckNote.test.ts index f08ba7f5..31437e96 100644 --- a/apps/desktop/test/printCheckNote.test.ts +++ b/apps/desktop/test/printCheckNote.test.ts @@ -14,7 +14,7 @@ describe('the ready-to-print check as the assistant reads it (M7)', () => { { status: 'block', findings: [ - { kind: 'size', severity: 'block', size: [300, 20, 20], bed: [256, 256, 256], lying: true }, + { kind: 'size', severity: 'block', body: 'Rod', size: [300, 20, 20], bed: [256, 256, 256], lying: true }, { kind: 'thinWall', severity: 'block', thickness: 0.3, minimum: 0.9, area: 400, places: [{ body: 'Plate', faces: ['F1:+z', 'F1:-z'] }], box: { min: [0, 0, 0], max: [20, 20, 0.3] } }, { kind: 'overhang', severity: 'warn', area: 120, limit: 45, places: [{ body: 'Top', faces: ['F3:-z'] }], box: null }, { kind: 'feature', severity: 'warn', feature: 'Shell1', message: 'thin result' }, @@ -29,7 +29,7 @@ describe('the ready-to-print check as the assistant reads it (M7)', () => { const note = printCheckNote(report); expect(note).toContain('not ready to print'); - expect(note).toContain('the part is 300 × 20 × 20 mm, the printer takes 256 × 256 × 256 mm; it would fit lying on another side'); + expect(note).toContain('body "Rod" is 300 × 20 × 20 mm, the printer takes 256 × 256 × 256 mm; it would fit lying on another side'); expect(note).toContain('walls down to 0.3 mm, thinner than the 0.4 mm nozzle, so they do not print (faces "F1:+z", "F1:-z" of body "Plate")'); expect(note).toContain('120 mm² lean more than 45° from the vertical (faces "F3:-z" of body "Top"); the slicer decides whether they need support'); expect(note).toContain('feature "Shell1" reports "thin result"'); diff --git a/packages/geometry/src/printCheck.test.ts b/packages/geometry/src/printCheck.test.ts index 9fd5b6b3..965d01a1 100644 --- a/packages/geometry/src/printCheck.test.ts +++ b/packages/geometry/src/printCheck.test.ts @@ -60,6 +60,25 @@ describe('the ready-to-print check (M7)', () => { expect(lying.findings[0]).toMatchObject({ kind: 'size', lying: true }); }); + it('lays a long bar across the diagonal of the plate, as the slicer turns it', () => { + // Audit D2, MG-02: 300 × 10 × 5 needs (300 + 10) / √2 = 219.2 mm a side and was blocked as 300 mm. + expect(checkPrint([box('Bar', [0, 0, 0], [300, 10, 5])], PLA).status).toBe('ready'); + expect(checkPrint([box('Bar', [0, 0, 0], [300, 20, 10])], PLA).status).toBe('ready'); + // 370 mm needs 268.7 mm a side: that one does not fit. + expect(checkPrint([box('Bar', [0, 0, 0], [370, 10, 5])], PLA).findings).toEqual([{ kind: 'size', severity: 'block', body: 'Bar', size: [370, 10, 5], bed: [256, 256, 256], lying: false }]); + // Turning about Z does not make a part lower. + expect(checkPrint([box('Post', [0, 0, 0], [10, 10, 300])], PLA).status).toBe('block'); + }); + + it('holds every body against the plate by itself, wherever it was designed', () => { + // Audit D1, MG-03: two bodies apart read as one part of 320 × 20 and 500 × 200 mm. + expect(checkPrint([box('A', [0, 0, 0], [20, 20, 20]), box('B', [300, 0, 0], [320, 20, 20])], PLA).status).toBe('ready'); + expect(checkPrint([box('A', [0, 0, 0], [200, 200, 3]), box('B', [300, 0, 0], [500, 200, 3])], PLA).status).toBe('ready'); + // The one that is too large is named. + const mixed = checkPrint([box('Small', [0, 0, 0], [20, 20, 20]), box('Tall', [300, 0, 0], [320, 20, 300])], PLA); + expect(mixed.findings.filter((f) => f.kind === 'size')).toMatchObject([{ body: 'Tall', size: [20, 20, 300] }]); + }); + it('warns about walls under two lines and blocks walls under the nozzle width, with their faces', () => { expect(minimumWall(0.4)).toBe(0.9); const thin = checkPrint([box('Plate', [0, 0, 0], [20, 20, 0.6])], PLA); @@ -162,6 +181,8 @@ describe('the ready-to-print check (M7)', () => { expect(result.status).not.toBe('block'); expect(result.findings.map((f) => f.kind)).not.toContain('size'); expect(result.findings.map((f) => f.kind)).not.toContain('contact'); + // 64 × 32 mm does not go onto a 50 mm plate, turned or not. + expect(checkPrint([clip], { ...PLA, bed: [50, 50, 256] }).findings.find((f) => f.kind === 'size')).toMatchObject({ body: 'CableClip', size: [64, 32, 20] }); }); }); diff --git a/packages/geometry/src/printCheck.ts b/packages/geometry/src/printCheck.ts index bedb6eba..96f2dbaf 100644 --- a/packages/geometry/src/printCheck.ts +++ b/packages/geometry/src/printCheck.ts @@ -61,7 +61,7 @@ interface Located { } export type PrintFinding = - | { kind: 'size'; severity: FindingSeverity; size: [number, number, number]; bed: [number, number, number]; lying: boolean } + | { kind: 'size'; severity: FindingSeverity; body: string; size:[number, number, number]; bed: [number, number, number]; lying: boolean } | ({ kind: 'thinWall'; severity: FindingSeverity; thickness: number; minimum: number; area: number } & Located) | ({ kind: 'overhang'; severity: FindingSeverity; area: number; limit: number } & Located) | ({ kind: 'contact'; severity: FindingSeverity; area: number } & Located) @@ -257,26 +257,67 @@ function touches(a: PrintCheckBody, b: PrintCheckBody): boolean { return false; } -/** Width and depth of all bodies together, and the height of the tallest one on its own bed. */ -function printedSize(bodies: readonly { positions: ArrayLike }[]): [number, number, number] | null { - const min = [Infinity, Infinity]; - const max = [-Infinity, -Infinity]; - let height = 0; - for (const { positions } of bodies) { - let low = Infinity; - let high = -Infinity; - for (let i = 0; i < positions.length; i += 3) { - for (let k = 0; k < 2; k += 1) { - min[k] = Math.min(min[k] ?? Infinity, positions[i + k] ?? 0); - max[k] = Math.max(max[k] ?? -Infinity, positions[i + k] ?? 0); +/** Width, depth and height of one body as it lies. */ +function extentOf(positions: ArrayLike): [number, number, number] { + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + for (let i = 0; i < positions.length; i += 3) { + for (let k = 0; k < 3; k += 1) { + min[k] = Math.min(min[k] ?? Infinity, positions[i + k] ?? 0); + max[k] = Math.max(max[k] ?? -Infinity, positions[i + k] ?? 0); + } + } + return [(max[0] ?? 0) - (min[0] ?? 0), (max[1] ?? 0) - (min[1] ?? 0), (max[2] ?? 0) - (min[2] ?? 0)]; +} + +/** The body seen from above: the convex hull of its points (monotone chain). */ +function outlineOf(positions: ArrayLike): [number, number][] { + const points: [number, number][] = []; + for (let i = 0; i < positions.length; i += 3) points.push([positions[i] ?? 0, positions[i + 1] ?? 0]); + points.sort((a, b) => a[0] - b[0] || a[1] - b[1]); + const half = (list: readonly [number, number][]) => { + const hull: [number, number][] = []; + for (const p of list) { + while (hull.length >= 2) { + const a = hull[hull.length - 2] ?? p; + const b = hull[hull.length - 1] ?? p; + if ((b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]) > 0) break; + hull.pop(); } - low = Math.min(low, positions[i + 2] ?? 0); - high = Math.max(high, positions[i + 2] ?? 0); + hull.push(p); } - if (Number.isFinite(low)) height = Math.max(height, high - low); + return hull; + }; + return [...half(points), ...half([...points].reverse())]; +} + +/** A size counts as fitting up to this much over the plate: sizes are shown to 0.1 mm. */ +const SIZE_SLACK = 0.05; + +/** + * Whether the body fits the plate turned about Z by some angle: the slicer turns it freely, so a + * long bar lies across the diagonal. + * shortcut: tried in steps of 0.1°, which is 0.3 mm at the end of a 300 mm bar; solve the angles + * exactly if a part that fits by less than that must not be blocked. + */ +function fitsTurned(positions: ArrayLike, width: number, depth: number): boolean { + const outline = outlineOf(positions); + for (let step = 1; step < 1800; step += 1) { + const cos = Math.cos((step * Math.PI) / 1800); + const sin = Math.sin((step * Math.PI) / 1800); + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const [x, y] of outline) { + minX = Math.min(minX, x * cos - y * sin); + maxX = Math.max(maxX, x * cos - y * sin); + minY = Math.min(minY, x * sin + y * cos); + maxY = Math.max(maxY, x * sin + y * cos); + } + if (maxX - minX <= width + SIZE_SLACK && maxY - minY <= depth + SIZE_SLACK) return true; } - if (!Number.isFinite(min[0])) return null; - return [(max[0] ?? 0) - (min[0] ?? 0), (max[1] ?? 0) - (min[1] ?? 0), height]; + return false; } /** The body's mesh as it lies on the bed: turned onto its print face, or as designed. */ @@ -307,16 +348,15 @@ function bedOf(positions: ArrayLike): number { return Number.isFinite(low) ? low : 0; } -function sizeFinding(bodies: readonly { positions: ArrayLike }[], bed: readonly [number, number, number]): PrintFinding | null { - const printed = printedSize(bodies); - if (!printed) return null; - const size = printed.map((value) => round(value, 1)) as [number, number, number]; - const fits = (x: number, y: number, z: number) => x <= bed[0] && y <= bed[1] && z <= bed[2]; - const [x, y, z] = size; +/** A body that fits the build volume in no position on the plate. */ +function sizeFinding(body: string, positions: ArrayLike, bed: readonly [number, number, number]): PrintFinding | null { + const [x, y, z] = extentOf(positions); + if (!Number.isFinite(x)) return null; + const fits = (a: number, b: number, c: number) => a <= bed[0] + SIZE_SLACK && b <= bed[1] + SIZE_SLACK && c <= bed[2] + SIZE_SLACK; // Turned on the bed, the slicer places it; that is no finding. - if (fits(x, y, z) || fits(y, x, z)) return null; + if (fits(x, y, z) || fits(y, x, z) || (z <= bed[2] + SIZE_SLACK && fitsTurned(positions, bed[0], bed[1]))) return null; const lying = fits(x, z, y) || fits(z, x, y) || fits(y, z, x) || fits(z, y, x); - return { kind: 'size', severity: 'block', size, bed: [...bed], lying }; + return { kind: 'size', severity: 'block', body, size: [round(x, 1), round(y, 1), round(z, 1)], bed: [...bed], lying }; } const SEVERITY_ORDER: Record = { block: 0, warn: 1 }; @@ -337,8 +377,11 @@ export function checkPrint(bodies: readonly PrintCheckBody[], profile: PrintChec const parts = bodies.filter((body) => body.model.table.faces.length > 0); if (parts.length > 0) { const meshes = parts.map(laid); - const size = sizeFinding(meshes, profile.bed); - if (size) findings.push(size); + // Each body against the plate by itself: the slicer places every one, on more plates if need be. + parts.forEach((body, b) => { + const size = sizeFinding(body.name, meshes[b]?.positions ?? [], profile.bed); + if (size) findings.push(size); + }); // Walls: under two lines they print poorly, under the nozzle width not at all. const minimum = minimumWall(profile.nozzle); From 1650c35a2fa723d7612caf4d514196914003a873 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:17:52 +0100 Subject: [PATCH 02/24] fix(geometry): the print check looks at what lies on the bed body by body Audit D3: the area on the bed was added up over all bodies and compared once, so the 400 mm2 of a cube hid a sphere or a cone on its tip beside it (no contact finding). Each body now gets its own finding, named even when no face of it lies on the bed. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/print/printCheckNote.ts | 2 ++ apps/desktop/src/renderer/src/i18n/de.json | 2 +- apps/desktop/src/renderer/src/i18n/en.json | 2 +- .../src/renderer/src/print/PrintCheckChip.tsx | 2 +- apps/desktop/test/printCheckNote.test.ts | 5 +++++ packages/geometry/src/printCheck.test.ts | 10 ++++++++++ packages/geometry/src/printCheck.ts | 19 ++++++++++--------- 7 files changed, 30 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/print/printCheckNote.ts b/apps/desktop/src/main/print/printCheckNote.ts index 5affb2ab..ed6a3021 100644 --- a/apps/desktop/src/main/print/printCheckNote.ts +++ b/apps/desktop/src/main/print/printCheckNote.ts @@ -8,6 +8,8 @@ const mm = (value: number) => `${String(Math.round(value * 100) / 100)} mm`; /** Where, in the face names the model also gets from SolidCraft; long lists are cut short. */ function where(places: Places): string { if (places.length === 0) return ''; + // A body that rests on a point or an edge has no face to name. + if (places.every(({ faces }) => faces.length === 0)) return ` (body ${places.map(({ body }) => JSON.stringify(body)).join(', ')})`; const parts = places.slice(0, 4).map(({ body, faces }) => { const shown = faces.slice(0, 6).map((face) => JSON.stringify(face)).join(', '); const more = faces.length > 6 ? ` and ${String(faces.length - 6)} more` : ''; diff --git a/apps/desktop/src/renderer/src/i18n/de.json b/apps/desktop/src/renderer/src/i18n/de.json index 1fae30a2..327dc19a 100644 --- a/apps/desktop/src/renderer/src/i18n/de.json +++ b/apps/desktop/src/renderer/src/i18n/de.json @@ -1120,7 +1120,7 @@ "wallTooThin": "Wände nur {thickness} mm, dünner als die Düse ({nozzle} mm): werden nicht gedruckt", "wallThin": "Wände nur {thickness} mm, mindestens {minimum} mm (zwei Linien)", "overhang": "{area} mm² neigen sich über {limit}°: Ob sie Stütze brauchen, entscheidet der Slicer", - "contact": "Nur {area} mm² liegen auf dem Druckbett: kann sich lösen oder kippen", + "contact": "Nur {area} mm² von {body} liegen auf dem Druckbett: kann sich lösen oder kippen", "separate": "{bodies} berühren sich, sind aber getrennte Körper: Der Slicer legt jeden für sich aufs Druckbett. Vereinige sie, wenn sie ein Teil sind.", "feature": "{feature}: {message}", "overlap": "{a} und {b} überschneiden sich um {volume} mm³ – als getrennte Teile passen sie so nicht zusammen; gehören sie zu einem Teil, vereinen", diff --git a/apps/desktop/src/renderer/src/i18n/en.json b/apps/desktop/src/renderer/src/i18n/en.json index cac419ef..d5e9197c 100644 --- a/apps/desktop/src/renderer/src/i18n/en.json +++ b/apps/desktop/src/renderer/src/i18n/en.json @@ -1120,7 +1120,7 @@ "wallTooThin": "Walls only {thickness} mm, thinner than the nozzle ({nozzle} mm): they do not print", "wallThin": "Walls only {thickness} mm, at least {minimum} mm (two lines)", "overhang": "{area} mm² lean beyond {limit}°: the slicer decides whether they need support", - "contact": "Only {area} mm² lie on the bed: may come loose or tip over", + "contact": "Only {area} mm² of {body} lie on the bed: may come loose or tip over", "separate": "{bodies} touch but are separate bodies: the slicer lays each on the bed by itself. Combine them if they are one part.", "feature": "{feature}: {message}", "overlap": "{a} and {b} overlap by {volume} mm³ – as separate parts they do not go together; combine them if they are one part", diff --git a/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx b/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx index 8fa08ca9..95a37112 100644 --- a/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx +++ b/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx @@ -32,7 +32,7 @@ export function useFindingText(): (finding: PrintFinding) => string { case 'overhang': return t('check.finding.overhang', { area: number.format(finding.area), limit: finding.limit }); case 'contact': - return t('check.finding.contact', { area: number.format(finding.area) }); + return t('check.finding.contact', { area: number.format(finding.area), body: finding.places[0]?.body ?? '' }); case 'separate': return t('check.finding.separate', { bodies: new Intl.ListFormat(i18n.language, { type: 'conjunction' }).format(finding.bodies) }); case 'feature': diff --git a/apps/desktop/test/printCheckNote.test.ts b/apps/desktop/test/printCheckNote.test.ts index 31437e96..137279b9 100644 --- a/apps/desktop/test/printCheckNote.test.ts +++ b/apps/desktop/test/printCheckNote.test.ts @@ -35,6 +35,11 @@ describe('the ready-to-print check as the assistant reads it (M7)', () => { expect(note).toContain('feature "Shell1" reports "thin result"'); }); + it('names the body that rests on an edge and has no face on the bed', () => { + const note = printCheckNote({ status: 'warn', nozzle: 0.4, revision: 1, findings: [{ kind: 'contact', severity: 'warn', area: 0, places: [{ body: 'Ball', faces: [] }] }] }); + expect(note).toContain('only 0 mm² lie on the bed, the part may come loose or tip over (body "Ball").'); + }); + it('cuts long face lists short', () => { const faces = Array.from({ length: 30 }, (_, i) => `F${String(i)}:+z`); const note = printCheckNote({ status: 'warn', nozzle: 0.4, revision: 1, findings: [{ kind: 'contact', severity: 'warn', area: 3, places: [{ body: 'B', faces }] }] }); diff --git a/packages/geometry/src/printCheck.test.ts b/packages/geometry/src/printCheck.test.ts index 965d01a1..d3f9f680 100644 --- a/packages/geometry/src/printCheck.test.ts +++ b/packages/geometry/src/printCheck.test.ts @@ -122,6 +122,16 @@ describe('the ready-to-print check (M7)', () => { expect(apart.findings.find((f) => f.kind === 'separate')).toMatchObject({ severity: 'warn', bodies: ['Leg', 'Top'] }); }); + it('looks at what lies on the bed body by body: a broad one does not hold one standing on an edge', () => { + // Audit D3: 400 mm² of a cube hid a second body with nothing on the bed. + const cube = box('Cube', [20, 0, 0], [40, 20, 20]); + const onEdge = prismBody('Diamond', [[0, 0], [10, 10], [0, 20], [-10, 10]], 20); + const contact = checkPrint([cube, onEdge], PLA).findings.filter((f) => f.kind === 'contact'); + expect(contact).toMatchObject([{ severity: 'warn', area: 0, places: [{ body: 'Diamond', faces: [] }] }]); + // Two bodies that both lie flat: nothing. + expect(checkPrint([cube, box('Second', [50, 0, 0], [70, 20, 20])], PLA)).toEqual({ status: 'ready', findings: [] }); + }); + it('takes a 45° chamfer facing the bed as printable, a flatter one as an overhang', () => { // A bar with its lower edges chamfered: 3 mm at 45°, and 5 mm across, 3 mm up (59° from the vertical). const at45 = checkPrint([prismBody('Bar', [[3, 0], [17, 0], [20, 3], [20, 10], [0, 10], [0, 3]], 40)], PLA); diff --git a/packages/geometry/src/printCheck.ts b/packages/geometry/src/printCheck.ts index 96f2dbaf..a913f634 100644 --- a/packages/geometry/src/printCheck.ts +++ b/packages/geometry/src/printCheck.ts @@ -410,9 +410,9 @@ export function checkPrint(bodies: readonly PrintCheckBody[], profile: PrintChec // Overhangs by angle and the area on the bed, each body on its own bed. const limits = overhangLimits(profile.overhang); const overhang: FaceTally[] = []; - const contact: FaceTally[] = []; - let onBed = 0; - parts.forEach(({ model }, b) => { + const loose: PrintFinding[] = []; + parts.forEach((body, b) => { + const { model } = body; const geometry = model.geometry; const mesh = meshes[b]; if (!mesh) return; @@ -429,7 +429,6 @@ export function checkPrint(bodies: readonly PrintCheckBody[], profile: PrintChec limits, smallOverhangReach(profile.nozzle), ); - onBed += map.area[Overhang.Bed]; const faces = model.table.faces.length; const overhangTally = new FaceTally(faces); const contactTally = new FaceTally(faces); @@ -444,16 +443,18 @@ export function checkPrint(bodies: readonly PrintCheckBody[], profile: PrintChec overhangTally.area[face] = area; }); overhang.push(overhangTally); - contact.push(contactTally); + // Each body lies on the bed by itself: one with a broad base does not hold another on its edge. + const onBed = map.area[Overhang.Bed]; + if (onBed < MIN_CONTACT) { + const lying = locate([body], [contactTally], 1e-6); + loose.push({ kind: 'contact', severity: 'warn', area: round(onBed, 0), places: lying.places.length > 0 ? lying.places : [{ body: body.name, faces: [] }], box: lying.box }); + } }); const steep = locate(parts, overhang, MIN_AREA); if (steep.area >= MIN_AREA) { findings.push({ kind: 'overhang', severity: 'warn', area: round(steep.area, 0), limit: profile.overhang, places: steep.places, box: steep.box }); } - if (onBed < MIN_CONTACT) { - const lying = locate(parts, contact, 1e-6); - findings.push({ kind: 'contact', severity: 'warn', area: round(onBed, 0), places: lying.places, box: lying.box }); - } + findings.push(...loose); } findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); From a187d196bf85c89f1dbad92bfd030c29f418209e Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:23:36 +0100 Subject: [PATCH 03/24] fix(geometry): the print check measures floors in layers and walls across their whole face Audit D4, N-druckpruefung-1: the wall check knew no direction and no layer height. A 0.3 mm plate flat on the bed blocked as "thinner than the nozzle", and the 0.8 mm floor the design rule asks for warned as a wall under two lines. What lies within 45 degrees of flat, as the body lies, now stands against four layers (at least 0.8 mm) and blocks under one layer; the profile carries the layer height. Audit D6: only the middle of each triangle was measured, so a wall tapering from 2.5 to 0.2 mm passed. Rays are now spread a nozzle width apart and set at the corners, and the block counts the area under the nozzle, not the faces that touch it. Audit MG-04: a wall of exactly two lines (0.9 mm) was reported as under them. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/print/printCheckNote.ts | 5 + apps/desktop/src/main/print/printProfiles.ts | 3 +- apps/desktop/src/renderer/src/i18n/de.json | 2 + apps/desktop/src/renderer/src/i18n/en.json | 2 + .../src/renderer/src/print/PrintCheckChip.tsx | 1 + apps/desktop/src/renderer/src/print/report.ts | 2 +- .../src/renderer/src/print/usePrintCheck.ts | 2 +- apps/desktop/src/shared/contract.ts | 2 +- apps/desktop/test/printCheckNote.test.ts | 11 +- packages/geometry/src/index.ts | 1 + packages/geometry/src/overhang.ts | 19 +++ packages/geometry/src/printCheck.test.ts | 53 ++++++-- packages/geometry/src/printCheck.ts | 115 +++++++++++++----- 13 files changed, 173 insertions(+), 45 deletions(-) diff --git a/apps/desktop/src/main/print/printCheckNote.ts b/apps/desktop/src/main/print/printCheckNote.ts index ed6a3021..e443ba7f 100644 --- a/apps/desktop/src/main/print/printCheckNote.ts +++ b/apps/desktop/src/main/print/printCheckNote.ts @@ -24,6 +24,11 @@ function line(finding: Finding, nozzle: number): string { case 'size': return `- ${tag}: body ${JSON.stringify(finding.body)} is ${finding.size.map(String).join(' × ')} mm, the printer takes ${finding.bed.map(String).join(' × ')} mm${finding.lying ? '; it would fit lying on another side' : ''}.`; case 'thinWall': + // What lies flat is built of layers: it prints from one layer on, and closes from four. + if (finding.floor) + return finding.severity === 'block' + ? `- ${tag}: floors or lids down to ${mm(finding.thickness)}, thinner than one layer, so they do not print${where(finding.places)}.` + : `- ${tag}: floors or lids down to ${mm(finding.thickness)}, under the ${mm(finding.minimum)} a floor needs to print closed (four layers)${where(finding.places)}.`; return finding.severity === 'block' ? `- ${tag}: walls down to ${mm(finding.thickness)}, thinner than the ${mm(nozzle)} nozzle, so they do not print${where(finding.places)}.` : `- ${tag}: walls down to ${mm(finding.thickness)}, under the ${mm(finding.minimum)} of two lines${where(finding.places)}.`; diff --git a/apps/desktop/src/main/print/printProfiles.ts b/apps/desktop/src/main/print/printProfiles.ts index 1f862689..b95ed72f 100644 --- a/apps/desktop/src/main/print/printProfiles.ts +++ b/apps/desktop/src/main/print/printProfiles.ts @@ -15,6 +15,7 @@ import { type PrintSettings, } from '../../shared/contract'; import { fitSize, lineWidth, printInPlaceGap, scaleClearance, unitSize, type FitPrinter } from '../../shared/designFit'; +import { minimumFloor } from '@partbuddy/geometry'; /** * What is kept next to a design: its material, and the design rule groups the user turned on or off @@ -280,7 +281,7 @@ export function designLines(design: DesignRules, printer: FitPrinter, material: ); if (design.snap) lines.push( - `- Sizes you choose yourself snap up to the printer: walls and ribs to whole lines of ${mm(line)} (${series(line, 2, 5)}), floors, lids, steps and chamfer heights to whole layers of ${mm(layer)} (${series(layer, 3, 6)}), floors and lids at least ${mm(Math.max(0.8, layer * 4))}, print-in-place gaps ${mm(gap)}, radii and details at least ${mm(unitSize('nozzle', printer))}. Mark each such parameter's comment with the multiple, e.g. "Wall [auto 3×line]", "Floor [auto 5×layer]", "Hinge gap [auto 1×nozzle]", so PartBuddy fits it again when the user changes printer or nozzle. Sizes the user named get no mark.`, + `- Sizes you choose yourself snap up to the printer: walls and ribs to whole lines of ${mm(line)} (${series(line, 2, 5)}), floors, lids, steps and chamfer heights to whole layers of ${mm(layer)} (${series(layer, 3, 6)}), floors and lids at least ${mm(minimumFloor(layer))}, print-in-place gaps ${mm(gap)}, radii and details at least ${mm(unitSize('nozzle', printer))}. Mark each such parameter's comment with the multiple, e.g. "Wall [auto 3×line]", "Floor [auto 5×layer]", "Hinge gap [auto 1×nozzle]", so PartBuddy fits it again when the user changes printer or nozzle. Sizes the user named get no mark.`, ); if (off.length > 0) lines.push(`- The user turned off ${off.map((rule) => RULE_NAMES[rule]).join(', ')}: do not add them unless the user asks for them.`); diff --git a/apps/desktop/src/renderer/src/i18n/de.json b/apps/desktop/src/renderer/src/i18n/de.json index 327dc19a..5934b70c 100644 --- a/apps/desktop/src/renderer/src/i18n/de.json +++ b/apps/desktop/src/renderer/src/i18n/de.json @@ -1119,6 +1119,8 @@ "sizeLying": "Zu groß für den Bauraum: {body}, {size} mm, Platz ist {bed} mm. Auf eine andere Seite gelegt würde es passen.", "wallTooThin": "Wände nur {thickness} mm, dünner als die Düse ({nozzle} mm): werden nicht gedruckt", "wallThin": "Wände nur {thickness} mm, mindestens {minimum} mm (zwei Linien)", + "floorTooThin": "Böden oder Deckel nur {thickness} mm, dünner als eine Schicht: werden nicht gedruckt", + "floorThin": "Böden oder Deckel nur {thickness} mm, mindestens {minimum} mm (vier Schichten)", "overhang": "{area} mm² neigen sich über {limit}°: Ob sie Stütze brauchen, entscheidet der Slicer", "contact": "Nur {area} mm² von {body} liegen auf dem Druckbett: kann sich lösen oder kippen", "separate": "{bodies} berühren sich, sind aber getrennte Körper: Der Slicer legt jeden für sich aufs Druckbett. Vereinige sie, wenn sie ein Teil sind.", diff --git a/apps/desktop/src/renderer/src/i18n/en.json b/apps/desktop/src/renderer/src/i18n/en.json index d5e9197c..1186686c 100644 --- a/apps/desktop/src/renderer/src/i18n/en.json +++ b/apps/desktop/src/renderer/src/i18n/en.json @@ -1119,6 +1119,8 @@ "sizeLying": "Too large for the build volume: {body}, {size} mm, the printer takes {bed} mm. Laid on another side it would fit.", "wallTooThin": "Walls only {thickness} mm, thinner than the nozzle ({nozzle} mm): they do not print", "wallThin": "Walls only {thickness} mm, at least {minimum} mm (two lines)", + "floorTooThin": "Floors or lids only {thickness} mm, thinner than one layer: they do not print", + "floorThin": "Floors or lids only {thickness} mm, at least {minimum} mm (four layers)", "overhang": "{area} mm² lean beyond {limit}°: the slicer decides whether they need support", "contact": "Only {area} mm² of {body} lie on the bed: may come loose or tip over", "separate": "{bodies} touch but are separate bodies: the slicer lays each on the bed by itself. Combine them if they are one part.", diff --git a/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx b/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx index 95a37112..89d5d523 100644 --- a/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx +++ b/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx @@ -26,6 +26,7 @@ export function useFindingText(): (finding: PrintFinding) => string { bed: finding.bed.map((value) => number.format(value)).join(' × '), }); case 'thinWall': + if (finding.floor) return t(finding.severity === 'block' ? 'check.finding.floorTooThin' : 'check.finding.floorThin', { thickness: number.format(finding.thickness), minimum: number.format(finding.minimum) }); return finding.severity === 'block' ? t('check.finding.wallTooThin', { thickness: number.format(finding.thickness), nozzle: number.format(nozzle) }) : t('check.finding.wallThin', { thickness: number.format(finding.thickness), minimum: number.format(finding.minimum) }); diff --git a/apps/desktop/src/renderer/src/print/report.ts b/apps/desktop/src/renderer/src/print/report.ts index b701a4f3..16b55479 100644 --- a/apps/desktop/src/renderer/src/print/report.ts +++ b/apps/desktop/src/renderer/src/print/report.ts @@ -18,7 +18,7 @@ export function reportOf(result: PrintCheck, nozzle: number, revision: number): const places = finding.places.slice(0, 20).map((place) => ({ body: place.body, faces: place.faces.slice(0, 40) })); switch (finding.kind) { case 'thinWall': - return { kind: finding.kind, severity: finding.severity, thickness: finding.thickness, minimum: finding.minimum, area: finding.area, places }; + return { kind: finding.kind, severity: finding.severity, floor: finding.floor, thickness: finding.thickness, minimum: finding.minimum, area: finding.area, places }; case 'overhang': return { kind: finding.kind, severity: finding.severity, area: finding.area, limit: finding.limit, places }; case 'contact': diff --git a/apps/desktop/src/renderer/src/print/usePrintCheck.ts b/apps/desktop/src/renderer/src/print/usePrintCheck.ts index b3272404..321e6d8a 100644 --- a/apps/desktop/src/renderer/src/print/usePrintCheck.ts +++ b/apps/desktop/src/renderer/src/print/usePrintCheck.ts @@ -27,7 +27,7 @@ export function usePrintCheck(): void { // Standard parts (B7) are counterparts and are not printed. const standard = new Set(model.structure.standardParts ?? []); const printed = model.bodies.filter((body) => !standard.has(body.name)).map((body) => ({ ...body, printFace: printFaces[body.name] })); - const result = checkPrint(printed, { bed: print.printer.bed, nozzle: print.printer.nozzle, overhang: print.material.overhang }, model.structure.features); + const result = checkPrint(printed, { bed: print.printer.bed, nozzle: print.printer.nozzle, layer: print.printer.layer, overhang: print.material.overhang }, model.structure.features); // A value that follows another through a motion link (a gear) is neither swept nor posed on its own: its driver is. const joints = (model.structure.joints ?? []).map((joint) => ({ ...joint, values: joint.values.map((value, index) => ({ ...value, index })).filter((value) => !value.follows) })).filter((joint) => joint.values.length > 0); // A joint left away from "as designed" needs no measuring, so it shows at once: with a lid closed onto its diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index 67d50451..1d07e1fe 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -1144,7 +1144,7 @@ const findingPlacesSchema = z const mmValue = finite.nonnegative().max(100_000); export const printFindingSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('size'), severity: findingSeveritySchema, body: bodyName, size: z.tuple([mmValue, mmValue, mmValue]), bed: z.tuple([mmValue, mmValue, mmValue]), lying: z.boolean() }).strict(), - z.object({ kind: z.literal('thinWall'), severity: findingSeveritySchema, thickness: mmValue, minimum: mmValue, area: finite.nonnegative(), places: findingPlacesSchema }).strict(), + z.object({ kind: z.literal('thinWall'), severity: findingSeveritySchema, floor: z.boolean(), thickness: mmValue, minimum: mmValue, area: finite.nonnegative(), places: findingPlacesSchema }).strict(), z.object({ kind: z.literal('overhang'), severity: findingSeveritySchema, area: finite.nonnegative(), limit: range(0, 90), places: findingPlacesSchema }).strict(), z.object({ kind: z.literal('contact'), severity: findingSeveritySchema, area: finite.nonnegative(), places: findingPlacesSchema }).strict(), z.object({ kind: z.literal('feature'), severity: findingSeveritySchema, feature: z.string().max(200), message: z.string().max(500) }).strict(), diff --git a/apps/desktop/test/printCheckNote.test.ts b/apps/desktop/test/printCheckNote.test.ts index 137279b9..6ee9221a 100644 --- a/apps/desktop/test/printCheckNote.test.ts +++ b/apps/desktop/test/printCheckNote.test.ts @@ -15,7 +15,7 @@ describe('the ready-to-print check as the assistant reads it (M7)', () => { status: 'block', findings: [ { kind: 'size', severity: 'block', body: 'Rod', size: [300, 20, 20], bed: [256, 256, 256], lying: true }, - { kind: 'thinWall', severity: 'block', thickness: 0.3, minimum: 0.9, area: 400, places: [{ body: 'Plate', faces: ['F1:+z', 'F1:-z'] }], box: { min: [0, 0, 0], max: [20, 20, 0.3] } }, + { kind: 'thinWall', severity: 'block', floor: false, thickness: 0.3, minimum: 0.9, area: 400, places: [{ body: 'Plate', faces: ['F1:+z', 'F1:-z'] }], box: { min: [0, 0, 0], max: [20, 20, 0.3] } }, { kind: 'overhang', severity: 'warn', area: 120, limit: 45, places: [{ body: 'Top', faces: ['F3:-z'] }], box: null }, { kind: 'feature', severity: 'warn', feature: 'Shell1', message: 'thin result' }, ], @@ -35,6 +35,15 @@ describe('the ready-to-print check as the assistant reads it (M7)', () => { expect(note).toContain('feature "Shell1" reports "thin result"'); }); + it('tells a thin floor from a thin wall: layers, not lines', () => { + const floor = { kind: 'thinWall' as const, floor: true, thickness: 0.3, minimum: 0.8, area: 900, places: [{ body: 'Box', faces: ['F1:-z'] }] }; + const warned = printCheckNote({ status: 'warn', nozzle: 0.4, revision: 1, findings: [{ ...floor, severity: 'warn' }] }); + expect(warned).toContain('warning: floors or lids down to 0.3 mm, under the 0.8 mm a floor needs to print closed (four layers) (faces "F1:-z" of body "Box")'); + expect(warned).not.toContain('nozzle'); + const blocked = printCheckNote({ status: 'block', nozzle: 0.4, revision: 1, findings: [{ ...floor, thickness: 0.1, severity: 'block' }] }); + expect(blocked).toContain('blocks printing: floors or lids down to 0.1 mm, thinner than one layer, so they do not print'); + }); + it('names the body that rests on an edge and has no face on the bed', () => { const note = printCheckNote({ status: 'warn', nozzle: 0.4, revision: 1, findings: [{ kind: 'contact', severity: 'warn', area: 0, places: [{ body: 'Ball', faces: [] }] }] }); expect(note).toContain('only 0 mm² lie on the bed, the part may come loose or tip over (body "Ball").'); diff --git a/packages/geometry/src/index.ts b/packages/geometry/src/index.ts index 30dc06d6..2c76b85a 100644 --- a/packages/geometry/src/index.ts +++ b/packages/geometry/src/index.ts @@ -21,6 +21,7 @@ export { } from './faceTable'; export { checkPrint, + minimumFloor, minimumWall, withClearance, withMotion, diff --git a/packages/geometry/src/overhang.ts b/packages/geometry/src/overhang.ts index 2b907c09..62c3a6c2 100644 --- a/packages/geometry/src/overhang.ts +++ b/packages/geometry/src/overhang.ts @@ -72,6 +72,25 @@ export function smallOverhangReach(nozzle: number): number { return Math.round(4 * 1.125 * nozzle * 100) / 100; } +/** + * `count` points spread evenly over a triangle, each as the weights of its three corners: the middle + * first, then the R2 sequence folded into the triangle. A flat face comes as two triangles, so its + * middles alone say nothing of what lies between them. + */ +export function spread(count: number): [number, number, number][] { + const points: [number, number, number][] = [[1 / 3, 1 / 3, 1 / 3]]; + for (let i = 1; i < count; i += 1) { + let u = (0.5 + i * 0.7548776662466927) % 1; + let v = (0.5 + i * 0.5698402909980532) % 1; + if (u + v > 1) { + u = 1 - u; + v = 1 - v; + } + points.push([1 - u - v, u, v]); + } + return points; +} + /** Positions closer than this (mm) are one vertex: render buffers repeat corners per face. */ const WELD = 1e-4; diff --git a/packages/geometry/src/printCheck.test.ts b/packages/geometry/src/printCheck.test.ts index d3f9f680..ca136554 100644 --- a/packages/geometry/src/printCheck.test.ts +++ b/packages/geometry/src/printCheck.test.ts @@ -4,13 +4,13 @@ import { describe, expect, it } from 'vitest'; import type { FaceTable } from './faceTable'; import { smallOverhangReach } from './overhang'; -import { checkPrint, minimumWall, withClearance, type PrintCheckBody, type PrintCheckProfile } from './printCheck'; +import { checkPrint, minimumFloor, minimumWall, withClearance, type PrintCheckBody, type PrintCheckProfile } from './printCheck'; import { buildRenderMesh } from './renderMesh'; import { parseTagged3mf, type TaggedMesh } from './tagged3mf'; import { boxesBody, lyingTube, prismBody } from './testShapes'; import { buildViewerModel } from './viewerModel'; -const PLA: PrintCheckProfile = { bed: [256, 256, 256], nozzle: 0.4, overhang: 45 }; +const PLA: PrintCheckProfile = { bed: [256, 256, 256], nozzle: 0.4, layer: 0.2, overhang: 45 }; /** A box as SolidCraft would export it: six faces, outward triangles, named like `F1:+z`. */ function box(name: string, min: [number, number, number], max: [number, number, number]): PrintCheckBody { @@ -45,6 +45,8 @@ function box(name: string, min: [number, number, number], max: [number, number, return { name, model: buildViewerModel(buildRenderMesh(mesh, table), table) }; } +const thinOf = (body: PrintCheckBody, profile = PLA) => checkPrint([body], profile).findings.filter((f) => f.kind === 'thinWall'); + describe('the ready-to-print check (M7)', () => { it('passes a solid block that lies flat on the bed', () => { expect(checkPrint([box('Block', [0, 0, 0], [40, 30, 10])], PLA)).toEqual({ status: 'ready', findings: [] }); @@ -81,15 +83,44 @@ describe('the ready-to-print check (M7)', () => { it('warns about walls under two lines and blocks walls under the nozzle width, with their faces', () => { expect(minimumWall(0.4)).toBe(0.9); - const thin = checkPrint([box('Plate', [0, 0, 0], [20, 20, 0.6])], PLA); - expect(thin.status).toBe('warn'); - const wall = thin.findings.find((f) => f.kind === 'thinWall'); - expect(wall).toMatchObject({ severity: 'warn', thickness: 0.6, minimum: 0.9 }); - expect(wall && 'places' in wall ? wall.places[0]?.faces : []).toEqual(expect.arrayContaining(['F1:-z', 'F1:+z'])); - - const foil = checkPrint([box('Foil', [0, 0, 0], [20, 20, 0.3])], PLA); - expect(foil.status).toBe('block'); - expect(foil.findings.find((f) => f.kind === 'thinWall')).toMatchObject({ severity: 'block', thickness: 0.3 }); + const thin = thinOf(box('Wall', [0, 0, 0], [30, 0.6, 20])); + expect(thin).toMatchObject([{ severity: 'warn', floor: false, thickness: 0.6, minimum: 0.9, area: 1200 }]); + expect(thin[0]?.places[0]?.faces).toEqual(expect.arrayContaining(['F1:-y', 'F1:+y'])); + expect(thinOf(box('Foil', [0, 0, 0], [30, 0.3, 20]))).toMatchObject([{ severity: 'block', floor: false, thickness: 0.3 }]); + }); + + it('does not count a wall of just two lines, only one under them', () => { + // Audit MG-04: 0.9 mm, the smallest wall the assistant snaps to, read as "0.9 under the 0.9 of two lines". + expect(thinOf(box('Wall', [0, 0, 0], [20, 0.9, 20]))).toEqual([]); + expect(thinOf(box('Wall', [0, 0, 0], [20, 0.89, 20]))).toMatchObject([{ severity: 'warn', thickness: 0.89 }]); + }); + + it('measures what lies flat in layers: a floor of four layers is sound, one layer still prints', () => { + expect(minimumFloor(0.2)).toBe(0.8); + expect(minimumFloor(0.3)).toBe(1.2); + // Audit N-druckpruefung-1: the 0.8 mm floor the design rule asks for read as a wall under two lines. + expect(checkPrint([box('Plate', [0, 0, 0], [30, 30, 0.8])], PLA)).toEqual({ status: 'ready', findings: [] }); + // Audit D4: 0.3 mm flat on the bed is a layer and a half, not "thinner than the nozzle, does not print". + const skin = thinOf(box('Plate', [0, 0, 0], [30, 30, 0.3])); + expect(skin).toMatchObject([{ severity: 'warn', floor: true, thickness: 0.3, minimum: 0.8, area: 1800 }]); + expect(skin[0]?.places[0]?.faces).toEqual(['F1:-z', 'F1:+z']); + // Under one layer nothing is laid. + expect(thinOf(box('Film', [0, 0, 0], [30, 30, 0.1]))).toMatchObject([{ severity: 'block', floor: true }]); + // The floor follows the layer height, and the way the body lies. + expect(thinOf(box('Plate', [0, 0, 0], [30, 30, 1]), { ...PLA, layer: 0.3 })).toMatchObject([{ floor: true, minimum: 1.2 }]); + const lying = { ...boxesBody('Wall', [[[0, 0, 0], [30, 0.6, 20]]]), printFace: 'F1:-y' }; + expect(thinOf(lying)).toMatchObject([{ severity: 'warn', floor: true, thickness: 0.6, minimum: 0.8 }]); + }); + + it('finds a wall that tapers to nothing between the middles of its triangles', () => { + // Audit D6: 30 long, 20 high, 0.2 mm at the bed and 2.5 mm on top; both face middles lie above 0.9 mm. + const taper = thinOf(prismBody('Taper', [[0, 0], [0.2, 0], [2.5, 20], [0, 20]], 30)); + expect(taper).toMatchObject([{ severity: 'block', floor: false, thickness: 0.2, places: [{ body: 'Taper', faces: ['F1:s1', 'F1:s3'] }] }]); + // 182.6 mm² a side lie under two lines. + expect(taper[0]?.area).toBeGreaterThan(340); + expect(taper[0]?.area).toBeLessThan(390); + // The same wall 2.5 mm all the way up has nothing thin. + expect(thinOf(prismBody('Wall', [[0, 0], [2.5, 0], [2.5, 20], [0, 20]], 30))).toEqual([]); }); it('marks the ceiling of a table top as an overhang by angle, and the small foot on the bed', () => { diff --git a/packages/geometry/src/printCheck.ts b/packages/geometry/src/printCheck.ts index a913f634..b67cf4df 100644 --- a/packages/geometry/src/printCheck.ts +++ b/packages/geometry/src/printCheck.ts @@ -1,7 +1,7 @@ import { Box3, DoubleSide, Quaternion, Ray, Vector3 } from 'three'; import type { MeshBVH } from 'three-mesh-bvh'; -import { Overhang, overhangLimits, overhangOf, smallOverhangReach } from './overhang'; +import { Overhang, overhangLimits, overhangOf, smallOverhangReach, spread } from './overhang'; import type { ViewerModel } from './viewerModel'; /** @@ -21,6 +21,8 @@ export interface PrintCheckProfile { /** Build volume X, Y, Z in mm. */ bed: readonly [number, number, number]; nozzle: number; + /** Layer height in mm: what lies flat is built of whole layers. */ + layer: number; /** Degrees from the vertical beyond which a face is an overhang. */ overhang: number; } @@ -62,7 +64,8 @@ interface Located { export type PrintFinding = | { kind: 'size'; severity: FindingSeverity; body: string; size:[number, number, number]; bed: [number, number, number]; lying: boolean } - | ({ kind: 'thinWall'; severity: FindingSeverity; thickness: number; minimum: number; area: number } & Located) + /** `floor`: what lies flat (a floor, a lid), measured against layers; else a wall, against lines. */ + | ({ kind: 'thinWall'; severity: FindingSeverity; floor: boolean; thickness: number; minimum: number; area: number } & Located) | ({ kind: 'overhang'; severity: FindingSeverity; area: number; limit: number } & Located) | ({ kind: 'contact'; severity: FindingSeverity; area: number } & Located) | { kind: 'separate'; severity: FindingSeverity; bodies: string[] } @@ -94,6 +97,15 @@ export function minimumWall(nozzle: number): number { return Math.round(nozzle * 2 * 1.125 * 100) / 100; } +/** + * Four layers, and at least 0.8 mm: the thinnest floor or lid that prints closed (Stratasys Direct, + * FDM Design Guide: four layers; Protolabs Network, key design considerations: over 0.8 mm). The + * assistant designs to the same number (printProfiles.ts). + */ +export function minimumFloor(layer: number): number { + return Math.round(Math.max(0.8, layer * 4) * 100) / 100; +} + /** Below this, an area is noise of the tessellation, not a finding (mm²). */ const MIN_AREA = 1; /** Walls under the nozzle width over at least this area make the part unprintable (mm²). */ @@ -163,35 +175,78 @@ function triangle(positions: ArrayLike, index: ArrayLike, t: num }; } -/** Wall thickness under each triangle: a ray from its middle straight into the material. */ -function thinWalls(body: PrintCheckBody, minimum: number): FaceTally { +/** The direction that points at the bed as the body lies: the normal of its print face, if it has a flat one. */ +function printDown({ model, printFace }: PrintCheckBody): Vector3 | undefined { + const down = printFace ? model.table.faces.find((face) => face.name === printFace)?.planeNormal : undefined; + return down ? new Vector3(...down).normalize() : undefined; +} + +/** A face within 45° of lying flat is a floor, a lid or a step: it is built in layers, not in lines. */ +const FLAT = Math.SQRT1_2; +/** Just inside each corner of a triangle, as weights of its corners. */ +const CORNERS: [number, number, number][] = [[0.998, 0.001, 0.001], [0.001, 0.998, 0.001], [0.001, 0.001, 0.998]]; + +/** What is too thin in one body: the faces under the minimum, and the area (mm²) that does not print at all. */ +interface Thin { + faces: FaceTally; + unprintable: number; +} + +/** + * Wall thickness under each triangle: rays straight into the material, spread a nozzle width apart + * over the triangle, and at its corners (between two flat faces a wall is thinnest at one of them). + * Walls stand against two lines and the nozzle; floors and lids, as the body lies, against four + * layers and one. + * shortcut: a body with more surface than MAX_RAYS nozzle widths squared is sampled more coarsely; + * raise MAX_RAYS (or check in a worker) if thin spots of large parts are missed. + */ +function thinWalls(body: PrintCheckBody, profile: PrintCheckProfile): { walls: Thin; floors: Thin } { const { model } = body; const geometry = model.geometry; // computeBoundsTree builds a MeshBVH (viewerModel.ts); the augmented type only names its base. const bvh = geometry.boundsTree as unknown as MeshBVH | undefined; - const tally = new FaceTally(model.table.faces.length); + const walls: Thin = { faces: new FaceTally(model.table.faces.length), unprintable: 0 }; + const floors: Thin = { faces: new FaceTally(model.table.faces.length), unprintable: 0 }; const index = geometry.index?.array; const positions = geometry.getAttribute('position').array; - if (!bvh || !index) return tally; + if (!bvh || !index) return { walls, floors }; const triangles = index.length / 3; const stride = Math.max(1, Math.ceil(triangles / MAX_RAYS)); + let surface = 0; + for (let t = 0; t < triangles; t += stride) surface += triangle(positions, index, t).area; + const cell = Math.max(profile.nozzle ** 2, surface / MAX_RAYS); + const down = printDown(body) ?? new Vector3(0, 0, -1); const ray = new Ray(); for (let t = 0; t < triangles; t += stride) { - const { corners, area, normal, centroid } = triangle(positions, index, t); + const { corners, area, normal } = triangle(positions, index, t); if (!normal || area === 0) continue; - ray.origin.copy(centroid).addScaledVector(normal, -EPSILON); + const flat = Math.abs(normal.dot(down)) >= FLAT; + const thin = flat ? floors : walls; + const minimum = flat ? minimumFloor(profile.layer) : minimumWall(profile.nozzle); + const printable = flat ? profile.layer : profile.nozzle; + const count = Math.ceil(area / cell); ray.direction.copy(normal).negate(); - const hit = bvh.raycastFirst(ray, DoubleSide, 0, minimum); - if (!hit || hit.faceIndex === t || hit.faceIndex === undefined || hit.faceIndex === null) continue; - const opposite = triangle(positions, index, hit.faceIndex).normal; - if (!opposite || opposite.dot(normal) > OPPOSITE) continue; - tally.add(model.triangleFaces[t] ?? 0, area * stride, hit.distance + EPSILON, corners); + const along = (axis: number, [a, b, c]: [number, number, number]) => a * (corners[axis] ?? 0) + b * (corners[axis + 3] ?? 0) + c * (corners[axis + 6] ?? 0); + [...spread(count), ...(count > 1 ? CORNERS : [])].forEach((weights, p) => { + ray.origin.set(along(0, weights), along(1, weights), along(2, weights)).addScaledVector(normal, -EPSILON); + const hit = bvh.raycastFirst(ray, DoubleSide, 0, minimum); + if (!hit || hit.faceIndex === t || hit.faceIndex === undefined || hit.faceIndex === null) return; + const thickness = hit.distance + EPSILON; + // A wall of just the minimum is not under it. + if (thickness > minimum - EPSILON) return; + const opposite = triangle(positions, index, hit.faceIndex).normal; + if (!opposite || opposite.dot(normal) > OPPOSITE) return; + // The corners only say how thin the face gets; its area is shared out among the spread points. + const share = p < count ? (area * stride) / count : 0; + thin.faces.add(model.triangleFaces[t] ?? 0, share, thickness, corners); + if (thickness < printable - EPSILON) thin.unprintable += share; + }); } - return tally; + return { walls, floors }; } /** Faces whose tallied area reaches `minArea`, with their names and common bounds. */ -function locate(bodies: readonly PrintCheckBody[], tallies: readonly FaceTally[], minArea: number, accept: (tally: FaceTally, face: number) => boolean = () => true) { +function locate(bodies: readonly PrintCheckBody[], tallies: readonly FaceTally[], minArea: number) { const places: FindingPlace[] = []; const min: [number, number, number] = [Infinity, Infinity, Infinity]; const max: [number, number, number] = [-Infinity, -Infinity, -Infinity]; @@ -202,7 +257,7 @@ function locate(bodies: readonly PrintCheckBody[], tallies: readonly FaceTally[] if (!tally) return; const faces: string[] = []; tally.area.forEach((value, face) => { - if (value < minArea || !accept(tally, face)) return; + if (value < minArea) return; faces.push(body.model.table.faces[face]?.name ?? `F${String(face)}`); area += value; extreme = Math.min(extreme, tally.extreme[face] ?? Number.POSITIVE_INFINITY); @@ -321,12 +376,12 @@ function fitsTurned(positions: ArrayLike, width: number, depth: number): } /** The body's mesh as it lies on the bed: turned onto its print face, or as designed. */ -function laid({ model, printFace }: PrintCheckBody): { positions: ArrayLike; normals: ArrayLike } { - const positions = model.geometry.getAttribute('position').array; - const normals = model.geometry.getAttribute('normal').array; - const down = printFace ? model.table.faces.find((face) => face.name === printFace)?.planeNormal : undefined; +function laid(body: PrintCheckBody): { positions: ArrayLike; normals: ArrayLike } { + const positions = body.model.geometry.getAttribute('position').array; + const normals = body.model.geometry.getAttribute('normal').array; + const down = printDown(body); if (!down) return { positions, normals }; - const turn = new Quaternion().setFromUnitVectors(new Vector3(...down).normalize(), new Vector3(0, 0, -1)); + const turn = new Quaternion().setFromUnitVectors(down, new Vector3(0, 0, -1)); const v = new Vector3(); const turned = (source: ArrayLike) => { const out = new Float32Array(source.length); @@ -383,14 +438,16 @@ export function checkPrint(bodies: readonly PrintCheckBody[], profile: PrintChec if (size) findings.push(size); }); - // Walls: under two lines they print poorly, under the nozzle width not at all. - const minimum = minimumWall(profile.nozzle); - const walls = parts.map((body) => thinWalls(body, minimum)); - const tooThin = locate(parts, walls, MIN_AREA, (tally, face) => (tally.extreme[face] ?? Infinity) < profile.nozzle); - const thin = locate(parts, walls, MIN_AREA); - if (thin.places.length > 0) { - const severity: FindingSeverity = tooThin.area >= BLOCK_THIN_AREA ? 'block' : 'warn'; - findings.push({ kind: 'thinWall', severity, thickness: round(thin.extreme), minimum, area: round(thin.area, 0), places: thin.places, box: thin.box }); + // Walls: under two lines they print poorly, under the nozzle width not at all. Floors and lids: + // under four layers they do not close, under one layer nothing is laid. + const thin = parts.map((body) => thinWalls(body, profile)); + for (const floor of [false, true]) { + const kind = thin.map((body) => (floor ? body.floors : body.walls)); + const found = locate(parts, kind.map((body) => body.faces), MIN_AREA); + if (found.places.length === 0) continue; + const unprintable = kind.reduce((sum, body) => sum + body.unprintable, 0); + const minimum = floor ? minimumFloor(profile.layer) : minimumWall(profile.nozzle); + findings.push({ kind: 'thinWall', severity: unprintable >= BLOCK_THIN_AREA ? 'block' : 'warn', floor, thickness: round(found.extreme), minimum, area: round(found.area, 0), places: found.places, box: found.box }); } // Bodies that touch fall apart in the slicer, each laid onto the bed by itself. From 73b24bf2051358ae9a5a94a7d1133420957ad134 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:26:33 +0100 Subject: [PATCH 04/24] fix(geometry): a ceiling or a wide bridge stays an overhang though its corners rest on walls Audit MG-01, N-mechanik-1: a patch counted as a small overhang when every corner of its triangles lay within reach of an edge with material below. A flat face is two triangles with all corners on its walls, so the ceiling of a box (1600 mm2), a 40 mm bridge and the 102 mm ceiling of a guide's groove (3058 mm2) dropped out and the check read ready. Points spread over each triangle are tested now, not only its corners. A flat ceiling between two such edges up to 10 mm apart is a bridge and stays out (UltiMaker; the assistant's design rule takes the same constant); a wider one is reported. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/print/printProfiles.ts | 4 +- packages/geometry/src/index.ts | 1 + packages/geometry/src/overhang.test.ts | 29 +++++++- packages/geometry/src/overhang.ts | 71 +++++++++++++++++--- packages/geometry/src/printCheck.ts | 3 +- 5 files changed, 96 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/print/printProfiles.ts b/apps/desktop/src/main/print/printProfiles.ts index b95ed72f..f669b727 100644 --- a/apps/desktop/src/main/print/printProfiles.ts +++ b/apps/desktop/src/main/print/printProfiles.ts @@ -15,7 +15,7 @@ import { type PrintSettings, } from '../../shared/contract'; import { fitSize, lineWidth, printInPlaceGap, scaleClearance, unitSize, type FitPrinter } from '../../shared/designFit'; -import { minimumFloor } from '@partbuddy/geometry'; +import { BRIDGE_SPAN, minimumFloor } from '@partbuddy/geometry'; /** * What is kept next to a design: its material, and the design rule groups the user turned on or off @@ -230,7 +230,7 @@ function supportFreeLines(overhang: number): string[] { 'Design it to print without support as designed, standing on its lowest point (the user\'s setting); where that cannot be done, say so and why:', `- Edges that face down towards the bed: chamfer them at 45° instead of filleting (the bottom of a fillet ends flat); fillets that face up or sideways are fine.`, `- Holes lying across the layers: up to about 10 mm wide the slicer bridges them; wider ones get a teardrop roof (45° to a point above the circle) or a flat roof (the teardrop cut off), so nothing leans more than ${String(overhang)}°.`, - '- Ceilings and ledges: walls at most 10 mm apart are bridged, ledges up to 1.5 mm out from a wall print; anything wider gets a 45° slope underneath, or say which side the part should rather stand on.', + `- Ceilings and ledges: walls at most ${String(BRIDGE_SPAN)} mm apart are bridged, ledges up to 1.5 mm out from a wall print; anything wider gets a 45° slope underneath, or say which side the part should rather stand on.`, 'Model these as their own features with parameters (layer height, roof angle) and name them when you report.', ]; } diff --git a/packages/geometry/src/index.ts b/packages/geometry/src/index.ts index 2c76b85a..bd7af646 100644 --- a/packages/geometry/src/index.ts +++ b/packages/geometry/src/index.ts @@ -1,5 +1,6 @@ export { meshMetrics, overhangFindings, type MeshMetrics, type OverhangFinding } from './analysis'; export { + BRIDGE_SPAN, Overhang, OVERHANG_LIMITS, overhangLimits, diff --git a/packages/geometry/src/overhang.test.ts b/packages/geometry/src/overhang.test.ts index 6ea4c5db..fadfdb2c 100644 --- a/packages/geometry/src/overhang.test.ts +++ b/packages/geometry/src/overhang.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { Overhang, overhangLimits, overhangOf } from './overhang'; +import { BRIDGE_SPAN, Overhang, OVERHANG_LIMITS, overhangLimits, overhangOf, smallOverhangReach } from './overhang'; /** One square face of side 10 per entry, as render buffers: corners, normal and face index. */ function faces(list: { corners: number[][]; normal: number[] }[]) { @@ -57,6 +57,33 @@ describe('overhangs for FDM printing (UI upgrade step 12)', () => { expect([map.vertices[0], map.vertices[4], map.vertices[8]]).toEqual([Overhang.Borderline, Overhang.Support, Overhang.Fine]); }); + it('leaves a bridge of up to 10 mm between two walls out, and keeps a wider one, a ceiling and an arm', () => { + // A ceiling at z 8, 30 deep, over the inner faces of the walls named: at x 0, at x `span`, at y 0 and y 30. + const roof = (span: number, walls: ('left' | 'right' | 'front' | 'back')[]) => { + const inner = { + left: { corners: [[0, 0, 0], [0, 30, 0], [0, 30, 8], [0, 0, 8]], normal: [1, 0, 0] }, + right: { corners: [[span, 0, 0], [span, 0, 8], [span, 30, 8], [span, 30, 0]], normal: [-1, 0, 0] }, + front: { corners: [[0, 0, 0], [0, 0, 8], [span, 0, 8], [span, 0, 0]], normal: [0, 1, 0] }, + back: { corners: [[0, 30, 0], [span, 30, 0], [span, 30, 8], [0, 30, 8]], normal: [0, -1, 0] }, + }; + const mesh = faces([{ corners: [[0, 0, 8], [0, 30, 8], [span, 30, 8], [span, 0, 8]], normal: [0, 0, -1] }, ...walls.map((wall) => inner[wall])]); + return Math.round(overhangOf(mesh, 0, OVERHANG_LIMITS, smallOverhangReach(0.4)).area[Overhang.Support]); + }; + expect(BRIDGE_SPAN).toBe(10); + // Within reach of both walls, and bridged. + expect(roof(3, ['left', 'right'])).toBe(0); + expect(roof(8, ['left', 'right'])).toBe(0); + expect(roof(10, ['left', 'right'])).toBe(0); + // Audit MG-01, N-mechanik-1: every corner of these lies on a wall, so they counted as small + // (a bridge of 40 mm, the 102 mm ceiling of a guide's groove, the ceiling of a box). + expect(roof(12, ['left', 'right'])).toBe(360); + expect(roof(40, ['left', 'right'])).toBe(1200); + expect(roof(102, ['left', 'right'])).toBe(3060); + expect(roof(40, ['left', 'right', 'front', 'back'])).toBe(1200); + // An arm held on one side only is no bridge, however short. + expect(roof(8, ['left'])).toBe(240); + }); + it('moves the limits with the material (M7): PETG at 40° needs support at 45°, borderline from 30°', () => { const at = (degrees: number) => -Math.sin((degrees * Math.PI) / 180); const slope = (degrees: number, z: number) => ({ diff --git a/packages/geometry/src/overhang.ts b/packages/geometry/src/overhang.ts index 62c3a6c2..5c650903 100644 --- a/packages/geometry/src/overhang.ts +++ b/packages/geometry/src/overhang.ts @@ -95,11 +95,24 @@ export function spread(count: number): [number, number, number][] { const WELD = 1e-4; /** - * Overhang triangles that are small: each connected patch whose every corner lies within `reach` + * How far (mm) a flat ceiling may span between the material on both sides and still print as a + * bridge without support: UltiMaker, Design for FFF 3D printing (10 mm; Protolabs Network and + * Xometry Pro name 5 mm for a bridge that does not sag at all). The assistant designs to the same + * number (printProfiles.ts). + */ +export const BRIDGE_SPAN = 10; + +/** A face that points down this straight (−z of its unit normal) is a ceiling that can be bridged. */ +const LEVEL = 0.99; +const CORNERS: [number, number, number][] = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; + +/** + * Overhang triangles that are small: each connected patch whose every point lies within `reach` * (mm, across the bed) of an edge where the material goes on below, the way a ledge or a thread - * flank hangs from its wall. A patch with no such edge starts in the air, and a patch reaching - * further (a ceiling, a cantilevered arm) needs support; both stay. - * shortcut: corner-to-edge distances are compared pairwise per patch; fine for the parts PartBuddy + * flank hangs from its wall, or, on a flat ceiling, between two such edges no more than + * `BRIDGE_SPAN` apart. A patch with no such edge starts in the air, and a patch reaching further + * (a ceiling, a wide bridge, a cantilevered arm) needs support; both stay. + * shortcut: point-to-edge distances are compared pairwise per patch; fine for the parts PartBuddy * builds, upgrade to a spatial grid if a check of a large mesh gets slow. */ function smallPatches(positions: ArrayLike, index: ArrayLike, support: readonly number[], reach: number, bedZ: number): Set { @@ -165,9 +178,7 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, su } } if (anchors.length === 0) continue; - const near = (v: number) => { - const px = positions[v * 3] ?? 0; - const py = positions[v * 3 + 1] ?? 0; + const near = (px: number, py: number) => { for (const [a, b] of anchors) { const ax = positions[a * 3] ?? 0; const ay = positions[a * 3 + 1] ?? 0; @@ -179,7 +190,51 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, su } return false; }; - const holds = patch.every((t) => near(index[t * 3] ?? 0) && near(index[t * 3 + 1] ?? 0) && near(index[t * 3 + 2] ?? 0)); + // A flat ceiling is bridged where a straight line through the point meets material on both + // sides within the bridge span. + const bridged = (px: number, py: number) => { + for (let turn = 0; turn < 12; turn += 1) { + const dx = Math.cos((turn * Math.PI) / 12); + const dy = Math.sin((turn * Math.PI) / 12); + let ahead = Infinity; + let behind = Infinity; + for (const [a, b] of anchors) { + const ax = (positions[a * 3] ?? 0) - px; + const ay = (positions[a * 3 + 1] ?? 0) - py; + const ex = (positions[b * 3] ?? 0) - (positions[a * 3] ?? 0); + const ey = (positions[b * 3 + 1] ?? 0) - (positions[a * 3 + 1] ?? 0); + const cross = dx * ey - dy * ex; + if (Math.abs(cross) < 1e-12) continue; + const s = (ax * dy - ay * dx) / cross; + if (s < -1e-6 || s > 1 + 1e-6) continue; + const along = (ax * ey - ay * ex) / cross; + if (along >= 0) ahead = Math.min(ahead, along); + else behind = Math.min(behind, -along); + } + if (ahead + behind <= BRIDGE_SPAN + WELD) return true; + } + return false; + }; + // Every point of the patch, not only its corners: a ceiling has all its corners on its walls. + const holds = patch.every((t) => { + const [a, b, c] = [index[t * 3] ?? 0, index[t * 3 + 1] ?? 0, index[t * 3 + 2] ?? 0]; + const x = (v: number) => positions[v * 3] ?? 0; + const y = (v: number) => positions[v * 3 + 1] ?? 0; + const ux = x(b) - x(a); + const uy = y(b) - y(a); + const vx = x(c) - x(a); + const vy = y(c) - y(a); + const nx = uy * (z(c) - z(a)) - (z(b) - z(a)) * vy; + const ny = (z(b) - z(a)) * vx - ux * (z(c) - z(a)); + const nz = ux * vy - uy * vx; + const level = -nz > LEVEL * Math.hypot(nx, ny, nz); + const points = [...spread(Math.ceil(Math.abs(nz) / 2 / (reach / 2) ** 2)), ...CORNERS]; + return points.every(([wa, wb, wc]) => { + const px = wa * x(a) + wb * x(b) + wc * x(c); + const py = wa * y(a) + wb * y(b) + wc * y(c); + return near(px, py) || (level && bridged(px, py)); + }); + }); if (holds) for (const t of patch) small.add(t); } return small; diff --git a/packages/geometry/src/printCheck.ts b/packages/geometry/src/printCheck.ts index b67cf4df..3253328a 100644 --- a/packages/geometry/src/printCheck.ts +++ b/packages/geometry/src/printCheck.ts @@ -10,7 +10,8 @@ import type { ViewerModel } from './viewerModel'; * whether the part fits the printer, walls the nozzle cannot print, bodies that touch, how much of * the part lies on the bed, and features SolidCraft could not build. Overhangs are a hint by angle * only (yellow, never red): whether something needs support, and how, is the slicer's job; small - * ones that hang from the material below, as the slicers ignore them, are left out. + * ones that hang from the material below, as the slicers ignore them, and ceilings the printer + * bridges are left out. * The part is checked as designed, Z up, or lying on its print face where SolidCraft has one for the * body (the slicer export lays it down so). Each body stands on its own lowest point: the slicer lays * every object of the 3MF onto the bed by itself (seen in Bambu Studio, 2026-11-05), so bodies that From 79fcd85cae1236fd225c8974cd252ba5a3143e7c Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:32:34 +0100 Subject: [PATCH 05/24] fix(geometry): the print check blocks a body that is an open shell Audit N-druckpruefung-2: SolidCraft's surface bodies (closed false, volume 0) ran through the check as printable parts: four walls without a floor gave only "0 mm2 on the bed", a cube with its top face removed only "bodies touch", and a mesh of five faces read ready. The render mesh keeps the count of mesh edges with one triangle, which the edge derivation already had. A body with any is reported as open, blocks, and is left out of the other checks. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/print/printCheckNote.ts | 2 ++ apps/desktop/src/renderer/src/i18n/de.json | 1 + apps/desktop/src/renderer/src/i18n/en.json | 1 + .../src/renderer/src/print/PrintCheckChip.tsx | 2 ++ apps/desktop/src/renderer/src/print/report.ts | 2 +- apps/desktop/src/shared/contract.ts | 1 + apps/desktop/test/printCheckNote.test.ts | 6 ++++++ packages/geometry/src/printCheck.test.ts | 20 +++++++++++++++++-- packages/geometry/src/printCheck.ts | 10 ++++++++-- packages/geometry/src/renderMesh.ts | 5 ++++- packages/geometry/src/viewerModel.ts | 3 +++ 11 files changed, 47 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/print/printCheckNote.ts b/apps/desktop/src/main/print/printCheckNote.ts index e443ba7f..ce61836a 100644 --- a/apps/desktop/src/main/print/printCheckNote.ts +++ b/apps/desktop/src/main/print/printCheckNote.ts @@ -38,6 +38,8 @@ function line(finding: Finding, nozzle: number): string { return `- ${tag}: only ${String(finding.area)} mm² lie on the bed, the part may come loose or tip over${where(finding.places)}.`; case 'separate': return `- ${tag}: bodies ${finding.bodies.map((body) => JSON.stringify(body)).join(', ')} touch but are separate; the slicer lays each on the bed by itself. Combine them if they are one part.`; + case 'open': + return `- ${tag}: ${finding.bodies.map((body) => `body ${JSON.stringify(body)}`).join(', ')} is an open surface, not a closed solid: it has no volume, so nothing of it prints. Close it into a solid, or remove it if it only helped to build.`; case 'feature': return ( `- ${tag}: feature ${JSON.stringify(finding.feature)} reports ${JSON.stringify(finding.message.slice(0, 200))}.` + diff --git a/apps/desktop/src/renderer/src/i18n/de.json b/apps/desktop/src/renderer/src/i18n/de.json index 5934b70c..6b7297c0 100644 --- a/apps/desktop/src/renderer/src/i18n/de.json +++ b/apps/desktop/src/renderer/src/i18n/de.json @@ -1123,6 +1123,7 @@ "floorThin": "Böden oder Deckel nur {thickness} mm, mindestens {minimum} mm (vier Schichten)", "overhang": "{area} mm² neigen sich über {limit}°: Ob sie Stütze brauchen, entscheidet der Slicer", "contact": "Nur {area} mm² von {body} liegen auf dem Druckbett: kann sich lösen oder kippen", + "open": "{count, plural, one {{bodies} ist kein geschlossener Körper} other {{bodies} sind keine geschlossenen Körper}}: eine offene Fläche ohne Volumen wird nicht gedruckt", "separate": "{bodies} berühren sich, sind aber getrennte Körper: Der Slicer legt jeden für sich aufs Druckbett. Vereinige sie, wenn sie ein Teil sind.", "feature": "{feature}: {message}", "overlap": "{a} und {b} überschneiden sich um {volume} mm³ – als getrennte Teile passen sie so nicht zusammen; gehören sie zu einem Teil, vereinen", diff --git a/apps/desktop/src/renderer/src/i18n/en.json b/apps/desktop/src/renderer/src/i18n/en.json index 1186686c..8380d3da 100644 --- a/apps/desktop/src/renderer/src/i18n/en.json +++ b/apps/desktop/src/renderer/src/i18n/en.json @@ -1123,6 +1123,7 @@ "floorThin": "Floors or lids only {thickness} mm, at least {minimum} mm (four layers)", "overhang": "{area} mm² lean beyond {limit}°: the slicer decides whether they need support", "contact": "Only {area} mm² of {body} lie on the bed: may come loose or tip over", + "open": "{count, plural, one {{bodies} is no closed solid} other {{bodies} are no closed solids}}: an open surface without volume does not print", "separate": "{bodies} touch but are separate bodies: the slicer lays each on the bed by itself. Combine them if they are one part.", "feature": "{feature}: {message}", "overlap": "{a} and {b} overlap by {volume} mm³ – as separate parts they do not go together; combine them if they are one part", diff --git a/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx b/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx index 89d5d523..4da73627 100644 --- a/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx +++ b/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx @@ -34,6 +34,8 @@ export function useFindingText(): (finding: PrintFinding) => string { return t('check.finding.overhang', { area: number.format(finding.area), limit: finding.limit }); case 'contact': return t('check.finding.contact', { area: number.format(finding.area), body: finding.places[0]?.body ?? '' }); + case 'open': + return t('check.finding.open', { count: finding.bodies.length, bodies: new Intl.ListFormat(i18n.language, { type: 'conjunction' }).format(finding.bodies) }); case 'separate': return t('check.finding.separate', { bodies: new Intl.ListFormat(i18n.language, { type: 'conjunction' }).format(finding.bodies) }); case 'feature': diff --git a/apps/desktop/src/renderer/src/print/report.ts b/apps/desktop/src/renderer/src/print/report.ts index 16b55479..baea7c71 100644 --- a/apps/desktop/src/renderer/src/print/report.ts +++ b/apps/desktop/src/renderer/src/print/report.ts @@ -14,7 +14,7 @@ export function reportOf(result: PrintCheck, nozzle: number, revision: number): // SolidCraft measures pairs of bodies, not faces: the pair is where it is. if (finding.kind === 'overlap') return { kind: finding.kind, severity: finding.severity, bodies: finding.bodies, volume: finding.volume, places: [] }; if (finding.kind === 'tight') return { kind: finding.kind, severity: finding.severity, bodies: finding.bodies, gap: finding.gap, minimum: finding.minimum, places: [] }; - if (finding.kind === 'separate') return { ...finding, bodies: finding.bodies.slice(0, 20) }; + if (finding.kind === 'separate' || finding.kind === 'open') return { ...finding, bodies: finding.bodies.slice(0, 20) }; const places = finding.places.slice(0, 20).map((place) => ({ body: place.body, faces: place.faces.slice(0, 40) })); switch (finding.kind) { case 'thinWall': diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index 1d07e1fe..574fc3d4 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -1149,6 +1149,7 @@ export const printFindingSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('contact'), severity: findingSeveritySchema, area: finite.nonnegative(), places: findingPlacesSchema }).strict(), z.object({ kind: z.literal('feature'), severity: findingSeveritySchema, feature: z.string().max(200), message: z.string().max(500) }).strict(), z.object({ kind: z.literal('separate'), severity: findingSeveritySchema, bodies: z.array(z.string().max(200)).min(2).max(20) }).strict(), + z.object({ kind: z.literal('open'), severity: findingSeveritySchema, bodies: z.array(z.string().max(200)).min(1).max(20) }).strict(), z.object({ kind: z.literal('overlap'), severity: findingSeveritySchema, bodies: z.tuple([bodyName, bodyName]), volume: finite.nonnegative(), places: findingPlacesSchema }).strict(), z.object({ kind: z.literal('tight'), severity: findingSeveritySchema, bodies: z.tuple([bodyName, bodyName]), gap: mmValue, minimum: mmValue, places: findingPlacesSchema }).strict(), z.object({ kind: z.literal('blocked'), severity: findingSeveritySchema, joint: z.string().max(200), bodies: z.tuple([bodyName, bodyName]), at: finite, limit: finite, unit: z.enum(['deg', 'mm']), places: findingPlacesSchema }).strict(), diff --git a/apps/desktop/test/printCheckNote.test.ts b/apps/desktop/test/printCheckNote.test.ts index 6ee9221a..5f413bd4 100644 --- a/apps/desktop/test/printCheckNote.test.ts +++ b/apps/desktop/test/printCheckNote.test.ts @@ -44,6 +44,12 @@ describe('the ready-to-print check as the assistant reads it (M7)', () => { expect(blocked).toContain('blocks printing: floors or lids down to 0.1 mm, thinner than one layer, so they do not print'); }); + it('says that an open surface does not print', () => { + const report = reportOf({ status: 'block', findings: [{ kind: 'open', severity: 'block', bodies: ['Tube'] }] }, 0.4, 1); + expect(printCheckReportSchema.safeParse(report).success).toBe(true); + expect(printCheckNote(report)).toContain('blocks printing: body "Tube" is an open surface, not a closed solid'); + }); + it('names the body that rests on an edge and has no face on the bed', () => { const note = printCheckNote({ status: 'warn', nozzle: 0.4, revision: 1, findings: [{ kind: 'contact', severity: 'warn', area: 0, places: [{ body: 'Ball', faces: [] }] }] }); expect(note).toContain('only 0 mm² lie on the bed, the part may come loose or tip over (body "Ball").'); diff --git a/packages/geometry/src/printCheck.test.ts b/packages/geometry/src/printCheck.test.ts index ca136554..2421858a 100644 --- a/packages/geometry/src/printCheck.test.ts +++ b/packages/geometry/src/printCheck.test.ts @@ -13,7 +13,7 @@ import { buildViewerModel } from './viewerModel'; const PLA: PrintCheckProfile = { bed: [256, 256, 256], nozzle: 0.4, layer: 0.2, overhang: 45 }; /** A box as SolidCraft would export it: six faces, outward triangles, named like `F1:+z`. */ -function box(name: string, min: [number, number, number], max: [number, number, number]): PrintCheckBody { +function box(name: string, min: [number, number, number], max: [number, number, number], lid = true): PrintCheckBody { const [x0, y0, z0] = min; const [x1, y1, z1] = max; const corners = [ @@ -23,6 +23,11 @@ function box(name: string, min: [number, number, number], max: [number, number, // Quads counter-clockwise seen from outside: -z, +z, -y, +x, +y, -x. const quads = [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]]; const names = ['F1:-z', 'F1:+z', 'F1:-y', 'F1:+x', 'F1:+y', 'F1:-x']; + // Without its top face the box is an open shell, as a surface body comes from SolidCraft. + if (!lid) { + quads.splice(1, 1); + names.splice(1, 1); + } const indices: number[] = []; const triangleFaces: number[] = []; quads.forEach(([a, b, c, d], face) => { @@ -31,7 +36,7 @@ function box(name: string, min: [number, number, number], max: [number, number, }); const mesh: TaggedMesh = { name, - faceCount: 6, + faceCount: names.length, positions: Float64Array.from(corners.flat()), indices: Uint32Array.from(indices), triangleFaces: Uint32Array.from(triangleFaces), @@ -153,6 +158,17 @@ describe('the ready-to-print check (M7)', () => { expect(apart.findings.find((f) => f.kind === 'separate')).toMatchObject({ severity: 'warn', bodies: ['Leg', 'Top'] }); }); + it('blocks a body that is an open shell, and says nothing else about it', () => { + // Audit N-druckpruefung-2: a cube without its top face (SolidCraft: closed false, volume 0) read ready. + const shell = box('Shell', [0, 0, 0], [20, 20, 20], false); + expect(shell.model.openEdges).toBe(4); + expect(checkPrint([shell], PLA)).toEqual({ status: 'block', findings: [{ kind: 'open', severity: 'block', bodies: ['Shell'] }] }); + // The closed bodies beside it are still checked, and a closed one alone has no such finding. + const beside = checkPrint([shell, box('Rod', [30, 0, 0], [40, 10, 300])], PLA).findings; + expect(beside.map((f) => f.kind)).toEqual(['open', 'size']); + expect(box('Cube', [0, 0, 0], [20, 20, 20]).model.openEdges).toBe(0); + }); + it('looks at what lies on the bed body by body: a broad one does not hold one standing on an edge', () => { // Audit D3: 400 mm² of a cube hid a second body with nothing on the bed. const cube = box('Cube', [20, 0, 0], [40, 20, 20]); diff --git a/packages/geometry/src/printCheck.ts b/packages/geometry/src/printCheck.ts index 3253328a..6e9f55f0 100644 --- a/packages/geometry/src/printCheck.ts +++ b/packages/geometry/src/printCheck.ts @@ -8,7 +8,7 @@ import type { ViewerModel } from './viewerModel'; * The "ready to print" check (M7, ADR 0010 as amended on 2026-11-05 for plan step R1): pure * TypeScript on the mirrored render meshes, no sidecar. It checks what only the design decides: * whether the part fits the printer, walls the nozzle cannot print, bodies that touch, how much of - * the part lies on the bed, and features SolidCraft could not build. Overhangs are a hint by angle + * the part lies on the bed, bodies that are no closed solid, and features SolidCraft could not build. Overhangs are a hint by angle * only (yellow, never red): whether something needs support, and how, is the slicer's job; small * ones that hang from the material below, as the slicers ignore them, and ceilings the printer * bridges are left out. @@ -70,6 +70,8 @@ export type PrintFinding = | ({ kind: 'overhang'; severity: FindingSeverity; area: number; limit: number } & Located) | ({ kind: 'contact'; severity: FindingSeverity; area: number } & Located) | { kind: 'separate'; severity: FindingSeverity; bodies: string[] } + /** Bodies that are open shells (SolidCraft's surface bodies): no solid to print. */ + | { kind: 'open'; severity: FindingSeverity; bodies: string[] } | { kind: 'feature'; severity: FindingSeverity; feature: string; message: string } /** Two bodies share volume: as separate parts they do not go together (F2, SolidCraft's measure). */ | ({ kind: 'overlap'; severity: FindingSeverity; bodies: [string, string]; volume: number } & Located) @@ -430,7 +432,11 @@ export function checkPrint(bodies: readonly PrintCheckBody[], profile: PrintChec else if (feature.warning) findings.push({ kind: 'feature', severity: 'warn', feature: feature.name, message: feature.warning }); } - const parts = bodies.filter((body) => body.model.table.faces.length > 0); + // A surface body has no volume to fill: nothing else about it is worth a finding. + const shells = bodies.filter((body) => body.model.table.faces.length > 0); + const open = shells.filter((body) => body.model.openEdges > 0); + if (open.length > 0) findings.push({ kind: 'open', severity: 'block', bodies: open.map((body) => body.name) }); + const parts = shells.filter((body) => body.model.openEdges === 0); if (parts.length > 0) { const meshes = parts.map(laid); // Each body against the plate by itself: the slicer places every one, on more plates if need be. diff --git a/packages/geometry/src/renderMesh.ts b/packages/geometry/src/renderMesh.ts index 0f4bc73c..6196443e 100644 --- a/packages/geometry/src/renderMesh.ts +++ b/packages/geometry/src/renderMesh.ts @@ -25,6 +25,8 @@ export interface RenderMesh { edgeLengths: Float32Array; /** B-rep vertices (ends of open edges, each once), xyz per vertex. */ vertices: Float32Array; + /** Mesh edges with a triangle on one side only: above 0 the body is an open shell, not a solid. */ + openEdges: number; } export function buildRenderMesh(mesh: TaggedMesh, table: FaceTable): RenderMesh { @@ -48,7 +50,7 @@ export function buildRenderMesh(mesh: TaggedMesh, table: FaceTable): RenderMesh index[i] = mapped; } - const { edges } = deriveEdges(mesh, (a, b) => { + const { edges, openEdges } = deriveEdges(mesh, (a, b) => { const fa = table.faces[a]; const fb = table.faces[b]; return fa !== undefined && fb !== undefined && sameCylinder(fa, fb); @@ -80,6 +82,7 @@ export function buildRenderMesh(mesh: TaggedMesh, table: FaceTable): RenderMesh edgeRanges, edgeLengths, vertices: Float32Array.from([...corners.values()].flat()), + openEdges, }; } diff --git a/packages/geometry/src/viewerModel.ts b/packages/geometry/src/viewerModel.ts index 445bfa13..eb963a04 100644 --- a/packages/geometry/src/viewerModel.ts +++ b/packages/geometry/src/viewerModel.ts @@ -26,6 +26,8 @@ export interface ViewerModel { edgeLengths: Float32Array; /** B-rep vertices, xyz per vertex. */ vertices: Float32Array; + /** Mesh edges with a triangle on one side only: above 0 the body is an open shell, not a solid. */ + openEdges: number; } /** Wraps render buffers into three.js geometry with a BVH (runs once per mirrored revision). */ @@ -61,6 +63,7 @@ export function buildViewerModel(mesh: RenderMesh, table: FaceTable): ViewerMode edgeRanges: mesh.edgeRanges, edgeLengths: mesh.edgeLengths, vertices: mesh.vertices, + openEdges: mesh.openEdges, }; } From ad4a9b339eb8f2689d96cca57bacd9f69935b688 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:57:07 +0100 Subject: [PATCH 06/24] fix(geometry): a triangle drawn to a line does not make a closed body read as open Review of the print check: a degenerate triangle (a, a, b) left an edge from a to itself that no other triangle closes, so the count of open edges was above 0 and the check blocked a closed solid as an open shell. Such an edge is skipped. Measured on SolidCraft 0.40.1: a sphere (25504 triangles), a cone with a tip (5242) and a cube with a modelled M5 thread (8918) carry no such triangle and no open edge. Co-Authored-By: Claude Fable 5.2 --- packages/geometry/src/edges.ts | 2 ++ packages/geometry/src/geometry.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/packages/geometry/src/edges.ts b/packages/geometry/src/edges.ts index 6b203a55..4bcd5c52 100644 --- a/packages/geometry/src/edges.ts +++ b/packages/geometry/src/edges.ts @@ -60,6 +60,8 @@ export function deriveEdges( for (let k = 0; k < 3; k += 1) { const a = mesh.indices[t * 3 + k] ?? 0; const b = mesh.indices[t * 3 + ((k + 1) % 3)] ?? 0; + // A triangle drawn to a line has an edge from a vertex to itself, which no other triangle closes. + if (a === b) continue; const key = a < b ? `${a}:${b}` : `${b}:${a}`; const first = owner.get(key); if (first === undefined) { diff --git a/packages/geometry/src/geometry.test.ts b/packages/geometry/src/geometry.test.ts index 6908e5e3..be3eb541 100644 --- a/packages/geometry/src/geometry.test.ts +++ b/packages/geometry/src/geometry.test.ts @@ -119,6 +119,14 @@ function cube(): TaggedMesh { } describe('B-rep edges and vertices', () => { + it('does not take a triangle drawn to a line for a hole in the shell', () => { + // (a, a, b) has an edge from a to itself, which no other triangle closes: a closed cube read as open. + const mesh = cube(); + const withSliver = { ...mesh, indices: Uint32Array.from([...mesh.indices, 0, 0, 1]), triangleFaces: Uint32Array.from([...mesh.triangleFaces, 0]) }; + expect(deriveEdges(withSliver).openEdges).toBe(0); + expect(deriveEdges(withSliver).edges).toHaveLength(2); + }); + it('splits the edges between two faces into one chain per connected loop', () => { const { edges, openEdges } = deriveEdges(cube()); expect(openEdges).toBe(0); From b67d3e3229ea24ab6475be78a0be2cef721d235d Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:57:10 +0100 Subject: [PATCH 07/24] fix(geometry): a bridge ends on standing material on both sides, with no free edge between Review of the print check, three faults in what counts as a small overhang: A line through a point of a ceiling took the nearest anchor ahead and behind without asking what lay between. The roof of a blind T-slot (9 mm between the walls, a 3 mm slot down the middle) read as a 9 mm bridge: 0 mm2, where main had 189 mm2. A direction is a bridge only when the nearest rim of the patch on both sides is an anchor; the ring under a counterbore with a through hole is reported too (65 mm2). Any edge whose neighbour goes on downwards was an anchor. A shelf 8 mm out of a wall with a drip edge at its tip read ready, the lip counting as a second wall. An anchor now needs its material to come down, never up, to the bed or to a face that looks up; the shelf is reported with 240 mm2. The assistant was told that lying holes up to about 10 mm are bridged while the check warned from 8 mm. Both take LYING_HOLE = 8 mm now (MakerVerse, the one source of the audit's scale with a size): the roof of a lying hole up to that stays out. The cases stand on closed bodies (a grid of blocks, prisms with clipped ears). Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/print/printProfiles.ts | 4 +- packages/geometry/src/bridges.test.ts | 56 ++++++++ packages/geometry/src/index.ts | 1 + packages/geometry/src/overhang.test.ts | 29 +---- packages/geometry/src/overhang.ts | 130 ++++++++++++++----- packages/geometry/src/testShapes.ts | 74 ++++++++++- 6 files changed, 226 insertions(+), 68 deletions(-) create mode 100644 packages/geometry/src/bridges.test.ts diff --git a/apps/desktop/src/main/print/printProfiles.ts b/apps/desktop/src/main/print/printProfiles.ts index f669b727..9bc4c0e1 100644 --- a/apps/desktop/src/main/print/printProfiles.ts +++ b/apps/desktop/src/main/print/printProfiles.ts @@ -15,7 +15,7 @@ import { type PrintSettings, } from '../../shared/contract'; import { fitSize, lineWidth, printInPlaceGap, scaleClearance, unitSize, type FitPrinter } from '../../shared/designFit'; -import { BRIDGE_SPAN, minimumFloor } from '@partbuddy/geometry'; +import { BRIDGE_SPAN, LYING_HOLE, minimumFloor } from '@partbuddy/geometry'; /** * What is kept next to a design: its material, and the design rule groups the user turned on or off @@ -229,7 +229,7 @@ function supportFreeLines(overhang: number): string[] { return [ 'Design it to print without support as designed, standing on its lowest point (the user\'s setting); where that cannot be done, say so and why:', `- Edges that face down towards the bed: chamfer them at 45° instead of filleting (the bottom of a fillet ends flat); fillets that face up or sideways are fine.`, - `- Holes lying across the layers: up to about 10 mm wide the slicer bridges them; wider ones get a teardrop roof (45° to a point above the circle) or a flat roof (the teardrop cut off), so nothing leans more than ${String(overhang)}°.`, + `- Holes lying across the layers: up to ${String(LYING_HOLE)} mm across they print round without support; wider ones get a teardrop roof (45° to a point above the circle) or a flat roof (the teardrop cut off), so nothing leans more than ${String(overhang)}°.`, `- Ceilings and ledges: walls at most ${String(BRIDGE_SPAN)} mm apart are bridged, ledges up to 1.5 mm out from a wall print; anything wider gets a 45° slope underneath, or say which side the part should rather stand on.`, 'Model these as their own features with parameters (layer height, roof angle) and name them when you report.', ]; diff --git a/packages/geometry/src/bridges.test.ts b/packages/geometry/src/bridges.test.ts new file mode 100644 index 00000000..8016fbb5 --- /dev/null +++ b/packages/geometry/src/bridges.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { BRIDGE_SPAN, LYING_HOLE } from './overhang'; +import { checkPrint, type PrintCheckBody, type PrintCheckProfile } from './printCheck'; +import { gridBody, lyingTube, prismBody } from './testShapes'; + +const PLA: PrintCheckProfile = { bed: [256, 256, 256], nozzle: 0.4, layer: 0.2, overhang: 45 }; + +describe('bridges, arms and what they hang from, on closed bodies', () => { + /** The area (mm²) reported as overhang, and the faces. */ + const hanging = (body: PrintCheckBody) => { + const finding = checkPrint([body], PLA).findings.find((f) => f.kind === 'overhang'); + return finding?.kind === 'overhang' ? { area: finding.area, faces: finding.places.flatMap((place) => place.faces) } : { area: 0, faces: [] }; + }; + + it('leaves a bridge of up to 10 mm between two walls out, and keeps a wider one, a ceiling and an arm', () => { + // Two walls 2 mm thick, `span` apart, 30 deep and 8 high, under a roof 2 mm thick. + const portal = (span: number) => gridBody('Portal', [0, 2, 2 + span, 4 + span], [0, 30], [0, 8, 10], (i, _j, k) => k === 1 || i !== 1); + expect(BRIDGE_SPAN).toBe(10); + expect([3, 5.97, 10].map((span) => hanging(portal(span)).area)).toEqual([0, 0, 0]); + // Audit MG-01, N-mechanik-1: every corner of these lies on a wall, so they counted as small + // (the ceilings of a guide's groove 13.9, 27.9 and 101.9 mm wide). + expect([13.9, 27.9, 101.9].map((span) => hanging(portal(span)).area)).toEqual([417, 837, 3057]); + // An arm held on one side only is no bridge. + expect(hanging(gridBody('Arm', [0, 2, 42], [0, 30], [0, 8, 10], (i, _j, k) => k === 1 || i === 0)).area).toBe(1200); + }); + + it('takes no bridge across a slot or a hole in the ceiling: what ends at a free edge is an arm', () => { + // The roof of a blind T-slot: 9 mm between the walls, a slot 3 mm wide down the middle, joined at the back. + // Two arms 3 mm out; a line across them met a wall on both sides and read as a 9 mm bridge. + const slotted = (slot: boolean) => gridBody('Slot', [0, 2, 5, 8, 11, 13], [0, 27, 30, 32], [0, 8, 10], (i, j, k) => (k === 1 ? !(slot && i === 2 && j === 0) : i === 0 || i === 4 || j === 2)); + expect(hanging(slotted(true))).toEqual({ area: 189, faces: ['F1:-z@8'] }); + expect(hanging(slotted(false)).area).toBe(0); + // The ring a counterbore leaves under a through hole: 9 mm wide with a hole of 4 mm. + const ring = (hole: boolean) => gridBody('Bore', [0, 2, 4.5, 8.5, 11, 13], [0, 2, 4.5, 8.5, 11, 13], [0, 8, 10], (i, j, k) => (k === 1 ? !(hole && i === 2 && j === 2) : i === 0 || i === 4 || j === 0 || j === 4)); + expect(hanging(ring(true)).area).toBe(65); + expect(hanging(ring(false)).area).toBe(0); + }); + + it('hangs nothing from material that itself ends in the air', () => { + // A shelf 8 mm out of a wall with a drip edge at its tip: the lip going down from the far edge + // counted as a second wall, and the shelf as bridged. + expect(hanging(prismBody('Shelf', [[0, 0], [5, 0], [5, 16], [13, 16], [13, 14], [15, 16], [15, 20], [0, 20]], 30))).toEqual({ area: 240, faces: ['F1:s2'] }); + expect(hanging(prismBody('Shelf', [[0, 0], [5, 0], [5, 16], [15, 16], [15, 20], [0, 20]], 30)).area).toBe(300); + // A wall that stands on the floor of a box holds a bridge as one on the bed does. + const tray = gridBody('Tray', [0, 2, 10, 12], [0, 30], [0, 2, 8, 10], (i, _j, k) => k !== 1 || i !== 1); + expect(hanging(tray).area).toBe(0); + }); + + it('lets a hole lying across the layers print round up to 8 mm, as the assistant is told', () => { + expect(LYING_HOLE).toBe(8); + expect(hanging(lyingTube('Tube', 10, 4, 10, 96)).faces).not.toContain('F1:hole'); + expect(hanging(lyingTube('Tube', 10, 4.5, 10, 96)).faces).toContain('F1:hole'); + expect(hanging(lyingTube('Tube', 10, 1.5, 10, 96)).faces).not.toContain('F1:hole'); + }); +}); diff --git a/packages/geometry/src/index.ts b/packages/geometry/src/index.ts index bd7af646..4e1c8d2b 100644 --- a/packages/geometry/src/index.ts +++ b/packages/geometry/src/index.ts @@ -1,6 +1,7 @@ export { meshMetrics, overhangFindings, type MeshMetrics, type OverhangFinding } from './analysis'; export { BRIDGE_SPAN, + LYING_HOLE, Overhang, OVERHANG_LIMITS, overhangLimits, diff --git a/packages/geometry/src/overhang.test.ts b/packages/geometry/src/overhang.test.ts index fadfdb2c..6ea4c5db 100644 --- a/packages/geometry/src/overhang.test.ts +++ b/packages/geometry/src/overhang.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { BRIDGE_SPAN, Overhang, OVERHANG_LIMITS, overhangLimits, overhangOf, smallOverhangReach } from './overhang'; +import { Overhang, overhangLimits, overhangOf } from './overhang'; /** One square face of side 10 per entry, as render buffers: corners, normal and face index. */ function faces(list: { corners: number[][]; normal: number[] }[]) { @@ -57,33 +57,6 @@ describe('overhangs for FDM printing (UI upgrade step 12)', () => { expect([map.vertices[0], map.vertices[4], map.vertices[8]]).toEqual([Overhang.Borderline, Overhang.Support, Overhang.Fine]); }); - it('leaves a bridge of up to 10 mm between two walls out, and keeps a wider one, a ceiling and an arm', () => { - // A ceiling at z 8, 30 deep, over the inner faces of the walls named: at x 0, at x `span`, at y 0 and y 30. - const roof = (span: number, walls: ('left' | 'right' | 'front' | 'back')[]) => { - const inner = { - left: { corners: [[0, 0, 0], [0, 30, 0], [0, 30, 8], [0, 0, 8]], normal: [1, 0, 0] }, - right: { corners: [[span, 0, 0], [span, 0, 8], [span, 30, 8], [span, 30, 0]], normal: [-1, 0, 0] }, - front: { corners: [[0, 0, 0], [0, 0, 8], [span, 0, 8], [span, 0, 0]], normal: [0, 1, 0] }, - back: { corners: [[0, 30, 0], [span, 30, 0], [span, 30, 8], [0, 30, 8]], normal: [0, -1, 0] }, - }; - const mesh = faces([{ corners: [[0, 0, 8], [0, 30, 8], [span, 30, 8], [span, 0, 8]], normal: [0, 0, -1] }, ...walls.map((wall) => inner[wall])]); - return Math.round(overhangOf(mesh, 0, OVERHANG_LIMITS, smallOverhangReach(0.4)).area[Overhang.Support]); - }; - expect(BRIDGE_SPAN).toBe(10); - // Within reach of both walls, and bridged. - expect(roof(3, ['left', 'right'])).toBe(0); - expect(roof(8, ['left', 'right'])).toBe(0); - expect(roof(10, ['left', 'right'])).toBe(0); - // Audit MG-01, N-mechanik-1: every corner of these lies on a wall, so they counted as small - // (a bridge of 40 mm, the 102 mm ceiling of a guide's groove, the ceiling of a box). - expect(roof(12, ['left', 'right'])).toBe(360); - expect(roof(40, ['left', 'right'])).toBe(1200); - expect(roof(102, ['left', 'right'])).toBe(3060); - expect(roof(40, ['left', 'right', 'front', 'back'])).toBe(1200); - // An arm held on one side only is no bridge, however short. - expect(roof(8, ['left'])).toBe(240); - }); - it('moves the limits with the material (M7): PETG at 40° needs support at 45°, borderline from 30°', () => { const at = (degrees: number) => -Math.sin((degrees * Math.PI) / 180); const slope = (degrees: number, z: number) => ({ diff --git a/packages/geometry/src/overhang.ts b/packages/geometry/src/overhang.ts index 5c650903..f1352d16 100644 --- a/packages/geometry/src/overhang.ts +++ b/packages/geometry/src/overhang.ts @@ -102,20 +102,30 @@ const WELD = 1e-4; */ export const BRIDGE_SPAN = 10; +/** + * Up to this diameter (mm) a hole lying across the layers prints round without support, slightly + * oval (MakerVerse, FDM Design Guide; the only source of the audit's scale that names a size). The + * assistant designs to the same number (printProfiles.ts). + */ +export const LYING_HOLE = 8; + /** A face that points down this straight (−z of its unit normal) is a ceiling that can be bridged. */ const LEVEL = 0.99; +/** A face that looks up at least this much (z of its unit normal) has material under it. */ +const UP = 0.1; const CORNERS: [number, number, number][] = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; /** * Overhang triangles that are small: each connected patch whose every point lies within `reach` * (mm, across the bed) of an edge where the material goes on below, the way a ledge or a thread - * flank hangs from its wall, or, on a flat ceiling, between two such edges no more than - * `BRIDGE_SPAN` apart. A patch with no such edge starts in the air, and a patch reaching further - * (a ceiling, a wide bridge, a cantilevered arm) needs support; both stay. + * flank hangs from its wall, or between two such edges that are the patch's nearest rims on both + * sides and no more than a span apart: `BRIDGE_SPAN` under a flat ceiling, `arch` under the roof of + * a lying hole. A patch with no such edge starts in the air, and a patch reaching further (a + * ceiling, a wide bridge, a cantilevered arm, a lip beside a slot) needs support; both stay. * shortcut: point-to-edge distances are compared pairwise per patch; fine for the parts PartBuddy * builds, upgrade to a spatial grid if a check of a large mesh gets slow. */ -function smallPatches(positions: ArrayLike, index: ArrayLike, support: readonly number[], reach: number, bedZ: number): Set { +function smallPatches(positions: ArrayLike, index: ArrayLike, support: readonly number[], reach: number, arch: number, bedZ: number): Set { const small = new Set(); if (support.length === 0) return small; const key = (v: number) => `${String(Math.round((positions[v * 3] ?? 0) / WELD))},${String(Math.round((positions[v * 3 + 1] ?? 0) / WELD))},${String(Math.round((positions[v * 3 + 2] ?? 0) / WELD))}`; @@ -133,9 +143,23 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, su const kb = keyOf(b); return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; }; - // Every edge of the body with the triangles on it and their third corner. + const x = (v: number) => positions[v * 3] ?? 0; + const y = (v: number) => positions[v * 3 + 1] ?? 0; + const z = (v: number) => positions[v * 3 + 2] ?? 0; + /** The normal of a triangle, not of unit length. */ + const normalOf = (t: number): [number, number, number] => { + const [a, b, c] = [index[t * 3] ?? 0, index[t * 3 + 1] ?? 0, index[t * 3 + 2] ?? 0]; + const [ux, uy, uz, vx, vy, vz] = [x(b) - x(a), y(b) - y(a), z(b) - z(a), x(c) - x(a), y(c) - y(a), z(c) - z(a)]; + return [uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx]; + }; + // Every edge of the body with the triangles on it and their third corner; per vertex the vertices + // one edge away, and whether a face that looks up meets it. const edges = new Map(); + const links = new Map(); + const looksUp = new Set(); for (let t = 0; t < index.length / 3; t += 1) { + const [nx, ny, nz] = normalOf(t); + const up = nz > UP * Math.hypot(nx, ny, nz); for (let k = 0; k < 3; k += 1) { const a = index[t * 3 + k] ?? 0; const b = index[t * 3 + ((k + 1) % 3)] ?? 0; @@ -144,16 +168,46 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, su const list = edges.get(id); if (list) list.push({ triangle: t, third }); else edges.set(id, [{ triangle: t, third }]); + const near = links.get(keyOf(a)); + if (near) near.push(b, third); + else links.set(keyOf(a), [b, third]); + if (up) looksUp.add(keyOf(a)); } } + // Whether the material at a vertex stands on something: going down along the mesh, never up, it + // comes to the bed or to a face that looks up (a floor, the flank below). The tip of a drip edge + // under a shelf ends in the air instead. + // shortcut: a hook whose own back looks up counts as standing; follow the material below the + // face that looks up if such a shape must be told apart. + const stands = new Map(); + const grounded = (start: number): boolean => { + const known = stands.get(keyOf(start)); + if (known !== undefined) return known; + const visited = new Set([keyOf(start)]); + const stack = [start]; + let found = false; + while (stack.length > 0 && !found) { + const v = stack.pop() ?? 0; + if (z(v) <= bedZ + BED_TOLERANCE || looksUp.has(keyOf(v))) found = true; + else + for (const next of links.get(keyOf(v)) ?? []) { + if (z(next) > z(v) + WELD || visited.has(keyOf(next))) continue; + visited.add(keyOf(next)); + stack.push(next); + } + } + stands.set(keyOf(start), found); + return found; + }; const isSupport = new Set(support); const seen = new Set(); - const z = (v: number) => positions[v * 3 + 2] ?? 0; for (const start of support) { if (seen.has(start)) continue; // One patch: support triangles joined by edges. const patch: number[] = []; const anchors: [number, number][] = []; + // Every edge where the patch ends, and whether it is an anchor. + const rim: [number, number, boolean][] = []; const queue = [start]; seen.add(start); while (queue.length > 0) { @@ -169,10 +223,12 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, su seen.add(other.triangle); queue.push(other.triangle); } - } else if (z(other.third) < Math.min(z(a), z(b)) - WELD || Math.max(z(a), z(b)) <= bedZ + BED_TOLERANCE) { - // The neighbour goes on downwards from this edge, or the edge lies on the bed: the - // patch hangs from material here. - anchors.push([a, b]); + } else { + // The neighbour goes on downwards from this edge to where it stands, or the edge lies + // on the bed: the patch hangs from material here. + const anchor = (z(other.third) < Math.min(z(a), z(b)) - WELD && grounded(other.third)) || Math.max(z(a), z(b)) <= bedZ + BED_TOLERANCE; + if (anchor) anchors.push([a, b]); + rim.push([a, b, anchor]); } } } @@ -190,49 +246,51 @@ function smallPatches(positions: ArrayLike, index: ArrayLike, su } return false; }; - // A flat ceiling is bridged where a straight line through the point meets material on both - // sides within the bridge span. - const bridged = (px: number, py: number) => { + // A point is bridged where a straight line through it leaves the patch over an anchor on both + // sides, within the span. A free edge or a hole on the way makes that side an arm, not a bridge. + const bridged = (px: number, py: number, span: number) => { for (let turn = 0; turn < 12; turn += 1) { const dx = Math.cos((turn * Math.PI) / 12); const dy = Math.sin((turn * Math.PI) / 12); - let ahead = Infinity; - let behind = Infinity; - for (const [a, b] of anchors) { - const ax = (positions[a * 3] ?? 0) - px; - const ay = (positions[a * 3 + 1] ?? 0) - py; - const ex = (positions[b * 3] ?? 0) - (positions[a * 3] ?? 0); - const ey = (positions[b * 3 + 1] ?? 0) - (positions[a * 3 + 1] ?? 0); + // Distance to the nearest rim ahead and behind, and whether it is free. + const ends = [ + { at: Infinity, free: false }, + { at: Infinity, free: false }, + ]; + for (const [a, b, anchor] of rim) { + const ax = x(a) - px; + const ay = y(a) - py; + const ex = x(b) - x(a); + const ey = y(b) - y(a); const cross = dx * ey - dy * ex; if (Math.abs(cross) < 1e-12) continue; const s = (ax * dy - ay * dx) / cross; if (s < -1e-6 || s > 1 + 1e-6) continue; const along = (ax * ey - ay * ex) / cross; - if (along >= 0) ahead = Math.min(ahead, along); - else behind = Math.min(behind, -along); + const end = ends[along >= 0 ? 0 : 1]; + if (!end) continue; + const at = Math.abs(along); + // Two rims met at one point: a free one among them decides. + if (Math.abs(at - end.at) <= WELD) end.free ||= !anchor; + else if (at < end.at) { + end.at = at; + end.free = !anchor; + } } - if (ahead + behind <= BRIDGE_SPAN + WELD) return true; + if (ends.every((end) => !end.free) && (ends[0]?.at ?? Infinity) + (ends[1]?.at ?? Infinity) <= span + WELD) return true; } return false; }; // Every point of the patch, not only its corners: a ceiling has all its corners on its walls. const holds = patch.every((t) => { const [a, b, c] = [index[t * 3] ?? 0, index[t * 3 + 1] ?? 0, index[t * 3 + 2] ?? 0]; - const x = (v: number) => positions[v * 3] ?? 0; - const y = (v: number) => positions[v * 3 + 1] ?? 0; - const ux = x(b) - x(a); - const uy = y(b) - y(a); - const vx = x(c) - x(a); - const vy = y(c) - y(a); - const nx = uy * (z(c) - z(a)) - (z(b) - z(a)) * vy; - const ny = (z(b) - z(a)) * vx - ux * (z(c) - z(a)); - const nz = ux * vy - uy * vx; - const level = -nz > LEVEL * Math.hypot(nx, ny, nz); + const [nx, ny, nz] = normalOf(t); + const span = -nz > LEVEL * Math.hypot(nx, ny, nz) ? BRIDGE_SPAN : arch; const points = [...spread(Math.ceil(Math.abs(nz) / 2 / (reach / 2) ** 2)), ...CORNERS]; return points.every(([wa, wb, wc]) => { const px = wa * x(a) + wb * x(b) + wc * x(c); const py = wa * y(a) + wb * y(b) + wc * y(c); - return near(px, py) || (level && bridged(px, py)); + return near(px, py) || bridged(px, py, span); }); }); if (holds) for (const t of patch) small.add(t); @@ -282,7 +340,9 @@ export function overhangOf(mesh: OverhangInput, bedZ: number, limits: OverhangLi triangles.push({ kind: length === 0 ? Overhang.Fine : onBed(face) ? Overhang.Bed : classOf(-nz / length), face, area: length / 2 }); } const support = reach > 0 ? triangles.flatMap((triangle, t) => (triangle.kind === Overhang.Support ? [t] : [])) : []; - const small = smallPatches(positions, index, support, reach, bedZ); + // The roof of a lying hole overhangs between the two lines where its wall passes the limit. + const arch = LYING_HOLE * Math.sin(((limits.support + ANGLE_TOLERANCE) * Math.PI) / 180); + const small = smallPatches(positions, index, support, reach, arch, bedZ); triangles.forEach(({ kind, face, area: size }, t) => { const counted = small.has(t) ? Overhang.Borderline : kind; area[counted] += size; diff --git a/packages/geometry/src/testShapes.ts b/packages/geometry/src/testShapes.ts index 71ca25e4..706233da 100644 --- a/packages/geometry/src/testShapes.ts +++ b/packages/geometry/src/testShapes.ts @@ -125,11 +125,79 @@ export function prismBody(name: string, outline: readonly (readonly [number, num const front = built.names.length; built.names.push('F1:-y', 'F1:+y'); built.normals.push([0, -1, 0], [0, 1, 0]); - for (let i = 1; i < n - 1; i += 1) { - built.indices.push(0, i, i + 1); + for (const [a, b, c] of ears(outline)) { + built.indices.push(a, b, c); built.triangleFaces.push(front); - built.indices.push(n, n + i + 1, n + i); + built.indices.push(n + a, n + c, n + b); built.triangleFaces.push(front + 1); } return finish(name, built); } + +/** The triangles of a simple polygon given counter-clockwise, by clipping ears: the outline need not be convex. */ +function ears(outline: readonly (readonly [number, number])[]): [number, number, number][] { + const turn = (o: number, a: number, b: number) => { + const [ox, oz] = outline[o] ?? [0, 0]; + const [ax, az] = outline[a] ?? [0, 0]; + const [bx, bz] = outline[b] ?? [0, 0]; + return (ax - ox) * (bz - oz) - (az - oz) * (bx - ox); + }; + const left = outline.map((_, i) => i); + const triangles: [number, number, number][] = []; + while (left.length > 3) { + const n = left.length; + const at = left.findIndex((b, i) => { + const a = left[(i + n - 1) % n] ?? 0; + const c = left[(i + 1) % n] ?? 0; + return turn(a, b, c) > 0 && left.every((p) => p === a || p === b || p === c || turn(a, b, p) < 0 || turn(b, c, p) < 0 || turn(c, a, p) < 0); + }); + if (at < 0) throw new Error('prismBody: the outline is not a simple counter-clockwise polygon'); + triangles.push([left[(at + n - 1) % n] ?? 0, left[at] ?? 0, left[(at + 1) % n] ?? 0]); + left.splice(at, 1); + } + triangles.push([left[0] ?? 0, left[1] ?? 0, left[2] ?? 0]); + return triangles; +} + +/** + * A body of blocks on a grid: the cells between the planes `xs`, `ys`, `zs` for which `filled` says + * so, as one closed shell with the faces between two filled cells left out. One face per direction + * and plane, named like `F1:-z@8` (facing down, at z 8). + */ +export function gridBody(name: string, xs: readonly number[], ys: readonly number[], zs: readonly number[], filled: (i: number, j: number, k: number) => boolean) { + const built: Built = { positions: [], indices: [], triangleFaces: [], names: [], normals: [] }; + const vertices = new Map(); + const vertex = (point: Point) => { + const key = point.join(','); + let v = vertices.get(key); + if (v === undefined) { + v = built.positions.length / 3; + vertices.set(key, v); + built.positions.push(...point); + } + return v; + }; + const inside = (i: number, j: number, k: number) => i >= 0 && j >= 0 && k >= 0 && i < xs.length - 1 && j < ys.length - 1 && k < zs.length - 1 && filled(i, j, k); + for (let i = 0; i < xs.length - 1; i += 1) { + for (let j = 0; j < ys.length - 1; j += 1) { + for (let k = 0; k < zs.length - 1; k += 1) { + if (!inside(i, j, k)) continue; + const [x0, x1, y0, y1, z0, z1] = [xs[i] ?? 0, xs[i + 1] ?? 0, ys[j] ?? 0, ys[j + 1] ?? 0, zs[k] ?? 0, zs[k + 1] ?? 0]; + const corners: Point[] = [[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], [x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1]]; + NORMALS.forEach(([dx, dy, dz], side) => { + if (inside(i + dx, j + dy, k + dz)) return; + const faceName = `F1:${SIDES[side] ?? ''}@${String([z0, z1, y0, x1, y1, x0][side] ?? 0)}`; + let face = built.names.indexOf(faceName); + if (face < 0) { + face = built.names.push(faceName) - 1; + built.normals.push([dx, dy, dz]); + } + const [p, q, r, s] = (QUADS[side] ?? [0, 0, 0, 0]).map((corner) => vertex(corners[corner] ?? [0, 0, 0])); + built.indices.push(p ?? 0, q ?? 0, r ?? 0, p ?? 0, r ?? 0, s ?? 0); + built.triangleFaces.push(face, face); + }); + } + } + } + return finish(name, built); +} From e294d82b9a16266b4bdf65fd0a00c913d24eff66 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 13:57:14 +0100 Subject: [PATCH 08/24] fix(geometry): the print check drops no body, no floor and no finding without a word Review of the print check: A body whose model carries no count of open edges fell out of both the open and the closed bodies: 10 x 10 x 300 mm read ready. It is checked as closed. A profile without a layer height switched the floor check off (a 0.3 mm plate read ready). It is checked as 0.2 mm. "(four layers)" was wrong under a 0.2 mm layer, where the 0.8 mm minimum is more (6.7 layers at 0.12 mm). The finding carries the number of layers and the texts name it. Fifty pins made fifty contact findings, of which the report kept 40 and the note 12. The bodies with little on the bed are one finding with their count; the report says how many findings it leaves out, and the note how many findings and bodies. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/print/printCheckNote.ts | 32 +++++++++++------ apps/desktop/src/renderer/src/i18n/de.json | 5 +-- apps/desktop/src/renderer/src/i18n/en.json | 5 +-- .../src/renderer/src/print/PrintCheckChip.tsx | 10 ++++-- apps/desktop/src/renderer/src/print/report.ts | 5 +-- apps/desktop/src/shared/contract.ts | 6 ++-- apps/desktop/test/printCheckNote.test.ts | 27 ++++++++++++--- packages/geometry/src/printCheck.test.ts | 24 ++++++++++++- packages/geometry/src/printCheck.ts | 34 ++++++++++++------- 9 files changed, 111 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/main/print/printCheckNote.ts b/apps/desktop/src/main/print/printCheckNote.ts index ce61836a..4963ca1e 100644 --- a/apps/desktop/src/main/print/printCheckNote.ts +++ b/apps/desktop/src/main/print/printCheckNote.ts @@ -5,17 +5,24 @@ type Places = Extract['places']; const mm = (value: number) => `${String(Math.round(value * 100) / 100)} mm`; -/** Where, in the face names the model also gets from SolidCraft; long lists are cut short. */ -function where(places: Places): string { +/** + * Where, in the face names the model also gets from SolidCraft; long lists are cut short, and say + * by how much. `bodies` is how many bodies the finding is about when the places do not name all. + */ +function where(places: Places, bodies = places.length): string { if (places.length === 0) return ''; + const shown = places.slice(0, 4); + const rest = bodies - shown.length; + const more = rest > 0 ? `; and ${String(rest)} more ${rest === 1 ? 'body' : 'bodies'}` : ''; // A body that rests on a point or an edge has no face to name. - if (places.every(({ faces }) => faces.length === 0)) return ` (body ${places.map(({ body }) => JSON.stringify(body)).join(', ')})`; - const parts = places.slice(0, 4).map(({ body, faces }) => { - const shown = faces.slice(0, 6).map((face) => JSON.stringify(face)).join(', '); - const more = faces.length > 6 ? ` and ${String(faces.length - 6)} more` : ''; - return `${shown}${more} of body ${JSON.stringify(body)}`; + if (shown.every(({ faces }) => faces.length === 0)) return ` (body ${shown.map(({ body }) => JSON.stringify(body)).join(', ')}${more})`; + const parts = shown.map(({ body, faces }) => { + if (faces.length === 0) return `body ${JSON.stringify(body)}`; + const named = faces.slice(0, 6).map((face) => JSON.stringify(face)).join(', '); + const further = faces.length > 6 ? ` and ${String(faces.length - 6)} more` : ''; + return `${named}${further} of body ${JSON.stringify(body)}`; }); - return ` (faces ${parts.join('; ')})`; + return ` (faces ${parts.join('; ')}${more})`; } function line(finding: Finding, nozzle: number): string { @@ -28,14 +35,16 @@ function line(finding: Finding, nozzle: number): string { if (finding.floor) return finding.severity === 'block' ? `- ${tag}: floors or lids down to ${mm(finding.thickness)}, thinner than one layer, so they do not print${where(finding.places)}.` - : `- ${tag}: floors or lids down to ${mm(finding.thickness)}, under the ${mm(finding.minimum)} a floor needs to print closed (four layers)${where(finding.places)}.`; + : `- ${tag}: floors or lids down to ${mm(finding.thickness)}, under the ${mm(finding.minimum)} a floor needs to print closed${finding.layers === undefined ? '' : ` (${String(finding.layers)} layers)`}${where(finding.places)}.`; return finding.severity === 'block' ? `- ${tag}: walls down to ${mm(finding.thickness)}, thinner than the ${mm(nozzle)} nozzle, so they do not print${where(finding.places)}.` : `- ${tag}: walls down to ${mm(finding.thickness)}, under the ${mm(finding.minimum)} of two lines${where(finding.places)}.`; case 'overhang': return `- ${tag}: ${String(finding.area)} mm² lean more than ${String(finding.limit)}° from the vertical${where(finding.places)}; the slicer decides whether they need support.`; case 'contact': - return `- ${tag}: only ${String(finding.area)} mm² lie on the bed, the part may come loose or tip over${where(finding.places)}.`; + return finding.bodies > 1 + ? `- ${tag}: ${String(finding.bodies)} bodies have little on the bed, the least ${String(finding.area)} mm²; they may come loose or tip over${where(finding.places, finding.bodies)}.` + : `- ${tag}: only ${String(finding.area)} mm² lie on the bed, the part may come loose or tip over${where(finding.places)}.`; case 'separate': return `- ${tag}: bodies ${finding.bodies.map((body) => JSON.stringify(body)).join(', ')} touch but are separate; the slicer lays each on the bed by itself. Combine them if they are one part.`; case 'open': @@ -70,5 +79,8 @@ export function printCheckNote(report: PrintCheckReport): string { if (report.findings.length === 0) return `${head} ready to print, nothing found.]`; const verdict = report.status === 'block' ? 'not ready to print' : report.status === 'warn' ? 'prints, with warnings' : 'ready to print'; const lines = report.findings.slice(0, 12).map((finding) => line(finding, report.nozzle)); + // What does not fit the note is counted, never dropped without a word. + const rest = report.findings.length - lines.length + (report.omitted ?? 0); + if (rest > 0) lines.push(`- and ${String(rest)} more ${rest === 1 ? 'finding' : 'findings'} not listed here; the user sees them in the ready-to-print panel.`); return [`${head} ${verdict}.`, ...lines, 'Fix what blocks printing before you call the part done; the user sees the same findings.]'].join('\n'); } diff --git a/apps/desktop/src/renderer/src/i18n/de.json b/apps/desktop/src/renderer/src/i18n/de.json index 6b7297c0..51f499fa 100644 --- a/apps/desktop/src/renderer/src/i18n/de.json +++ b/apps/desktop/src/renderer/src/i18n/de.json @@ -1113,6 +1113,7 @@ "none": "Keine Probleme gefunden: Das Teil passt in den Bauraum, die Wände sind dick genug, nichts neigt sich über den Überhangwinkel.", "basis": "Geprüft nach jeder Änderung, wie konstruiert (auf der untersten Stelle stehend), mit Drucker und Material dieses Teils. Stützen, Brücken und die Lage entscheidet der Slicer.", "faces": "{count, plural, one {# Fläche} other {# Flächen}}", + "more": "{count, plural, one {# weiterer} other {# weitere}}", "show": "Stelle zeigen", "finding": { "size": "Zu groß für den Bauraum: {body}, {size} mm, Platz ist {bed} mm", @@ -1120,9 +1121,9 @@ "wallTooThin": "Wände nur {thickness} mm, dünner als die Düse ({nozzle} mm): werden nicht gedruckt", "wallThin": "Wände nur {thickness} mm, mindestens {minimum} mm (zwei Linien)", "floorTooThin": "Böden oder Deckel nur {thickness} mm, dünner als eine Schicht: werden nicht gedruckt", - "floorThin": "Böden oder Deckel nur {thickness} mm, mindestens {minimum} mm (vier Schichten)", + "floorThin": "Böden oder Deckel nur {thickness} mm, mindestens {minimum} mm{layers, select, none {} other { ({layers} Schichten)}}", "overhang": "{area} mm² neigen sich über {limit}°: Ob sie Stütze brauchen, entscheidet der Slicer", - "contact": "Nur {area} mm² von {body} liegen auf dem Druckbett: kann sich lösen oder kippen", + "contact": "{count, plural, one {Nur {area} mm² von {bodies} liegen auf dem Druckbett: kann sich lösen oder kippen} other {# Körper liegen kaum auf dem Druckbett ({bodies}), am wenigsten {area} mm²: können sich lösen oder kippen}}", "open": "{count, plural, one {{bodies} ist kein geschlossener Körper} other {{bodies} sind keine geschlossenen Körper}}: eine offene Fläche ohne Volumen wird nicht gedruckt", "separate": "{bodies} berühren sich, sind aber getrennte Körper: Der Slicer legt jeden für sich aufs Druckbett. Vereinige sie, wenn sie ein Teil sind.", "feature": "{feature}: {message}", diff --git a/apps/desktop/src/renderer/src/i18n/en.json b/apps/desktop/src/renderer/src/i18n/en.json index 8380d3da..6d087fdf 100644 --- a/apps/desktop/src/renderer/src/i18n/en.json +++ b/apps/desktop/src/renderer/src/i18n/en.json @@ -1113,6 +1113,7 @@ "none": "No problems found: the part fits the build volume, the walls are thick enough, nothing leans past the overhang angle.", "basis": "Checked after every change, as designed (standing on its lowest point), with the printer and material of this part. Supports, bridges and the orientation are up to the slicer.", "faces": "{count, plural, one {# face} other {# faces}}", + "more": "{count} more", "show": "Show the place", "finding": { "size": "Too large for the build volume: {body}, {size} mm, the printer takes {bed} mm", @@ -1120,9 +1121,9 @@ "wallTooThin": "Walls only {thickness} mm, thinner than the nozzle ({nozzle} mm): they do not print", "wallThin": "Walls only {thickness} mm, at least {minimum} mm (two lines)", "floorTooThin": "Floors or lids only {thickness} mm, thinner than one layer: they do not print", - "floorThin": "Floors or lids only {thickness} mm, at least {minimum} mm (four layers)", + "floorThin": "Floors or lids only {thickness} mm, at least {minimum} mm{layers, select, none {} other { ({layers} layers)}}", "overhang": "{area} mm² lean beyond {limit}°: the slicer decides whether they need support", - "contact": "Only {area} mm² of {body} lie on the bed: may come loose or tip over", + "contact": "{count, plural, one {Only {area} mm² of {bodies} lie on the bed: may come loose or tip over} other {# bodies have little on the bed ({bodies}), the least {area} mm²: they may come loose or tip over}}", "open": "{count, plural, one {{bodies} is no closed solid} other {{bodies} are no closed solids}}: an open surface without volume does not print", "separate": "{bodies} touch but are separate bodies: the slicer lays each on the bed by itself. Combine them if they are one part.", "feature": "{feature}: {message}", diff --git a/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx b/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx index 4da73627..66b783b7 100644 --- a/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx +++ b/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx @@ -26,14 +26,20 @@ export function useFindingText(): (finding: PrintFinding) => string { bed: finding.bed.map((value) => number.format(value)).join(' × '), }); case 'thinWall': - if (finding.floor) return t(finding.severity === 'block' ? 'check.finding.floorTooThin' : 'check.finding.floorThin', { thickness: number.format(finding.thickness), minimum: number.format(finding.minimum) }); + if (finding.floor) return t(finding.severity === 'block' ? 'check.finding.floorTooThin' : 'check.finding.floorThin', { thickness: number.format(finding.thickness), minimum: number.format(finding.minimum), layers: finding.layers === undefined ? 'none' : number.format(finding.layers) }); return finding.severity === 'block' ? t('check.finding.wallTooThin', { thickness: number.format(finding.thickness), nozzle: number.format(nozzle) }) : t('check.finding.wallThin', { thickness: number.format(finding.thickness), minimum: number.format(finding.minimum) }); case 'overhang': return t('check.finding.overhang', { area: number.format(finding.area), limit: finding.limit }); case 'contact': - return t('check.finding.contact', { area: number.format(finding.area), body: finding.places[0]?.body ?? '' }); + { + // Three names, and how many more: fifty pins do not fit a line. + const names = finding.places.slice(0, 3).map((place) => place.body); + const rest = finding.bodies - names.length; + const bodies = new Intl.ListFormat(i18n.language, { type: 'conjunction' }).format(rest > 0 ? [...names, t('check.more', { count: rest })] : names); + return t('check.finding.contact', { count: finding.bodies, area: number.format(finding.area), bodies }); + } case 'open': return t('check.finding.open', { count: finding.bodies.length, bodies: new Intl.ListFormat(i18n.language, { type: 'conjunction' }).format(finding.bodies) }); case 'separate': diff --git a/apps/desktop/src/renderer/src/print/report.ts b/apps/desktop/src/renderer/src/print/report.ts index baea7c71..392b0d94 100644 --- a/apps/desktop/src/renderer/src/print/report.ts +++ b/apps/desktop/src/renderer/src/print/report.ts @@ -8,6 +8,7 @@ export function reportOf(result: PrintCheck, nozzle: number, revision: number): status: result.status, nozzle, revision, + ...(result.findings.length > 40 ? { omitted: result.findings.length - 40 } : {}), findings: result.findings.slice(0, 40).map((finding) => { if (finding.kind === 'size' || finding.kind === 'feature' || finding.kind === 'posed') return finding; if (finding.kind === 'blocked') return { kind: finding.kind, severity: finding.severity, joint: finding.joint, bodies: finding.bodies, at: finding.at, limit: finding.limit, unit: finding.unit, places: [] }; @@ -18,11 +19,11 @@ export function reportOf(result: PrintCheck, nozzle: number, revision: number): const places = finding.places.slice(0, 20).map((place) => ({ body: place.body, faces: place.faces.slice(0, 40) })); switch (finding.kind) { case 'thinWall': - return { kind: finding.kind, severity: finding.severity, floor: finding.floor, thickness: finding.thickness, minimum: finding.minimum, area: finding.area, places }; + return { kind: finding.kind, severity: finding.severity, floor: finding.floor, ...(finding.layers === undefined ? {} : { layers: finding.layers }), thickness: finding.thickness, minimum: finding.minimum, area: finding.area, places }; case 'overhang': return { kind: finding.kind, severity: finding.severity, area: finding.area, limit: finding.limit, places }; case 'contact': - return { kind: finding.kind, severity: finding.severity, area: finding.area, places }; + return { kind: finding.kind, severity: finding.severity, area: finding.area, bodies: finding.bodies, places }; } }), }; diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index 574fc3d4..8e970a86 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -1144,9 +1144,9 @@ const findingPlacesSchema = z const mmValue = finite.nonnegative().max(100_000); export const printFindingSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('size'), severity: findingSeveritySchema, body: bodyName, size: z.tuple([mmValue, mmValue, mmValue]), bed: z.tuple([mmValue, mmValue, mmValue]), lying: z.boolean() }).strict(), - z.object({ kind: z.literal('thinWall'), severity: findingSeveritySchema, floor: z.boolean(), thickness: mmValue, minimum: mmValue, area: finite.nonnegative(), places: findingPlacesSchema }).strict(), + z.object({ kind: z.literal('thinWall'), severity: findingSeveritySchema, floor: z.boolean(), layers: finite.positive().max(1000).optional(), thickness: mmValue, minimum: mmValue, area: finite.nonnegative(), places: findingPlacesSchema }).strict(), z.object({ kind: z.literal('overhang'), severity: findingSeveritySchema, area: finite.nonnegative(), limit: range(0, 90), places: findingPlacesSchema }).strict(), - z.object({ kind: z.literal('contact'), severity: findingSeveritySchema, area: finite.nonnegative(), places: findingPlacesSchema }).strict(), + z.object({ kind: z.literal('contact'), severity: findingSeveritySchema, area: finite.nonnegative(), bodies: z.number().int().positive().max(500), places: findingPlacesSchema }).strict(), z.object({ kind: z.literal('feature'), severity: findingSeveritySchema, feature: z.string().max(200), message: z.string().max(500) }).strict(), z.object({ kind: z.literal('separate'), severity: findingSeveritySchema, bodies: z.array(z.string().max(200)).min(2).max(20) }).strict(), z.object({ kind: z.literal('open'), severity: findingSeveritySchema, bodies: z.array(z.string().max(200)).min(1).max(20) }).strict(), @@ -1159,6 +1159,8 @@ export const printCheckReportSchema = z .object({ status: z.enum(['ready', 'warn', 'block']), findings: z.array(printFindingSchema).max(40), + /** How many more findings the check has than the report carries. */ + omitted: z.number().int().positive().max(100_000).optional(), /** The printer's nozzle the check used, for the wording of walls under it. */ nozzle: range(0.1, 2), /** The mirrored revision the check belongs to, so a check of an older revision reads as pending. */ diff --git a/apps/desktop/test/printCheckNote.test.ts b/apps/desktop/test/printCheckNote.test.ts index 5f413bd4..38cf40cd 100644 --- a/apps/desktop/test/printCheckNote.test.ts +++ b/apps/desktop/test/printCheckNote.test.ts @@ -36,10 +36,12 @@ describe('the ready-to-print check as the assistant reads it (M7)', () => { }); it('tells a thin floor from a thin wall: layers, not lines', () => { - const floor = { kind: 'thinWall' as const, floor: true, thickness: 0.3, minimum: 0.8, area: 900, places: [{ body: 'Box', faces: ['F1:-z'] }] }; + const floor = { kind: 'thinWall' as const, floor: true, layers: 4, thickness: 0.3, minimum: 0.8, area: 900, places: [{ body: 'Box', faces: ['F1:-z'] }] }; const warned = printCheckNote({ status: 'warn', nozzle: 0.4, revision: 1, findings: [{ ...floor, severity: 'warn' }] }); - expect(warned).toContain('warning: floors or lids down to 0.3 mm, under the 0.8 mm a floor needs to print closed (four layers) (faces "F1:-z" of body "Box")'); + expect(warned).toContain('warning: floors or lids down to 0.3 mm, under the 0.8 mm a floor needs to print closed (4 layers) (faces "F1:-z" of body "Box")'); expect(warned).not.toContain('nozzle'); + // At a 0.12 mm layer the 0.8 mm are not four layers, and the note does not say so. + expect(printCheckNote({ status: 'warn', nozzle: 0.4, revision: 1, findings: [{ ...floor, layers: 6.7, severity: 'warn' }] })).toContain('print closed (6.7 layers)'); const blocked = printCheckNote({ status: 'block', nozzle: 0.4, revision: 1, findings: [{ ...floor, thickness: 0.1, severity: 'block' }] }); expect(blocked).toContain('blocks printing: floors or lids down to 0.1 mm, thinner than one layer, so they do not print'); }); @@ -51,13 +53,30 @@ describe('the ready-to-print check as the assistant reads it (M7)', () => { }); it('names the body that rests on an edge and has no face on the bed', () => { - const note = printCheckNote({ status: 'warn', nozzle: 0.4, revision: 1, findings: [{ kind: 'contact', severity: 'warn', area: 0, places: [{ body: 'Ball', faces: [] }] }] }); + const note = printCheckNote({ status: 'warn', nozzle: 0.4, revision: 1, findings: [{ kind: 'contact', severity: 'warn', area: 0, bodies: 1, places: [{ body: 'Ball', faces: [] }] }] }); expect(note).toContain('only 0 mm² lie on the bed, the part may come loose or tip over (body "Ball").'); }); + it('says how much it leaves out: bodies of a finding, and findings of a long check', () => { + // Fifty pins: one finding, of which the report carries 20 places and the note names 4. + const places = Array.from({ length: 50 }, (_, i) => ({ body: 'Pin' + String(i), faces: ['F1:-z'] })); + const report = reportOf({ status: 'warn', findings: [{ kind: 'contact', severity: 'warn', area: 16, bodies: 50, places, box: null }] }, 0.4, 1); + expect(printCheckReportSchema.safeParse(report).success).toBe(true); + const note = printCheckNote(report); + expect(note).toContain('50 bodies have little on the bed, the least 16 mm²'); + expect(note).toContain('"F1:-z" of body "Pin3"; and 46 more bodies)'); + // Fifty findings: the report keeps 40 and counts the rest, the note lists 12 and counts the rest. + const many = reportOf({ status: 'block', findings: Array.from({ length: 50 }, (_, i) => ({ kind: 'size' as const, severity: 'block' as const, body: 'Rod' + String(i), size: [300, 300, 300] as [number, number, number], bed: [256, 256, 256] as [number, number, number], lying: false })) }, 0.4, 1); + expect(many).toMatchObject({ omitted: 10 }); + expect(many.findings).toHaveLength(40); + expect(printCheckReportSchema.safeParse(many).success).toBe(true); + expect(printCheckNote(many)).toContain('- and 38 more findings not listed here'); + expect(printCheckNote(report)).not.toContain('more findings'); + }); + it('cuts long face lists short', () => { const faces = Array.from({ length: 30 }, (_, i) => `F${String(i)}:+z`); - const note = printCheckNote({ status: 'warn', nozzle: 0.4, revision: 1, findings: [{ kind: 'contact', severity: 'warn', area: 3, places: [{ body: 'B', faces }] }] }); + const note = printCheckNote({ status: 'warn', nozzle: 0.4, revision: 1, findings: [{ kind: 'contact', severity: 'warn', area: 3, bodies: 1, places: [{ body: 'B', faces }] }] }); expect(note).toContain('and 24 more'); }); }); diff --git a/packages/geometry/src/printCheck.test.ts b/packages/geometry/src/printCheck.test.ts index 2421858a..576de243 100644 --- a/packages/geometry/src/printCheck.test.ts +++ b/packages/geometry/src/printCheck.test.ts @@ -109,6 +109,12 @@ describe('the ready-to-print check (M7)', () => { const skin = thinOf(box('Plate', [0, 0, 0], [30, 30, 0.3])); expect(skin).toMatchObject([{ severity: 'warn', floor: true, thickness: 0.3, minimum: 0.8, area: 1800 }]); expect(skin[0]?.places[0]?.faces).toEqual(['F1:-z', 'F1:+z']); + // A profile without a layer height is checked as 0.2 mm, not passed (it read ready). + expect(thinOf(box('Plate', [0, 0, 0], [30, 30, 0.3]), { ...PLA, layer: undefined as unknown as number })).toMatchObject([{ floor: true, minimum: 0.8, layers: 4 }]); + expect(minimumFloor(Number.NaN)).toBe(0.8); + // The finding says how many layers its minimum is: four from 0.2 mm on, more below (0.8 mm at 0.12 mm). + expect(skin[0]?.layers).toBe(4); + expect(thinOf(box('Plate', [0, 0, 0], [30, 30, 0.3]), { ...PLA, layer: 0.12 })).toMatchObject([{ minimum: 0.8, layers: 6.7 }]); // Under one layer nothing is laid. expect(thinOf(box('Film', [0, 0, 0], [30, 30, 0.1]))).toMatchObject([{ severity: 'block', floor: true }]); // The floor follows the layer height, and the way the body lies. @@ -169,12 +175,28 @@ describe('the ready-to-print check (M7)', () => { expect(box('Cube', [0, 0, 0], [20, 20, 20]).model.openEdges).toBe(0); }); + it('checks a body whose model carries no count of open edges as a closed one', () => { + // It fell out of both the open and the closed bodies: 10 × 10 × 300 mm read ready. + const post = box('Post', [0, 0, 0], [10, 10, 300]); + const bare = { ...post, model: { ...post.model, openEdges: undefined as unknown as number } }; + expect(checkPrint([bare], PLA).findings).toMatchObject([{ kind: 'size', severity: 'block', body: 'Post' }]); + }); + + it('gathers the bodies with little on the bed into one finding that names them all', () => { + // Fifty pins 4 × 4 mm made fifty findings, of which the report kept 40 and the note 12. + const pins = Array.from({ length: 50 }, (_, i) => box(`Pin${String(i)}`, [i * 6, 0, 0], [i * 6 + 4, 4, 10])); + const findings = checkPrint(pins, PLA).findings; + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ kind: 'contact', severity: 'warn', area: 16, bodies: 50 }); + expect(findings[0]?.kind === 'contact' ? findings[0].places.map((place) => place.body) : []).toEqual(pins.map((pin) => pin.name)); + }); + it('looks at what lies on the bed body by body: a broad one does not hold one standing on an edge', () => { // Audit D3: 400 mm² of a cube hid a second body with nothing on the bed. const cube = box('Cube', [20, 0, 0], [40, 20, 20]); const onEdge = prismBody('Diamond', [[0, 0], [10, 10], [0, 20], [-10, 10]], 20); const contact = checkPrint([cube, onEdge], PLA).findings.filter((f) => f.kind === 'contact'); - expect(contact).toMatchObject([{ severity: 'warn', area: 0, places: [{ body: 'Diamond', faces: [] }] }]); + expect(contact).toMatchObject([{ severity: 'warn', area: 0, bodies: 1, places: [{ body: 'Diamond', faces: [] }] }]); // Two bodies that both lie flat: nothing. expect(checkPrint([cube, box('Second', [50, 0, 0], [70, 20, 20])], PLA)).toEqual({ status: 'ready', findings: [] }); }); diff --git a/packages/geometry/src/printCheck.ts b/packages/geometry/src/printCheck.ts index 6e9f55f0..19707ee3 100644 --- a/packages/geometry/src/printCheck.ts +++ b/packages/geometry/src/printCheck.ts @@ -66,9 +66,10 @@ interface Located { export type PrintFinding = | { kind: 'size'; severity: FindingSeverity; body: string; size:[number, number, number]; bed: [number, number, number]; lying: boolean } /** `floor`: what lies flat (a floor, a lid), measured against layers; else a wall, against lines. */ - | ({ kind: 'thinWall'; severity: FindingSeverity; floor: boolean; thickness: number; minimum: number; area: number } & Located) + | ({ kind: 'thinWall'; severity: FindingSeverity; floor: boolean; /** For a floor: how many layers the minimum is. */ layers?: number; thickness: number; minimum: number; area: number } & Located) | ({ kind: 'overhang'; severity: FindingSeverity; area: number; limit: number } & Located) - | ({ kind: 'contact'; severity: FindingSeverity; area: number } & Located) + /** The bodies with too little on the bed: how many, and the area of the one with the least. */ + | ({ kind: 'contact'; severity: FindingSeverity; area: number; bodies: number } & Located) | { kind: 'separate'; severity: FindingSeverity; bodies: string[] } /** Bodies that are open shells (SolidCraft's surface bodies): no solid to print. */ | { kind: 'open'; severity: FindingSeverity; bodies: string[] } @@ -106,7 +107,12 @@ export function minimumWall(nozzle: number): number { * assistant designs to the same number (printProfiles.ts). */ export function minimumFloor(layer: number): number { - return Math.round(Math.max(0.8, layer * 4) * 100) / 100; + return Math.round(Math.max(0.8, layerOf(layer) * 4) * 100) / 100; +} + +/** The layer height to check with: the usual 0.2 mm where the profile names none, so floors are still checked. */ +function layerOf(layer: number): number { + return Number.isFinite(layer) && layer > 0 ? layer : 0.2; } /** Below this, an area is noise of the tessellation, not a finding (mm²). */ @@ -226,7 +232,7 @@ function thinWalls(body: PrintCheckBody, profile: PrintCheckProfile): { walls: T const flat = Math.abs(normal.dot(down)) >= FLAT; const thin = flat ? floors : walls; const minimum = flat ? minimumFloor(profile.layer) : minimumWall(profile.nozzle); - const printable = flat ? profile.layer : profile.nozzle; + const printable = flat ? layerOf(profile.layer) : profile.nozzle; const count = Math.ceil(area / cell); ray.direction.copy(normal).negate(); const along = (axis: number, [a, b, c]: [number, number, number]) => a * (corners[axis] ?? 0) + b * (corners[axis + 3] ?? 0) + c * (corners[axis + 6] ?? 0); @@ -436,7 +442,8 @@ export function checkPrint(bodies: readonly PrintCheckBody[], profile: PrintChec const shells = bodies.filter((body) => body.model.table.faces.length > 0); const open = shells.filter((body) => body.model.openEdges > 0); if (open.length > 0) findings.push({ kind: 'open', severity: 'block', bodies: open.map((body) => body.name) }); - const parts = shells.filter((body) => body.model.openEdges === 0); + // A model that carries no count is taken as closed, not dropped from the check. + const parts = shells.filter((body) => !(body.model.openEdges > 0)); if (parts.length > 0) { const meshes = parts.map(laid); // Each body against the plate by itself: the slicer places every one, on more plates if need be. @@ -454,7 +461,8 @@ export function checkPrint(bodies: readonly PrintCheckBody[], profile: PrintChec if (found.places.length === 0) continue; const unprintable = kind.reduce((sum, body) => sum + body.unprintable, 0); const minimum = floor ? minimumFloor(profile.layer) : minimumWall(profile.nozzle); - findings.push({ kind: 'thinWall', severity: unprintable >= BLOCK_THIN_AREA ? 'block' : 'warn', floor, thickness: round(found.extreme), minimum, area: round(found.area, 0), places: found.places, box: found.box }); + const layers = floor ? { layers: round(minimum / layerOf(profile.layer), 1) } : {}; + findings.push({ kind: 'thinWall', severity: unprintable >= BLOCK_THIN_AREA ? 'block' : 'warn', floor, ...layers, thickness: round(found.extreme), minimum, area: round(found.area, 0), places: found.places, box: found.box }); } // Bodies that touch fall apart in the slicer, each laid onto the bed by itself. @@ -474,7 +482,7 @@ export function checkPrint(bodies: readonly PrintCheckBody[], profile: PrintChec // Overhangs by angle and the area on the bed, each body on its own bed. const limits = overhangLimits(profile.overhang); const overhang: FaceTally[] = []; - const loose: PrintFinding[] = []; + const loose: { body: PrintCheckBody; tally: FaceTally; area: number }[] = []; parts.forEach((body, b) => { const { model } = body; const geometry = model.geometry; @@ -509,16 +517,18 @@ export function checkPrint(bodies: readonly PrintCheckBody[], profile: PrintChec overhang.push(overhangTally); // Each body lies on the bed by itself: one with a broad base does not hold another on its edge. const onBed = map.area[Overhang.Bed]; - if (onBed < MIN_CONTACT) { - const lying = locate([body], [contactTally], 1e-6); - loose.push({ kind: 'contact', severity: 'warn', area: round(onBed, 0), places: lying.places.length > 0 ? lying.places : [{ body: body.name, faces: [] }], box: lying.box }); - } + if (onBed < MIN_CONTACT) loose.push({ body, tally: contactTally, area: onBed }); }); const steep = locate(parts, overhang, MIN_AREA); if (steep.area >= MIN_AREA) { findings.push({ kind: 'overhang', severity: 'warn', area: round(steep.area, 0), limit: profile.overhang, places: steep.places, box: steep.box }); } - findings.push(...loose); + // One finding for all of them: fifty pins are one thing to say, and none drops off the end of a list. + if (loose.length > 0) { + const lying = locate(loose.map((entry) => entry.body), loose.map((entry) => entry.tally), 1e-6); + const places = loose.map(({ body }) => lying.places.find((place) => place.body === body.name) ?? { body: body.name, faces: [] }); + findings.push({ kind: 'contact', severity: 'warn', area: round(Math.min(...loose.map((entry) => entry.area)), 0), bodies: loose.length, places, box: lying.box }); + } } findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); From b6662d55f258538a2a8e7d0218293951db7e29a7 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:31:00 +0100 Subject: [PATCH 09/24] fix(desktop): the print check of a change comes back with the tool call that made it Audit R1: reading the check after the last change was only asked of the assistant, so a turn could end on a blocked part it never saw checked. The work log now hands every answer of a call that moved the design's revision to the check: it waits up to 5 s for the mirror and the check of that revision and puts the findings behind the answer, or says that the check is still running. All three routes share this tool chain. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/agents/viewContext.ts | 4 +- apps/desktop/src/main/agents/workLog.ts | 10 +- apps/desktop/src/main/index.ts | 7 +- apps/desktop/src/main/print/printCheckNote.ts | 34 +++++- apps/desktop/test/checkOfChange.test.ts | 106 ++++++++++++++++++ 5 files changed, 152 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/test/checkOfChange.test.ts diff --git a/apps/desktop/src/main/agents/viewContext.ts b/apps/desktop/src/main/agents/viewContext.ts index 355fe384..0801ff71 100644 --- a/apps/desktop/src/main/agents/viewContext.ts +++ b/apps/desktop/src/main/agents/viewContext.ts @@ -194,6 +194,6 @@ export function withViewTool( export const VIEW_GUIDANCE = 'A message may start with a [PartBuddy: …] note naming the face, edge or vertex the user selected in PartBuddy’s view; ' + `that is what "this face", "this edge" or "the marked one" means. Call ${VIEW_TOOL} to see what the user looks at. ` + - `PartBuddy checks the part for printing after every change and shows the result to the user as a light; ${VIEW_TOOL} ` + - 'gives you the same findings. Read them after your last change, before you call the part done. ' + + 'PartBuddy checks the part for printing after every change and shows the result to the user as a light; the findings come ' + + `behind the answer of each tool call that changed the design, and ${VIEW_TOOL} gives the latest ones. ` + 'Take a screenshot only when the look of the part matters: it costs many tokens.'; diff --git a/apps/desktop/src/main/agents/workLog.ts b/apps/desktop/src/main/agents/workLog.ts index 8d20bd89..d6ada6a5 100644 --- a/apps/desktop/src/main/agents/workLog.ts +++ b/apps/desktop/src/main/agents/workLog.ts @@ -18,6 +18,8 @@ export interface WorkLogOptions { /** Runs Undo or Redo `count` times as one request. */ travel(command: 'edit.undo' | 'edit.redo', count: number): Promise; emit(event: AgentEvent): void; + /** The answer of a call that moved the design's revision, as the model gets it (the print check goes behind it). */ + changed?(result: ToolResult, after: DesignHistory): Promise; } /** Tools that may change the design; every other tool only reads and is not tracked. */ @@ -62,9 +64,9 @@ export class WorkLog { return { instructions: () => upstream.instructions(), listTools: () => upstream.listTools(), - callTool: (name, args, callId) => { + callTool: async (name, args, callId) => { if (!CHANGING_TOOLS.has(name)) return upstream.callTool(name, args, callId); - return this.serial(async () => { + const { result, moved } = await this.serial(async () => { const before = await this.readHistory(); const result: ToolResult = await upstream.callTool(name, args, callId); const after = await this.readHistory(); @@ -73,8 +75,10 @@ export class WorkLog { } else { this.publish(); } - return result; + return { result, moved: before && after && (before.documentId !== after.documentId || before.revision !== after.revision) ? after : null }; }); + // Outside the queue: the user's own changes do not wait for the check. + return moved && this.options.changed ? this.options.changed(result, moved) : result; }, }; } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 16239e3e..8aaf5f92 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -57,7 +57,7 @@ import { readVersion } from './solidcraft/engineVersion'; import { safeDesignFileName } from './solidcraft/headless'; import { longPath, SolidCraftSupervisor } from './solidcraft/supervisor'; import { ChatArchive } from './chat/chatArchive'; -import { printCheckNote } from './print/printCheckNote'; +import { CHECK_PENDING, printCheckNote, withCheckOfChange } from './print/printCheckNote'; import { templatePrintFaces, templateSteps } from '../shared/templates'; import { testPieceSteps } from '../shared/testPieces'; import { sketchNote } from './agents/sketchNote'; @@ -371,8 +371,7 @@ if (!app.requestSingleInstanceLock()) { // The check as partbuddy_view shows it: a check of an older revision is still catching up. const checkForView = () => { if (!printCheck) return null; - if (printCheck.revision !== supervisor.currentRevision()?.revision) - return '[PartBuddy: the ready-to-print check of the latest change is still running; call partbuddy_view again in a moment.]'; + if (printCheck.revision !== supervisor.currentRevision()?.revision) return CHECK_PENDING; return printCheckNote(printCheck); }; bridge.handle(invokeChannels.printReportCheck, (report) => { @@ -550,6 +549,8 @@ if (!app.requestSingleInstanceLock()) { history: () => supervisor.history(), travel: (command, count) => supervisor.travel(command, count), emit: emitEvent, + // The check of each change goes back with the tool's answer, so no turn ends on a part the model never saw checked. + changed: (result, after) => withCheckOfChange(result, after, () => ({ mirrored: supervisor.currentRevision(), report: printCheck })), }); workLog = steps; // Questions and plans as cards (M2a); changes wait until the user answered. diff --git a/apps/desktop/src/main/print/printCheckNote.ts b/apps/desktop/src/main/print/printCheckNote.ts index 4963ca1e..f5064fd8 100644 --- a/apps/desktop/src/main/print/printCheckNote.ts +++ b/apps/desktop/src/main/print/printCheckNote.ts @@ -1,4 +1,5 @@ import type { PrintCheckReport } from '../../shared/contract'; +import type { ToolResult } from '../agents/gateProxy'; type Finding = PrintCheckReport['findings'][number]; type Places = Extract['places']; @@ -70,7 +71,8 @@ function line(finding: Finding, nozzle: number): string { /** * The ready-to-print check (M7) as the model reads it: PartBuddy checks after every revision, the - * model hears of it with the next message when the result changed. Face and feature names come + * model reads it behind the answer of each tool that changed the design, and with the next message + * when the result changed. Face and feature names come * from the design, so they are quoted. */ export function printCheckNote(report: PrintCheckReport): string { @@ -84,3 +86,33 @@ export function printCheckNote(report: PrintCheckReport): string { if (rest > 0) lines.push(`- and ${String(rest)} more ${rest === 1 ? 'finding' : 'findings'} not listed here; the user sees them in the ready-to-print panel.`); return [`${head} ${verdict}.`, ...lines, 'Fix what blocks printing before you call the part done; the user sees the same findings.]'].join('\n'); } + +/** Said in place of a check that has not caught up with the design yet. */ +export const CHECK_PENDING = + '[PartBuddy: the ready-to-print check of the latest change is still running; call partbuddy_view for it before you call the part done.]'; + +/** How long a changing tool's answer waits for the check of what it built. */ +export const CHECK_WAIT_MS = 5000; + +/** What PartBuddy holds right now: the revision the mirror last delivered and the check last reported. */ +export interface CheckState { + mirrored: { documentId: string; revision: number } | null; + report: PrintCheckReport | null; +} + +/** + * The answer of a tool that changed the design, with the check of that change behind it (audit + * R1): the model reads the findings in the turn that caused them, on every route, instead of being + * asked to fetch them. The check runs in the renderer after the mirror, so the answer waits for + * it, but no longer than `waitMs`; after that it says the check is still running. + */ +export async function withCheckOfChange(result: ToolResult, change: { documentId: string; revision: number }, state: () => CheckState, waitMs = CHECK_WAIT_MS): Promise { + const until = Date.now() + waitMs; + for (;;) { + const { mirrored, report } = state(); + // SolidCraft's revision also moves without a mirror (a save), so the mirror may be past the change. + const checked = mirrored?.documentId === change.documentId && mirrored.revision >= change.revision && report?.revision === mirrored.revision ? report : null; + if (checked || Date.now() >= until) return { ...result, content: [...result.content, { type: 'text', text: checked ? printCheckNote(checked) : CHECK_PENDING }] }; + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} diff --git a/apps/desktop/test/checkOfChange.test.ts b/apps/desktop/test/checkOfChange.test.ts new file mode 100644 index 00000000..3ab514c0 --- /dev/null +++ b/apps/desktop/test/checkOfChange.test.ts @@ -0,0 +1,106 @@ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { GateProxy, type ToolResult, type UpstreamTools } from '../src/main/agents/gateProxy'; +import { policyToolSet } from '../src/main/agents/loop'; +import { WorkLog } from '../src/main/agents/workLog'; +import { CHECK_PENDING, withCheckOfChange, type CheckState } from '../src/main/print/printCheckNote'; +import type { PrintCheckReport } from '../src/shared/contract'; + +const cleanups: (() => Promise | void)[] = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); +}); + +const thinWall: PrintCheckReport['findings'][number] = { kind: 'thinWall', severity: 'block', floor: false, thickness: 0.3, minimum: 0.9, area: 400, places: [{ body: 'Plate', faces: ['F2:side:0'] }] }; + +/** + * The tool chain as index.ts builds it for every route, with a design whose `solid.extrude` leaves a + * 0.3 mm wall. `checkAfterMs`: how long mirror and check take after a change; null: they never come. + */ +function setup(checkAfterMs: number | null, waitMs = 1000) { + let revision = 1; + const state: CheckState = { mirrored: { documentId: 'doc-1', revision: 1 }, report: { status: 'ready', findings: [], nozzle: 0.4, revision: 1 } }; + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve([{ name: 'execute', inputSchema: { type: 'object' } }]), + callTool: (_name, args): Promise => { + if (args.command === 'solid.extrude') { + revision += 1; + const built = revision; + if (checkAfterMs !== null) + setTimeout(() => { + state.mirrored = { documentId: 'doc-1', revision: built }; + state.report = { status: 'block', findings: [thinWall], nozzle: 0.4, revision: built }; + }, checkAfterMs); + } + return Promise.resolve({ content: [{ type: 'text', text: 'ok' }] }); + }, + }; + const log = new WorkLog({ + history: () => Promise.resolve({ documentId: 'doc-1', revision, undo: revision - 1, redo: 0 }), + travel: () => Promise.resolve(), + emit: () => undefined, + changed: (result, after) => withCheckOfChange(result, after, () => state, waitMs), + }); + return log.tools(upstream); +} + +const extrude = { command: 'solid.extrude', params: { distance: 5 } }; +const BLOCKED = 'not ready to print.\n- blocks printing: walls down to 0.3 mm, thinner than the 0.4 mm nozzle'; + +/** What Claude Code reads: the tool result through the gate proxy. */ +async function throughProxy(tools: UpstreamTools, args: Record): Promise { + const proxy = new GateProxy(tools, () => undefined); + await proxy.start(); + cleanups.push(() => proxy.stop()); + const client = new Client({ name: 'test', version: '1.0.0' }); + await client.connect(new StreamableHTTPClientTransport(new URL(proxy.url), { requestInit: { headers: { Authorization: `Bearer ${proxy.token}` } } })); + cleanups.push(() => client.close()); + const result = await client.callTool({ name: 'execute', arguments: args }); + return (result.content as { text: string }[]).map((part) => part.text).join('\n'); +} + +/** What ChatGPT and local models read: the tool result of PartBuddy's own loop. */ +async function throughLoop(tools: UpstreamTools, args: Record): Promise { + const set = await policyToolSet(tools, { nextId: () => 'call-1', event: () => undefined }); + const execute = set.execute?.execute as (input: unknown, options: unknown) => Promise; + return execute(args, { toolCallId: 'call-1', messages: [] }); +} + +describe('the print check of a change comes back with the tool that made it (audit R1)', () => { + it('puts a blocking check into the answer of the changing call on the Claude Code route', async () => { + const text = await throughProxy(setup(150), extrude); + expect(text).toContain('ok'); + expect(text).toContain(BLOCKED); + }); + + it('does the same in the loop of ChatGPT and local models', async () => { + const text = await throughLoop(setup(150), extrude); + expect(text).toContain(BLOCKED); + }); + + it('adds nothing to a call that changed nothing', async () => { + const read = { command: 'inspect.measure', params: {} }; + expect(await throughProxy(setup(150), read)).toBe('ok'); + expect(await throughLoop(setup(150), read)).toBe('ok'); + }); + + it('waits a bounded time and then says the check is still running, never an older verdict', async () => { + const started = Date.now(); + const text = await throughLoop(setup(null, 300), extrude); + expect(Date.now() - started).toBeLessThan(2000); + expect(text).toBe(`ok\n${CHECK_PENDING}`); + expect(text).not.toContain('nothing found'); + }); + + it('takes a mirror that is past the change, and no check of another design', async () => { + const result: ToolResult = { content: [{ type: 'text', text: 'ok' }] }; + const report: PrintCheckReport = { status: 'block', findings: [thinWall], nozzle: 0.4, revision: 9 }; + const text = async (state: CheckState) => (await withCheckOfChange(result, { documentId: 'doc-1', revision: 7 }, () => state, 0)).content.at(-1)?.text; + expect(await text({ mirrored: { documentId: 'doc-1', revision: 9 }, report })).toContain('blocks printing'); + expect(await text({ mirrored: { documentId: 'doc-2', revision: 9 }, report })).toBe(CHECK_PENDING); + expect(await text({ mirrored: { documentId: 'doc-1', revision: 9 }, report: { ...report, revision: 6 } })).toBe(CHECK_PENDING); + expect(await text({ mirrored: null, report: null })).toBe(CHECK_PENDING); + }); +}); From 9749dcf14e21150536e0860ff8b451fd55447a4d Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:32:38 +0100 Subject: [PATCH 10/24] fix(desktop): a clean print check names what it looked at and what it did not Audit R3: "ready to print, nothing found" and "the print check still judges what you build" promised more than the check covers. The note now lists what was checked and what was not (strength and layer direction, chosen fits and hole allowances, fine details, bed-edge chamfers), and the standard topics say that the check does not test their rules. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/print/printCheckNote.ts | 10 ++++++++-- apps/desktop/src/shared/standards.ts | 2 +- apps/desktop/test/printCheckNote.test.ts | 10 ++++++++-- apps/desktop/test/standardTool.test.ts | 2 +- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/print/printCheckNote.ts b/apps/desktop/src/main/print/printCheckNote.ts index f5064fd8..73adc8f8 100644 --- a/apps/desktop/src/main/print/printCheckNote.ts +++ b/apps/desktop/src/main/print/printCheckNote.ts @@ -69,6 +69,12 @@ function line(finding: Finding, nozzle: number): string { } } +/** What the check looks at (the finding kinds of printCheck.ts, withClearance and withMotion), so "nothing found" claims no more. */ +const CHECKED = + 'build volume, wall and floor thickness, overhang angle, area on the bed, open bodies, failed features, bodies that touch, overlap or come closer than a push fit, joints that stop before a limit'; +/** What a clean check says nothing about (audit R3); the model must not pass the verdict on as more. */ +const NOT_CHECKED = 'Not checked: strength and layer direction under load, the fits and hole allowances you chose, details and text too fine to print, bed-edge chamfers. Judge those yourself.'; + /** * The ready-to-print check (M7) as the model reads it: PartBuddy checks after every revision, the * model reads it behind the answer of each tool that changed the design, and with the next message @@ -78,13 +84,13 @@ function line(finding: Finding, nozzle: number): string { export function printCheckNote(report: PrintCheckReport): string { const head = '[PartBuddy: ready-to-print check of the part as it is now (each body lying on its print face when it has one, else as designed along +Z on its own lowest point; overhangs by angle only):'; - if (report.findings.length === 0) return `${head} ready to print, nothing found.]`; + if (report.findings.length === 0) return `${head} nothing found in what it checks (${CHECKED}). ${NOT_CHECKED}]`; const verdict = report.status === 'block' ? 'not ready to print' : report.status === 'warn' ? 'prints, with warnings' : 'ready to print'; const lines = report.findings.slice(0, 12).map((finding) => line(finding, report.nozzle)); // What does not fit the note is counted, never dropped without a word. const rest = report.findings.length - lines.length + (report.omitted ?? 0); if (rest > 0) lines.push(`- and ${String(rest)} more ${rest === 1 ? 'finding' : 'findings'} not listed here; the user sees them in the ready-to-print panel.`); - return [`${head} ${verdict}.`, ...lines, 'Fix what blocks printing before you call the part done; the user sees the same findings.]'].join('\n'); + return [`${head} ${verdict}.`, ...lines, `Fix what blocks printing before you call the part done; the user sees the same findings. ${NOT_CHECKED}]`].join('\n'); } /** Said in place of a check that has not caught up with the design yet. */ diff --git a/apps/desktop/src/shared/standards.ts b/apps/desktop/src/shared/standards.ts index a8286975..33e39b85 100644 --- a/apps/desktop/src/shared/standards.ts +++ b/apps/desktop/src/shared/standards.ts @@ -102,5 +102,5 @@ export const STANDARDS: Record `- ${rule}`).join('\n')}\nNone of this has been printed by PartBuddy yet. The print check in partbuddy_view still judges what you build.`; + 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, and the print check does not test these rules: measure what you built against them.`; } diff --git a/apps/desktop/test/printCheckNote.test.ts b/apps/desktop/test/printCheckNote.test.ts index 38cf40cd..4b3b3d98 100644 --- a/apps/desktop/test/printCheckNote.test.ts +++ b/apps/desktop/test/printCheckNote.test.ts @@ -5,8 +5,14 @@ import { reportOf } from '../src/renderer/src/print/report'; import { printCheckReportSchema } from '../src/shared/contract'; describe('the ready-to-print check as the assistant reads it (M7)', () => { - it('says plainly when nothing was found', () => { - expect(printCheckNote({ status: 'ready', findings: [], nozzle: 0.4, revision: 1 })).toContain('ready to print, nothing found'); + it('says what it looked at when nothing was found, and what it did not (audit R3)', () => { + const clean = printCheckNote({ status: 'ready', findings: [], nozzle: 0.4, revision: 1 }); + expect(clean).toContain('nothing found in what it checks (build volume, wall and floor thickness, overhang angle, area on the bed,'); + expect(clean).toContain('Not checked: strength and layer direction under load, the fits and hole allowances you chose,'); + // No verdict the model could hand on as "ready to print". + expect(clean).not.toMatch(/ready to print[,.]/); + // With findings the limits stand as well. + expect(printCheckNote({ status: 'warn', nozzle: 0.4, revision: 1, findings: [{ kind: 'open', severity: 'warn', bodies: ['A'] }] })).toContain('Not checked: strength'); }); it('words every finding with its numbers and quoted face names, blocking ones first', () => { diff --git a/apps/desktop/test/standardTool.test.ts b/apps/desktop/test/standardTool.test.ts index de98411c..4e46561a 100644 --- a/apps/desktop/test/standardTool.test.ts +++ b/apps/desktop/test/standardTool.test.ts @@ -20,7 +20,7 @@ describe('the standards the assistant looks up', () => { 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(feet).toContain('None of this has been printed by PartBuddy yet, and the print check does not test these rules'); 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 }); From afaf18d278278117c815eccddff8273e63387a20 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:46:57 +0100 Subject: [PATCH 11/24] fix(desktop): a block is built only where the part has the plastic its cuts need Audit R54: the blocks named what the surrounding plastic must be only in their answer and built without measuring. A keyhole and a magnet pocket are now measured on trial before anything is cut (the pocket with what must stay round and under it, laid in as a probe body), the seat of an insert is checked by the volume it takes, and a block that leaves the body in pieces is taken back, each with the measured value. What is still not measured (wood screw, the wall round an insert) says so in the answer. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/agents/blockTool.ts | 93 +++++++++++++++++-- apps/desktop/src/shared/magnet.ts | 13 ++- apps/desktop/src/shared/wallMount.ts | 11 +++ apps/desktop/test/blockTool.test.ts | 104 ++++++++++++++++++++-- 4 files changed, 204 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/main/agents/blockTool.ts b/apps/desktop/src/main/agents/blockTool.ts index 467d7ac6..07102fe3 100644 --- a/apps/desktop/src/main/agents/blockTool.ts +++ b/apps/desktop/src/main/agents/blockTool.ts @@ -1,8 +1,8 @@ import { BLOCK_SIZES, defaultBlock, needsNormal, sizesOf, stepsOf, type BlockSize, type FeatureBlock } from '../../shared/blocks'; import { featureBlockSchema } from '../../shared/contract'; import { KNOB, KNOB_CLEARANCE, KNOB_SHAFTS, knobFlat, knobFlutes, knobInsert, knobProblem, knobSteps, type KnobShaft } from '../../shared/knob'; -import { MAGNET, MAGNET_HOLDS, magnetPocket, magnetProblem, magnetSteps, type MagnetHold } from '../../shared/magnet'; -import { KEYHOLE, WALL, WOOD_SCREWS, keyholeDepth, keyholeProblem, keyholeSteps, woodScrewSteps, type WoodScrew } from '../../shared/wallMount'; +import { MAGNET, MAGNET_HOLDS, magnetPocket, magnetProblem, magnetSteps, magnetStock, type MagnetHold } from '../../shared/magnet'; +import { KEYHOLE, WALL, WOOD_SCREWS, keyholeDepth, keyholeProblem, keyholeSteps, keyholeStock, woodScrewSteps, type WoodScrew } from '../../shared/wallMount'; import { componentOf } from '../solidcraft/actions'; import { count } from '../solidcraft/mirror'; import type { ToolResult, UpstreamTool, UpstreamTools } from './gateProxy'; @@ -100,8 +100,18 @@ export function blockOf(args: Record): FeatureBlock | string { type Step = { command: string; params: Record }; +/** Plastic a block needs where it cuts (audit R54): a cylinder from `base` along `axis` that must lie wholly in the body. */ +export interface Stock { + /** The point the model gave, for the answer. */ + at: Vec3; + base: Vec3; + axis: Vec3; + diameter: number; + depth: number; +} + /** A wall mounting the model asked for: its steps and what to say; null when the block is none of them; or what is wrong. */ -export function wallOf(args: Record): { body: string; steps: Step[]; text: string } | string | null { +export function wallOf(args: Record): { body: string; steps: Step[]; text: string; stock?: Stock[]; need?: string } | string | null { if (args.block !== 'keyhole' && args.block !== 'woodScrew') return null; const body = typeof args.body === 'string' ? args.body.trim() : ''; const point = vec3(args.point); @@ -116,7 +126,7 @@ export function wallOf(args: Record): { body: string; steps: St return { body, steps: woodScrewSteps({ body, point, more: more as Vec3[], screw, name: 'Wall screw' }), - text: `Built ${String(count)} countersunk hole${count > 1 ? 's' : ''} for ${String(screw)} mm wood screws in ${body}: ${String(size.clearance)} mm through, countersink ${String(size.countersink)} mm at 90°, so the head lies flush. Keep at least ${String(screw)} mm of plastic between a hole and an edge, and the plate thicker than the countersink is deep (${String(Math.round(((size.countersink - size.clearance) / 2) * 10) / 10)} mm).`, + text: `Built ${String(count)} countersunk hole${count > 1 ? 's' : ''} for ${String(screw)} mm wood screws in ${body}: ${String(size.clearance)} mm through, countersink ${String(size.countersink)} mm at 90°, so the head lies flush. Not checked: keep at least ${String(screw)} mm of plastic between a hole and an edge, and the plate thicker than the countersink is deep (${String(Math.round(((size.countersink - size.clearance) / 2) * 10) / 10)} mm).`, }; } const normal = vec3(args.normal); @@ -129,6 +139,8 @@ export function wallOf(args: Record): { body: string; steps: St return { body, steps: keyholeSteps(spec), + stock: keyholeStock(spec), + need: `a keyhole needs the part ${String(keyholeDepth(screw))} mm thick behind its entry and its slot. Thicken the part there, move the keyhole away from edges and other cuts, or use woodScrew`, text: `Built ${String(count)} keyhole slot${count > 1 ? 's' : ''} for ${String(screw)} mm wood screws in ${body}: entry ${String(size.entry)} mm for the head, a slot ${String(size.slot)} mm wide running ${String(KEYHOLE.travel)} mm up to where the screw rests, ` + `and behind its ${String(KEYHOLE.lip)} mm lip a pocket ${String(size.pocket)} mm deep for the head. The part must be at least ${String(keyholeDepth(screw))} mm thick there. ` + @@ -137,7 +149,7 @@ export function wallOf(args: Record): { body: string; steps: St } /** Magnet pockets the model asked for: their steps and what to say; null when the block is none; or what is wrong. */ -export function magnetOf(args: Record): { body: string; steps: Step[]; text: string } | string | null { +export function magnetOf(args: Record): { body: string; steps: Step[]; text: string; stock: Stock[]; need: string } | string | null { if (args.block !== 'magnet') return null; const body = typeof args.body === 'string' ? args.body.trim() : ''; const point = vec3(args.point); @@ -157,10 +169,12 @@ export function magnetOf(args: Record): { body: string; steps: return { body, steps: magnetSteps(spec), + stock: magnetStock(spec), + need: `a pocket needs ${String(MAGNET.around)} mm of plastic round it and ${String(MAGNET.under)} mm under it. Thicken the part there, move the pocket away from edges and other cuts, or take a thinner magnet`, text: `Built ${String(count)} pocket${count > 1 ? 's' : ''} for ${String(spec.diameter)} × ${String(spec.height)} mm disc magnets in ${body}: ${String(pocket.diameter)} mm wide and ${String(pocket.depth)} mm deep, so the magnet lies ${String(MAGNET.sunk)} mm under the face, with a chamfered mouth. ` + (hold === 'glue' ? 'The magnet drops in and is held by a drop of superglue. ' : `The magnet is pressed in (${String(MAGNET.room.press)} mm of room on the diameter); with more than that missing the plastic splits along its layers, so try one pocket first. `) + - `Keep at least 1.2 mm of plastic round a pocket and 0.6 mm under it. Mark the poles before gluing a pair: the two parts must attract. Neodymium magnets lose strength above 80 °C. No pocket of these sizes has been printed yet.`, + `Keep at least ${String(MAGNET.around)} mm of plastic round a pocket and ${String(MAGNET.under)} mm under it. Mark the poles before gluing a pair: the two parts must attract. Neodymium magnets lose strength above 80 °C. No pocket of these sizes has been printed yet.`, }; } @@ -226,6 +240,9 @@ function volumesOf(measured: unknown): Map { /** Why a block would not land on the body it names, from the volumes before and after a trial; null when it does. */ export function misplaced(body: string, before: Map, after: Map): string | null { if (!before.has(body)) return `there is no body named ${body}; the design has ${[...before.keys()].join(', ') || 'no body'}`; + // SolidCraft makes each loose piece a body of its own (seen with 0.40.1: "Strip" and "Strip (1)"). + const pieces = [...after.keys()].filter((name) => !before.has(name)); + if (pieces.length > 0) return `it would leave ${String(pieces.length + 1)} bodies where ${body} was one (${pieces.join(', ')}): the cut goes right through the part there. Give a point with more plastic round it, or a smaller size`; const changed = (name: string) => Math.abs((after.get(name) ?? 0) - (before.get(name) ?? 0)) > 1e-6; const others = [...new Set([...before.keys(), ...after.keys()])].filter((name) => name !== body && changed(name)); if (others.length > 0) return `at this point it would change ${others.join(', ')}, not only ${body}; give a point on a face of ${body} alone, away from other bodies`; @@ -233,6 +250,49 @@ export function misplaced(body: string, before: Map, after: Map< return null; } +/** The name of the bodies that measure the plastic at a block; they are never kept. */ +const PROBE = 'PartBuddy probe'; + +/** + * Where the body has less plastic than a block's cuts need, measured before anything is built (audit R54): each + * stock cylinder is laid in as a body on trial and SolidCraft says how much of it lies in the part. A step that must + * fail ends the batch, and SolidCraft takes the probes back (as mechanismTool.ts measures a hinge line). Null when + * all of it is plastic; `undefined` when it could not be measured. + */ +export async function shortOf(upstream: UpstreamTools, body: string, stock: Stock[], callId: string | undefined): Promise { + try { + const answer = await upstream.callTool( + 'batch', + { + commands: [ + ...stock.flatMap((room, i) => [ + { command: 'solid.cylinder', params: { diameter: `${String(room.diameter)} mm`, height: `${String(room.depth)} mm`, base: room.base, axis: room.axis, operation: 'new', body_name: `${PROBE} ${String(i)}` } }, + { command: 'inspect.interference', params: { bodies: [body, `${PROBE} ${String(i)}`] } }, + ]), + { command: 'sketch.inspect', params: { sketch: 2147483647 } }, + ], + rollback: true, + }, + callId, + ); + const text = answer.content.find((part) => part.type === 'text')?.text; + const results = (JSON.parse(typeof text === 'string' ? text : '') as { results?: unknown[] }).results; + if (!Array.isArray(results) || results.length < stock.length * 2) return undefined; + for (const [i, room] of stock.entries()) { + const found = (results[i * 2 + 1] as { interferences?: { volume_mm3?: unknown }[] } | undefined)?.interferences; + if (!Array.isArray(found)) return undefined; + const area = (Math.PI * room.diameter ** 2) / 4; + const plastic = found.reduce((sum, pair) => sum + (typeof pair.volume_mm3 === 'number' ? pair.volume_mm3 : 0), 0); + // A thousandth of the cylinder is the noise of the measure, not a missing wall. + if (plastic < area * room.depth * 0.999) + return `at [${room.at.join(', ')}] ${body} has ${String(Math.round((plastic / area) * 100) / 100)} mm of plastic where ${String(room.depth)} mm are needed (the mean over a width of ${String(room.diameter)} mm; the far side, an edge or another cut takes the rest)`; + } + return null; + } catch { + return undefined; + } +} + /** What was built, with the sizes it took from the tables, for the model to tell the user. */ function built(block: FeatureBlock): string { const { block: kind, body, point, ...sizes } = block; @@ -278,12 +338,26 @@ export function withBlockTool(upstream: UpstreamTools, keepFace: (body: string, const before = volumesOf(measured); const missing = fresh ? null : misplaced(body, before, before); if (missing?.startsWith('there is no body')) return { content: [{ type: 'text', text: `Not built: ${missing}` }], isError: true }; + // A pocket that comes out of the far side or of an edge is not built at all. + const { stock, need } = (wall ?? {}) as { stock?: Stock[]; need?: string }; + const short = stock ? await shortOf(upstream, body, stock, callId) : null; + if (short) return { content: [{ type: 'text', text: `Not built: ${short}; ${need ?? ''}.` }], isError: true }; + // A seat is a blind hole: cut out of solid plastic it takes its whole cylinder from the body. + const seat = block?.block === 'insertSeat' ? { volume: ((Math.PI * block.diameter ** 2) / 4) * block.depth * ((block.more?.length ?? 0) + 1), area: (Math.PI * block.diameter ** 2) / 4, depth: block.depth } : null; let results: unknown[]; try { results = await run([...sequence, measure]); // The point decides what SolidCraft cuts, so the named body is checked before the block stays. - const wrong = fresh ? null : misplaced(body, before, volumesOf(results[results.length - 1])); + const after = volumesOf(results[results.length - 1]); + const wrong = fresh ? null : misplaced(body, before, after); if (wrong) throw new Error(wrong); + const cut = (before.get(body) ?? NaN) - (after.get(body) ?? NaN); + if (seat && cut < seat.volume * 0.999) + throw new Error( + `the seat${block && 'more' in block && block.more?.length ? 's' : ''} would cut ${String(Math.round(cut * 10) / 10)} mm³ of plastic where a blind hole ${String(seat.depth)} mm deep takes ${String(Math.round(seat.volume * 10) / 10)} mm³: it comes out of the far side or of an edge` + + (block && !('more' in block && block.more?.length) ? ` (${String(Math.round((cut / seat.area) * 100) / 100)} mm of plastic at the point)` : '') + + '. Thicken the part there, or use a boss', + ); } catch (cause) { // Nothing of a block that failed half way or landed on another body stays: undone step by step, as the work // log goes back. (A batch with `rollback` or a checkpoint is no way back in SolidCraft 0.40.1: after the one @@ -299,7 +373,10 @@ export function withBlockTool(upstream: UpstreamTools, keepFace: (body: string, const warning = (result as { warning?: unknown } | undefined)?.warning; return typeof warning === 'string' ? [warning.slice(0, 200)] : []; }); - return { content: [{ type: 'text', text: `${wall ? wall.text : block ? built(block) : ''}${warnings.length > 0 ? ` SolidCraft warns: ${warnings.join(' ')}` : ''}` }] }; + // What was to be measured and was not is said, never passed over. + const stockSaid = !stock ? '' : short === null ? ' PartBuddy measured that plastic before building: it is there.' : ' PartBuddy could not measure that plastic this time: check it yourself.'; + const said = (wall ? wall.text : block ? built(block) : '') + (seat ? ' Measured: the seat is a blind hole cut out of solid plastic. Not checked: the plastic left round and under it.' : ''); + return { content: [{ type: 'text', text: `${said}${stockSaid}${warnings.length > 0 ? ` SolidCraft warns: ${warnings.join(' ')}` : ''}` }] }; } catch (cause) { return { content: [{ type: 'text', text: `Not built: ${(cause as Error).message}` }], isError: true }; } diff --git a/apps/desktop/src/shared/magnet.ts b/apps/desktop/src/shared/magnet.ts index 112c1f00..62dd3425 100644 --- a/apps/desktop/src/shared/magnet.ts +++ b/apps/desktop/src/shared/magnet.ts @@ -12,9 +12,10 @@ export type MagnetHold = (typeof MAGNET_HOLDS)[number]; /** * In mm: what is added to the magnet's diameter (to drop it in on a drop of glue, or to press it in), - * how far it lies under the face so nothing stands proud, and the chamfer at the pocket's mouth. + * how far it lies under the face so nothing stands proud, the chamfer at the pocket's mouth, and the + * plastic that must stay round a pocket and under it (three lines and three layers of a 0.4 mm nozzle). */ -export const MAGNET = { room: { glue: 0.3, press: 0.1 }, sunk: 0.2, chamfer: 0.4 }; +export const MAGNET = { room: { glue: 0.3, press: 0.1 }, sunk: 0.2, chamfer: 0.4, around: 1.2, under: 0.6 }; export interface MagnetSpec { body: string; @@ -37,6 +38,14 @@ export function magnetPocket(spec: Pick round(-value / length) + 0) as Vec3; + const pocket = magnetPocket(spec); + return [spec.point, ...spec.more].map((point) => ({ at: point, base: point.map(round) as Vec3, axis, diameter: round(pocket.diameter + 2 * MAGNET.around), depth: round(pocket.depth + MAGNET.under) })); +} + /** What is wrong with a magnet pocket before anything is built; null when it can be built. */ export function magnetProblem(spec: MagnetSpec): string | null { if (Math.hypot(...spec.normal) < 1e-9) return 'normal must not be zero'; diff --git a/apps/desktop/src/shared/wallMount.ts b/apps/desktop/src/shared/wallMount.ts index 2d2001b2..83d2d8cd 100644 --- a/apps/desktop/src/shared/wallMount.ts +++ b/apps/desktop/src/shared/wallMount.ts @@ -55,6 +55,17 @@ export function keyholeDepth(screw: WoodScrew): number { return round(KEYHOLE.lip + WALL[screw].pocket + KEYHOLE.behind); } +/** The plastic each keyhole needs: as thick as {@link keyholeDepth} behind the entry and behind where the screw rests. */ +export function keyholeStock(spec: KeyholeSpec): { at: Vec3; base: Vec3; axis: Vec3; diameter: number; depth: number }[] { + const [n, up] = [unit(spec.normal), unit(spec.up)]; + const axis = n.map((value) => round(-value) + 0) as Vec3; + const room = { axis, diameter: WALL[spec.screw].entry, depth: keyholeDepth(spec.screw) }; + return [spec.point, ...spec.more].flatMap((rest) => [ + { at: rest, base: rest.map(round) as Vec3, ...room }, + { at: rest, base: rest.map((value, k) => round(value - (up[k] ?? 0) * KEYHOLE.travel)) as Vec3, ...room }, + ]); +} + /** What is wrong with a keyhole before anything is built; null when it can be built. */ export function keyholeProblem(spec: KeyholeSpec): string | null { if (Math.hypot(...spec.normal) < 1e-9 || Math.hypot(...spec.up) < 1e-9) return 'normal and up must not be zero'; diff --git a/apps/desktop/test/blockTool.test.ts b/apps/desktop/test/blockTool.test.ts index 9ec8f8fd..792b2892 100644 --- a/apps/desktop/test/blockTool.test.ts +++ b/apps/desktop/test/blockTool.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { blockOf, knobOf, magnetOf, misplaced, wallOf, withBlockTool } from '../src/main/agents/blockTool'; -import type { UpstreamTools } from '../src/main/agents/gateProxy'; +import { blockOf, knobOf, magnetOf, misplaced, shortOf, wallOf, withBlockTool } from '../src/main/agents/blockTool'; +import type { ToolResult, UpstreamTools } from '../src/main/agents/gateProxy'; import { decide } from '../src/main/agents/policy'; import { KNOB, knobProblem, knobSteps, type KnobSpec } from '../src/shared/knob'; -import { MAGNET, magnetPocket, magnetSteps } from '../src/shared/magnet'; +import { MAGNET, magnetPocket, magnetSteps, magnetStock } from '../src/shared/magnet'; import { describeToolCall } from '../src/shared/stepPhrases'; -import { KEYHOLE, WALL, keyholeDepth, keyholeProblem, keyholeSteps, woodScrewSteps, type KeyholeSpec } from '../src/shared/wallMount'; +import { KEYHOLE, WALL, keyholeDepth, keyholeProblem, keyholeSteps, keyholeStock, woodScrewSteps, type KeyholeSpec } from '../src/shared/wallMount'; describe('the function blocks for the assistant (standards)', () => { it('fills a block in from the standard tables and lays over it only what the model gave', () => { @@ -48,8 +48,8 @@ describe('the function blocks for the assistant (standards)', () => { expect(hole.content[0]).toMatchObject({ text: 'Built 2 × screwHole on Plate at [10, 10, 6]: size M3, head cap, fit normal.' }); const key = await tools.callTool('partbuddy_block', { block: 'keyhole', body: 'Plate', point: [20, 0, 80], normal: [0, -1, 0] }); expect(String(key.content[0]?.text)).toContain('Built 1 keyhole slot for 4 mm wood screws in Plate: entry 9 mm for the head, a slot 4.5 mm wide running 8 mm up to where the screw rests, and behind its 2 mm lip a pocket 3.5 mm deep for the head. The part must be at least 6.7 mm thick there.'); - // The holes need no sketch; the keyhole's sketches are drawn with the plate's component active. - expect(batches).toEqual(['solid.hole solid.hole', 'component.list', 'component.activate solid.cylinder sketch.create sketch.slot.center_to_center solid.extrude sketch.create sketch.slot.center_to_center solid.extrude component.activate']); + // The holes need no sketch; the keyhole's plastic is measured on trial, then its sketches are drawn with the plate's component active. + expect(batches).toEqual(['solid.hole solid.hole', 'component.list', 'solid.cylinder inspect.interference solid.cylinder inspect.interference sketch.inspect', 'component.activate solid.cylinder sketch.create sketch.slot.center_to_center solid.extrude sketch.create sketch.slot.center_to_center solid.extrude component.activate']); expect(await tools.callTool('partbuddy_block', { block: 'keyhole', body: 'Plate', point: [20, 0, 80] })).toMatchObject({ isError: true, content: [{ text: 'Not built: keyhole needs normal: the way out of the face against the wall, three numbers' }] }); expect(decide('partbuddy_block', { block: 'screwHole' })).toEqual({ allow: true }); expect(describeToolCall('partbuddy_block', { block: 'keyhole', screw: 4.5 })).toEqual([{ key: 'keyhole', values: { screw: 4.5 } }]); @@ -158,7 +158,7 @@ describe('pockets for disc magnets (standards)', () => { expect(steps[1]?.params).toMatchObject({ diameter: '7.1 mm', top_diameter: '6.3 mm', height: '0.4 mm', base: [10, 10, 0], axis: [0, 0, 1], operation: 'cut' }); expect(steps[2]?.params).toMatchObject({ base: [30, 10, 0] }); expect(magnetPocket({ diameter: 10, height: 3, hold: 'press' })).toEqual({ diameter: 10.1, depth: 3.2 }); - expect(MAGNET).toEqual({ room: { glue: 0.3, press: 0.1 }, sunk: 0.2, chamfer: 0.4 }); + expect(MAGNET).toEqual({ room: { glue: 0.3, press: 0.1 }, sunk: 0.2, chamfer: 0.4, around: 1.2, under: 0.6 }); }); it('fills a pocket in from the standard, says how the magnet is held and refuses what is no magnet', () => { @@ -262,3 +262,93 @@ describe('a block that lands on another body is taken back (audit W1)', () => { expect(String((await withBlockTool(upstream).callTool('partbuddy_block', { block: 'screwHole', body: 'Ghost', point: [30, 10, 20] })).content[0]?.text)).toBe('Not built: there is no body named Ghost; the design has A, B'); }); }); + +describe('a block is built only where the part has the plastic for it (audit R54)', () => { + /** A plate `thick` mm thick, as SolidCraft 0.40.1 answers: the probe's share in the plate, each loose piece a body of its own. */ + function plate(thick: number, split = false) { + const batches: string[] = []; + let built = 0; + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve([]), + callTool: (_name, args) => { + const commands = args.commands as { command: string; params: { diameter?: string; height?: string } }[]; + batches.push(commands.map((step) => step.command).join(' ')); + const trial = args.rollback === true; + let probe = { diameter: 0, height: 0 }; + const results = commands.map((step) => { + if (trial && step.command === 'solid.cylinder') probe = { diameter: parseFloat(step.params.diameter ?? ''), height: parseFloat(step.params.height ?? '') }; + if (step.command === 'inspect.interference') return { interferences: [{ a: 'Plate', b: 'PartBuddy probe 0', volume_mm3: ((Math.PI * probe.diameter ** 2) / 4) * Math.min(probe.height, thick) }] }; + if (!trial && (step.command === 'solid.hole' || step.command === 'solid.cylinder')) built += 1; + if (step.command === 'edit.undo') built -= 1; + // A blind hole of 4 mm takes what plastic there is, the plate's thickness at most. + if (step.command === 'inspect.measure') return { bodies: [{ name: 'Plate', volume_mm3: 4800 - built * 4 * Math.PI * Math.min(6.7, thick) }, ...(split && built > 0 ? [{ name: 'Plate (1)', volume_mm3: 100 }] : [])] }; + if (step.command === 'document.inspect') return { undo: 1 + built }; + return {}; + }); + // The trial ends on a step that must fail; SolidCraft answers it as an error that carries the results before it. + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify(trial ? { results: results.slice(0, -1), failed: { command: 'sketch.inspect' } } : { results }) }], ...(trial ? { isError: true } : {}) }); + }, + }; + return { tools: withBlockTool(upstream), upstream, batches }; + } + const text = async (answer: Promise) => String((await answer).content[0]?.text); + + it('names the plastic a keyhole and a magnet pocket need: as deep as the cut with what must stay behind it', () => { + const key: KeyholeSpec = { body: 'Plate', point: [20, 30, 0], normal: [0, 0, -1], up: [0, 1, 0], screw: 4, more: [], name: 'Keyhole' }; + // Behind where the screw rests and behind the entry 8 mm below it. + expect(keyholeStock(key)).toEqual([ + { at: [20, 30, 0], base: [20, 30, 0], axis: [0, 0, 1], diameter: 9, depth: 6.7 }, + { at: [20, 30, 0], base: [20, 22, 0], axis: [0, 0, 1], diameter: 9, depth: 6.7 }, + ]); + // A 6 × 2 mm magnet: 6.3 + 2 × 1.2 wide, 2.2 + 0.6 deep. + expect(magnetStock({ body: 'Plate', point: [10, 10, 3], normal: [0, 0, 2], more: [[30, 10, 3]], diameter: 6, height: 2, hold: 'glue', name: 'Magnet' })).toEqual([ + { at: [10, 10, 3], base: [10, 10, 3], axis: [0, 0, -1], diameter: 8.7, depth: 2.8 }, + { at: [30, 10, 3], base: [30, 10, 3], axis: [0, 0, -1], diameter: 8.7, depth: 2.8 }, + ]); + }); + + it('builds no keyhole in a 3 mm plate and no magnet pocket through it, and says what it measured', async () => { + const { tools, batches } = plate(3); + expect(await text(tools.callTool('partbuddy_block', { block: 'keyhole', body: 'Plate', point: [20, 30, 0], normal: [0, 0, -1], up: [0, 1, 0] }))).toBe( + 'Not built: at [20, 30, 0] Plate has 3 mm of plastic where 6.7 mm are needed (the mean over a width of 9 mm; the far side, an edge or another cut takes the rest); a keyhole needs the part 6.7 mm thick behind its entry and its slot. Thicken the part there, move the keyhole away from edges and other cuts, or use woodScrew.', + ); + expect(await text(tools.callTool('partbuddy_block', { block: 'magnet', body: 'Plate', point: [20, 10, 3], normal: [0, 0, 1], height: 3 }))).toContain( + 'Not built: at [20, 10, 3] Plate has 3 mm of plastic where 3.8 mm are needed (the mean over a width of 8.7 mm', + ); + // Nothing but trials reached SolidCraft: no cut, so nothing to undo. + expect(batches.filter((batch) => batch.includes('solid.cylinder')).every((batch) => batch.endsWith('sketch.inspect'))).toBe(true); + expect(batches.join(' ')).not.toContain('solid.extrude'); + }); + + it('builds them where the plastic is, and says that it measured', async () => { + const { tools } = plate(10); + expect(await text(tools.callTool('partbuddy_block', { block: 'magnet', body: 'Plate', point: [20, 10, 10], normal: [0, 0, 1] }))).toMatch(/^Built 1 pocket .* PartBuddy measured that plastic before building: it is there\.$/); + }); + + it('says so when the plastic could not be measured, instead of passing it over', async () => { + const silent: UpstreamTools = { instructions: () => undefined, listTools: () => Promise.resolve([]), callTool: () => Promise.resolve({ content: [{ type: 'text', text: 'no' }], isError: true }) }; + expect(await shortOf(silent, 'Plate', [{ at: [0, 0, 0], base: [0, 0, 0], axis: [0, 0, 1], diameter: 9, depth: 6.7 }], undefined)).toBeUndefined(); + }); + + it('takes back the seat of an insert that comes out of the far side', async () => { + const thin = plate(3); + expect(await text(thin.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [10, 10, 3] }))).toBe( + 'Not built: the seat would cut 37.7 mm³ of plastic where a blind hole 6.7 mm deep takes 84.2 mm³: it comes out of the far side or of an edge (3 mm of plastic at the point). Thicken the part there, or use a boss', + ); + expect(thin.batches.at(-1)).toBe('edit.undo'); + const thick = plate(10); + expect(await text(thick.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [10, 10, 10] }))).toBe( + 'Built insertSeat on Plate at [10, 10, 10]: size M3, diameter 4, depth 6.7. Measured: the seat is a blind hole cut out of solid plastic. Not checked: the plastic left round and under it.', + ); + }); + + it('takes back a block that cuts the body into pieces', async () => { + expect(misplaced('Strip', new Map([['Strip', 720]]), new Map([['Strip', 300], ['Strip (1)', 300]]))).toBe( + 'it would leave 2 bodies where Strip was one (Strip (1)): the cut goes right through the part there. Give a point with more plastic round it, or a smaller size', + ); + const { tools, batches } = plate(10, true); + expect(await text(tools.callTool('partbuddy_block', { block: 'screwHole', body: 'Plate', point: [10, 10, 10], head: 'none' }))).toContain('Not built: it would leave 2 bodies where Plate was one (Plate (1))'); + expect(batches.at(-1)).toBe('edit.undo'); + }); +}); From c13327e4e0fb5a7e65db8624967f5e5c259b3e1a Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:52:30 +0100 Subject: [PATCH 12/24] feat(desktop): the assistant designs for material and print time and sees what a change saved Reported 2026-11-12: the user had to ask for print time and material to be optimised every time. A fifth design rule group (on by default, switched off in the settings or with partbuddy_design_rules) tells the assistant what the shape decides of both, with the wall and layer sizes of the printer in use, and what stays the slicer's. The print check now carries per body the solid volume, the height and layers as it prints, the area on the bed and the area that leans past the overhang limit, so the numbers come with every check. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/e2e/app.spec.ts | 2 +- apps/desktop/src/main/agents/designTool.ts | 3 +- apps/desktop/src/main/print/printCheckNote.ts | 24 +++++++- apps/desktop/src/main/print/printProfiles.ts | 13 +++- apps/desktop/src/renderer/src/i18n/de.json | 4 ++ apps/desktop/src/renderer/src/i18n/en.json | 4 ++ .../src/renderer/src/print/bodyMeasures.ts | 51 ++++++++++++++++ apps/desktop/src/renderer/src/print/report.ts | 6 +- .../src/renderer/src/print/usePrintCheck.ts | 5 +- apps/desktop/src/shared/contract.ts | 21 +++++-- apps/desktop/test/bodyMeasures.test.ts | 60 +++++++++++++++++++ apps/desktop/test/designRules.test.ts | 39 +++++++++++- 12 files changed, 215 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/src/renderer/src/print/bodyMeasures.ts create mode 100644 apps/desktop/test/bodyMeasures.test.ts diff --git a/apps/desktop/e2e/app.spec.ts b/apps/desktop/e2e/app.spec.ts index a17bcf47..baf7bc46 100644 --- a/apps/desktop/e2e/app.spec.ts +++ b/apps/desktop/e2e/app.spec.ts @@ -1411,7 +1411,7 @@ test('the part material is chosen in the status bar and the profiles are edited await supportFree.press('Space'); await expect(supportFree).not.toBeChecked(); // The design rule groups (2026-11-07) are on from the start; each turns off on its own. - for (const rule of ['rounding', 'printInPlace', 'details', 'snap']) await expect(assistant.getByTestId(`design-${rule}`).getByRole('switch')).toBeChecked(); + for (const rule of ['rounding', 'printInPlace', 'details', 'snap', 'economy']) await expect(assistant.getByTestId(`design-${rule}`).getByRole('switch')).toBeChecked(); const rounding = assistant.getByTestId('design-rounding').getByRole('switch', { name: 'Fillets and chamfers' }); await rounding.press('Space'); await expect(rounding).not.toBeChecked(); diff --git a/apps/desktop/src/main/agents/designTool.ts b/apps/desktop/src/main/agents/designTool.ts index 1d8e5606..abe67751 100644 --- a/apps/desktop/src/main/agents/designTool.ts +++ b/apps/desktop/src/main/agents/designTool.ts @@ -16,7 +16,7 @@ const designTool: UpstreamTool = { name: DESIGN_TOOL, description: 'Record that the user turned a group of the design rules off or back on for this project, e.g. "no fillets" → ' + - 'rounding false, "screw the hinge instead" → printInPlace false. Only for what the user said; leave out the groups ' + + 'rounding false, "screw the hinge instead" → printInPlace false, "make it solid" → economy false. Only for what the user said; leave out the groups ' + 'they did not mention.', inputSchema: { type: 'object', @@ -25,6 +25,7 @@ const designTool: UpstreamTool = { printInPlace: choice('Print-in-place hinges, joints and clips'), details: choice('Refined details (hole chamfers, sunk screw heads, lead-ins, even walls)'), snap: choice('Sizes snapped to nozzle and layer height'), + economy: choice('Saving material and print time (shells and ribs instead of solid blocks, low build height, no needless support)'), }, additionalProperties: false, }, diff --git a/apps/desktop/src/main/print/printCheckNote.ts b/apps/desktop/src/main/print/printCheckNote.ts index 73adc8f8..62738384 100644 --- a/apps/desktop/src/main/print/printCheckNote.ts +++ b/apps/desktop/src/main/print/printCheckNote.ts @@ -75,6 +75,26 @@ const CHECKED = /** What a clean check says nothing about (audit R3); the model must not pass the verdict on as more. */ const NOT_CHECKED = 'Not checked: strength and layer direction under load, the fits and hole allowances you chose, details and text too fine to print, bed-edge chamfers. Judge those yourself.'; +/** Bodies listed with their measures before the rest is counted. */ +const MAX_BODIES = 6; + +/** + * What each body costs to print as far as its shape decides it, one line per body: the numbers the model compares + * before and after a change. No grams and no minutes: PartBuddy does not know the slicer's infill, walls or speed. + */ +function measureLines(report: PrintCheckReport): string[] { + const bodies = report.bodies ?? []; + if (bodies.length === 0) return []; + const rest = bodies.length - MAX_BODIES + (report.bodiesOmitted ?? 0); + return [ + "Per body as it prints (solid volume; the slicer's infill, walls and speed decide grams and time, so promise neither):", + ...bodies + .slice(0, MAX_BODIES) + .map((body) => `- ${JSON.stringify(body.name)}: ${String(body.volume)} cm³, ${mm(body.height)} high (${String(body.layers)} layers), ${String(body.contact)} mm² on the bed, ${String(body.support)} mm² leaning past the overhang limit.`), + ...(rest > 0 ? [`- and ${String(rest)} more ${rest === 1 ? 'body' : 'bodies'}.`] : []), + ]; +} + /** * The ready-to-print check (M7) as the model reads it: PartBuddy checks after every revision, the * model reads it behind the answer of each tool that changed the design, and with the next message @@ -84,13 +104,13 @@ const NOT_CHECKED = 'Not checked: strength and layer direction under load, the f export function printCheckNote(report: PrintCheckReport): string { const head = '[PartBuddy: ready-to-print check of the part as it is now (each body lying on its print face when it has one, else as designed along +Z on its own lowest point; overhangs by angle only):'; - if (report.findings.length === 0) return `${head} nothing found in what it checks (${CHECKED}). ${NOT_CHECKED}]`; + if (report.findings.length === 0) return [`${head} nothing found in what it checks (${CHECKED}).`, ...measureLines(report), `${NOT_CHECKED}]`].join('\n'); const verdict = report.status === 'block' ? 'not ready to print' : report.status === 'warn' ? 'prints, with warnings' : 'ready to print'; const lines = report.findings.slice(0, 12).map((finding) => line(finding, report.nozzle)); // What does not fit the note is counted, never dropped without a word. const rest = report.findings.length - lines.length + (report.omitted ?? 0); if (rest > 0) lines.push(`- and ${String(rest)} more ${rest === 1 ? 'finding' : 'findings'} not listed here; the user sees them in the ready-to-print panel.`); - return [`${head} ${verdict}.`, ...lines, `Fix what blocks printing before you call the part done; the user sees the same findings. ${NOT_CHECKED}]`].join('\n'); + return [`${head} ${verdict}.`, ...lines, ...measureLines(report), `Fix what blocks printing before you call the part done; the user sees the same findings. ${NOT_CHECKED}]`].join('\n'); } /** Said in place of a check that has not caught up with the design yet. */ diff --git a/apps/desktop/src/main/print/printProfiles.ts b/apps/desktop/src/main/print/printProfiles.ts index 9bc4c0e1..8246dd94 100644 --- a/apps/desktop/src/main/print/printProfiles.ts +++ b/apps/desktop/src/main/print/printProfiles.ts @@ -247,10 +247,11 @@ const RULE_NAMES: Record = { printInPlace: 'print-in-place joints', details: 'refined details', snap: 'sizes snapped to nozzle and layer', + economy: 'saving material and print time', }; /** - * What a careful FDM designer does without being asked (decided 2026-11-07), in four groups the user + * What a careful FDM designer does without being asked (decided 2026-11-07), in five groups the user * can turn off: in the settings for every project, in the chat for one. The sizes come from the * printer in use, so they fit its nozzle and layer height. Sizes the user names are above all of it. */ @@ -283,11 +284,17 @@ export function designLines(design: DesignRules, printer: FitPrinter, material: lines.push( `- Sizes you choose yourself snap up to the printer: walls and ribs to whole lines of ${mm(line)} (${series(line, 2, 5)}), floors, lids, steps and chamfer heights to whole layers of ${mm(layer)} (${series(layer, 3, 6)}), floors and lids at least ${mm(minimumFloor(layer))}, print-in-place gaps ${mm(gap)}, radii and details at least ${mm(unitSize('nozzle', printer))}. Mark each such parameter's comment with the multiple, e.g. "Wall [auto 3×line]", "Floor [auto 5×layer]", "Hinge gap [auto 1×nozzle]", so PartBuddy fits it again when the user changes printer or nozzle. Sizes the user named get no mark.`, ); + // Material and print time (2026-11-12): what the shape decides of them, by the makers' design guides (Prusa, Protolabs + // Network, Markforged, Stratasys Direct); what the slicer decides is named as not the assistant's to promise. + if (design.economy) + lines.push( + `- Material and print time, from the first shape on and not as a clean-up: build a shell, not a block. Slicers print ${series(line, 2, 4)} (two to four lines) as wall and fill anything thicker with sparse infill, so stiffen with ribs about half to three quarters of the wall thick, in whole lines, instead of thickening the wall. Keep the part low as it prints: every ${mm(layer)} of height is one more layer, and print time grows with the layers. Shape it so it needs no support, which costs more time and material than any other choice. Leave out whole regions that do nothing (an open frame instead of a plate, a shorter body); small pockets and holes in a filled region add walls and save little or nothing. Split a part only when that saves support or much height and a joint is acceptable. Loaded regions, sizes the user names and what the user wants solid stay as they are. Infill, speed and layer height are set in the slicer: never promise grams or minutes. The print check lists each body's solid volume, height, layers and support area: compare them before and after a change.`, + ); if (off.length > 0) lines.push(`- The user turned off ${off.map((rule) => RULE_NAMES[rule]).join(', ')}: do not add them unless the user asks for them.`); lines.push( - '- After each design step, name what you added on your own and the sizes you snapped in one short line, e.g. "Outer edges 2 mm rounded, hinge print-in-place with 0.4 mm gap, lid sliding fit 0.3 mm", so the user can turn it off or change it.', - `- When the user turns one of these off or back on ("no fillets", "screw the hinge instead"), call partbuddy_design_rules, so it holds for the rest of the project, also after a restart.`, + `- After each design step, name what you added on your own${design.economy ? ', what you did for material and print time' : ''} and the sizes you snapped in one short line, e.g. "Outer edges 2 mm rounded, hinge print-in-place with 0.4 mm gap, lid sliding fit 0.3 mm${design.economy ? `, shell ${mm(line * 3)} with two ribs instead of a solid block` : ''}", so the user can turn it off or change it.`, + `- When the user turns one of these off or back on ("no fillets", "screw the hinge instead", "make it solid"), call partbuddy_design_rules, so it holds for the rest of the project, also after a restart.`, ); return lines; } diff --git a/apps/desktop/src/renderer/src/i18n/de.json b/apps/desktop/src/renderer/src/i18n/de.json index 51f499fa..f4a37985 100644 --- a/apps/desktop/src/renderer/src/i18n/de.json +++ b/apps/desktop/src/renderer/src/i18n/de.json @@ -1032,6 +1032,10 @@ "snap": { "label": "Maße einrasten", "hint": "Selbst gewählte Wände auf ganze Linienbreiten, Böden und Höhen auf ganze Schichten. Bei einem Druckerwechsel passt PartBuddy nur diese Maße an." + }, + "economy": { + "label": "Sparsam konstruieren", + "hint": "Spart Material und Druckzeit: Schalen mit Rippen statt massiver Blöcke, geringe Bauhöhe, keine unnötigen Stützen. Funktion und Festigkeit gehen vor; Füllung und Geschwindigkeit bleiben Sache deines Slicers." } }, "add": "Drucker hinzufügen", diff --git a/apps/desktop/src/renderer/src/i18n/en.json b/apps/desktop/src/renderer/src/i18n/en.json index 6d087fdf..e5effee3 100644 --- a/apps/desktop/src/renderer/src/i18n/en.json +++ b/apps/desktop/src/renderer/src/i18n/en.json @@ -1032,6 +1032,10 @@ "snap": { "label": "Snap sizes", "hint": "Walls it chooses become whole line widths, floors and heights whole layers. When you change printer, PartBuddy adjusts only these sizes." + }, + "economy": { + "label": "Design lean", + "hint": "Saves material and print time: shells with ribs instead of solid blocks, low build height, no needless support. Function and strength come first; infill and speed stay with your slicer." } }, "add": "Add printer", diff --git a/apps/desktop/src/renderer/src/print/bodyMeasures.ts b/apps/desktop/src/renderer/src/print/bodyMeasures.ts new file mode 100644 index 00000000..338ca180 --- /dev/null +++ b/apps/desktop/src/renderer/src/print/bodyMeasures.ts @@ -0,0 +1,51 @@ +import { Overhang, meshMetrics, overhangLimits, overhangOf, type PrintCheckBody, type PrintCheckProfile } from '@partbuddy/geometry'; +import { Quaternion, Vector3 } from 'three'; + +import type { PrintCheckReport } from '../../../shared/contract'; + +export type BodyMeasures = NonNullable[number]; + +const round = (value: number, digits: number) => Math.round(value * 10 ** digits) / 10 ** digits; + +/** + * What a body costs to print as far as its shape decides it: solid volume, height and layers as it + * lies on the bed, the area on the bed and the area that leans past the material's limit. The + * assistant reads them with every check, so it sees what a change saved without being asked to + * measure. Grams and minutes are not among them: infill, walls and speed are the slicer's. + * shortcut: the pose (print face down) and the reach of small overhangs repeat what printCheck.ts + * and overhang.ts of packages/geometry keep to themselves (`laid`, `smallOverhangReach`); take them + * from there once the package exports them. + */ +export function bodyMeasures(body: PrintCheckBody, profile: Pick): BodyMeasures { + const { model } = body; + const down = body.printFace ? model.table.faces.find((face) => face.name === body.printFace)?.planeNormal : undefined; + const turn = down ? new Quaternion().setFromUnitVectors(new Vector3(...down).normalize(), new Vector3(0, 0, -1)) : null; + const v = new Vector3(); + const laid = (source: ArrayLike) => { + const out = new Float32Array(source.length); + for (let i = 0; i < source.length; i += 3) { + v.set(source[i] ?? 0, source[i + 1] ?? 0, source[i + 2] ?? 0); + if (turn) v.applyQuaternion(turn); + out.set([v.x, v.y, v.z], i); + } + return out; + }; + const positions = laid(model.geometry.getAttribute('position').array); + const index = (model.geometry.index?.array ?? new Uint32Array()) as Uint32Array; + const { volumeMm3, bbox } = meshMetrics({ name: body.name, faceCount: model.table.faces.length, positions: Float64Array.from(positions), indices: index, triangleFaces: model.triangleFaces }); + const height = Math.max(0, bbox.max[2] - bbox.min[2]); + const map = overhangOf( + { positions, normals: laid(model.geometry.getAttribute('normal').array), index, vertexFaces: model.vertexFaces, faceCount: model.table.faces.length }, + bbox.min[2], + overhangLimits(profile.overhang), + Math.round(4 * 1.125 * profile.nozzle * 100) / 100, + ); + return { + name: body.name, + volume: round(Math.abs(volumeMm3) / 1000, 2), + height: round(height, 1), + layers: profile.layer > 0 ? Math.ceil(height / profile.layer - 1e-6) : 0, + contact: round(map.area[Overhang.Bed], 0), + support: round(map.area[Overhang.Support], 0), + }; +} diff --git a/apps/desktop/src/renderer/src/print/report.ts b/apps/desktop/src/renderer/src/print/report.ts index 392b0d94..3bfed0c5 100644 --- a/apps/desktop/src/renderer/src/print/report.ts +++ b/apps/desktop/src/renderer/src/print/report.ts @@ -2,12 +2,14 @@ import type { PrintCheck } from '@partbuddy/geometry'; import type { PrintCheckReport } from '../../../shared/contract'; -/** The check without the camera boxes, as the main process words it for the assistant. */ -export function reportOf(result: PrintCheck, nozzle: number, revision: number): PrintCheckReport { +/** The check without the camera boxes, as the main process words it for the assistant; `bodies`: what each body costs to print. */ +export function reportOf(result: PrintCheck, nozzle: number, revision: number, bodies: NonNullable = []): PrintCheckReport { return { status: result.status, nozzle, revision, + ...(bodies.length > 0 ? { bodies: bodies.slice(0, 40) } : {}), + ...(bodies.length > 40 ? { bodiesOmitted: bodies.length - 40 } : {}), ...(result.findings.length > 40 ? { omitted: result.findings.length - 40 } : {}), findings: result.findings.slice(0, 40).map((finding) => { if (finding.kind === 'size' || finding.kind === 'feature' || finding.kind === 'posed') return finding; diff --git a/apps/desktop/src/renderer/src/print/usePrintCheck.ts b/apps/desktop/src/renderer/src/print/usePrintCheck.ts index 321e6d8a..1483d9a7 100644 --- a/apps/desktop/src/renderer/src/print/usePrintCheck.ts +++ b/apps/desktop/src/renderer/src/print/usePrintCheck.ts @@ -4,6 +4,7 @@ import { useEffect } from 'react'; import { platform } from '../platform'; import { useStudio } from '../state/store'; +import { bodyMeasures } from './bodyMeasures'; import { reportOf } from './report'; /** Quiet time after a revision before the check runs, so a burst of steps is checked once. */ @@ -37,9 +38,11 @@ export function usePrintCheck(): void { const parts = printed.filter((body) => !isLogoColourBody(body.name)); const measuring = parts.length >= 2 || joints.length > 0; useStudio.getState().setPrintCheck({ revision: model.revision, result: joints.length > 0 ? withMotion(result, { joints, stops: [] }) : result, ...(measuring ? { measuring } : {}) }); + // What each closed body costs to print, for the assistant to see what a change saved. + const measures = printed.filter((body) => body.model.table.faces.length > 0 && !(body.model.openEdges > 0)).map((body) => bodyMeasures(body, { nozzle: print.printer.nozzle, layer: print.printer.layer, overhang: print.material.overhang })); const report = (final: typeof result) => { void platform() - .print.reportCheck(reportOf(final, print.printer.nozzle, model.revision)) + .print.reportCheck(reportOf(final, print.printer.nozzle, model.revision, measures)) .catch(() => undefined); }; if (!measuring) { diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index 8e970a86..18f192b9 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -1081,16 +1081,17 @@ const uniqueIds = (items: readonly { id: string }[]) => new Set(items.map((item) /** * The groups of design rules the assistant applies without being asked (decided 2026-11-07): - * rounded and chamfered edges, print-in-place joints, clean details and sizes snapped to nozzle and - * layer. The user turns a group off in the settings, or for one project in the chat. + * rounded and chamfered edges, print-in-place joints, clean details, sizes snapped to nozzle and + * layer, and (2026-11-12) material and print time. The user turns a group off in the settings, or + * for one project in the chat. */ -export const DESIGN_RULES = ['rounding', 'printInPlace', 'details', 'snap'] as const; +export const DESIGN_RULES = ['rounding', 'printInPlace', 'details', 'snap', 'economy'] as const; export type DesignRule = (typeof DESIGN_RULES)[number]; export const designRulesSchema = z - .object({ rounding: z.boolean().default(true), printInPlace: z.boolean().default(true), details: z.boolean().default(true), snap: z.boolean().default(true) }) + .object({ rounding: z.boolean().default(true), printInPlace: z.boolean().default(true), details: z.boolean().default(true), snap: z.boolean().default(true), economy: z.boolean().default(true) }) .strict(); export type DesignRules = z.infer; -export const DESIGN_DEFAULTS: DesignRules = { rounding: true, printInPlace: true, details: true, snap: true }; +export const DESIGN_DEFAULTS: DesignRules = { rounding: true, printInPlace: true, details: true, snap: true, economy: true }; export const printSettingsSchema = z .object({ @@ -1165,6 +1166,16 @@ export const printCheckReportSchema = z nozzle: range(0.1, 2), /** The mirrored revision the check belongs to, so a check of an older revision reads as pending. */ revision: z.number().int().nonnegative(), + /** + * Per printed body, as it lies on the bed: solid volume in cm³, height in mm and its layers, mm² on the bed and + * mm² leaning past the material's limit. What the shape decides of material and print time; the rest is the slicer's. + */ + bodies: z + .array(z.object({ name: z.string().max(200), volume: range(0, 1e9), height: range(0, 1e6), layers: z.number().int().nonnegative(), contact: range(0, 1e9), support: range(0, 1e9) }).strict()) + .max(40) + .optional(), + /** How many more bodies the design has than `bodies` carries. */ + bodiesOmitted: z.number().int().positive().max(100_000).optional(), }) .strict(); diff --git a/apps/desktop/test/bodyMeasures.test.ts b/apps/desktop/test/bodyMeasures.test.ts new file mode 100644 index 00000000..2ccf3c79 --- /dev/null +++ b/apps/desktop/test/bodyMeasures.test.ts @@ -0,0 +1,60 @@ +import { buildRenderMesh, buildViewerModel, type FaceTable, type PrintCheckBody, type TaggedMesh } from '@partbuddy/geometry'; +import { describe, expect, it } from 'vitest'; + +import { printCheckNote } from '../src/main/print/printCheckNote'; +import { bodyMeasures } from '../src/renderer/src/print/bodyMeasures'; +import { reportOf } from '../src/renderer/src/print/report'; +import { printCheckReportSchema } from '../src/shared/contract'; + +/** A box as SolidCraft exports it: six named faces with their normals, outward triangles. */ +function box(name: string, size: [number, number, number]): PrintCheckBody { + const [x, y, z] = size; + const corners = [[0, 0, 0], [x, 0, 0], [x, y, 0], [0, y, 0], [0, 0, z], [x, 0, z], [x, y, z], [0, y, z]]; + const quads = [[0, 3, 2, 1], [4, 5, 6, 7], [0, 1, 5, 4], [1, 2, 6, 5], [2, 3, 7, 6], [3, 0, 4, 7]]; + const faces: [string, [number, number, number]][] = [['F1:-z', [0, 0, -1]], ['F1:+z', [0, 0, 1]], ['F1:-y', [0, -1, 0]], ['F1:+x', [1, 0, 0]], ['F1:+y', [0, 1, 0]], ['F1:-x', [-1, 0, 0]]]; + const mesh: TaggedMesh = { + name, + faceCount: 6, + positions: Float64Array.from(corners.flat()), + indices: Uint32Array.from(quads.flatMap(([a, b, c, d]) => [a, b, c, a, c, d] as number[])), + triangleFaces: Uint32Array.from(quads.flatMap((_, face) => [face, face])), + }; + const table: FaceTable = { model: 'test', body: name, units: 'mm', faces: faces.map(([faceName, planeNormal], index) => ({ index, name: faceName, exact: true, area: 0, centroid: [0, 0, 0], point: [0, 0, 0], planeNormal })) }; + return { name, model: buildViewerModel(buildRenderMesh(mesh, table), table) }; +} + +const PLA = { nozzle: 0.4, layer: 0.2, overhang: 45 }; + +describe('what a body costs to print, as the assistant reads it with every check (2026-11-12)', () => { + it('measures a 20 × 30 × 10 mm box as designed: 6 cm³, 50 layers, 600 mm² on the bed, nothing to support', () => { + expect(bodyMeasures(box('Box', [20, 30, 10]), PLA)).toEqual({ name: 'Box', volume: 6, height: 10, layers: 50, contact: 600, support: 0 }); + // A coarser layer: fewer layers for the same height. + expect(bodyMeasures(box('Box', [20, 30, 10]), { ...PLA, layer: 0.3 }).layers).toBe(34); + }); + + it('measures it as it lies on its print face: stood on its small side it is twice as high', () => { + expect(bodyMeasures({ ...box('Box', [20, 30, 10]), printFace: 'F1:-x' }, PLA)).toEqual({ name: 'Box', volume: 6, height: 20, layers: 100, contact: 300, support: 0 }); + }); + + it('goes into the note one line per body, with what it is not: grams and minutes', () => { + const report = reportOf({ status: 'ready', findings: [] }, 0.4, 3, [bodyMeasures(box('Box', [20, 30, 10]), PLA)]); + expect(printCheckReportSchema.safeParse(report).success).toBe(true); + const note = printCheckNote(report); + expect(note).toContain("Per body as it prints (solid volume; the slicer's infill, walls and speed decide grams and time, so promise neither):"); + expect(note).toContain('- "Box": 6 cm³, 10 mm high (50 layers), 600 mm² on the bed, 0 mm² leaning past the overhang limit.'); + // A check without measures (an older renderer, no closed body) says nothing of them. + expect(printCheckNote({ status: 'ready', findings: [], nozzle: 0.4, revision: 3 })).not.toContain('Per body'); + }); + + it('lists six bodies and counts the rest, also what the report itself left out', () => { + const many = Array.from({ length: 45 }, (_, i) => ({ name: `Pin ${String(i + 1)}`, volume: 0.1, height: 5, layers: 25, contact: 20, support: 0 })); + const report = reportOf({ status: 'warn', findings: [{ kind: 'open', severity: 'warn', bodies: ['Shell'] }] }, 0.4, 3, many); + expect(report.bodies).toHaveLength(40); + expect(report.bodiesOmitted).toBe(5); + expect(printCheckReportSchema.safeParse(report).success).toBe(true); + const note = printCheckNote(report); + expect(note).toContain('- "Pin 6": 0.1 cm³'); + expect(note).not.toContain('"Pin 7"'); + expect(note).toContain('- and 39 more bodies.'); + }); +}); diff --git a/apps/desktop/test/designRules.test.ts b/apps/desktop/test/designRules.test.ts index 403d69c2..86edd27f 100644 --- a/apps/desktop/test/designRules.test.ts +++ b/apps/desktop/test/designRules.test.ts @@ -28,6 +28,7 @@ const MARKERS: Record = { printInPlace: 'first look for a print-in-place solution', details: 'chamfer hole and screw entries', snap: 'Sizes you choose yourself snap up to the printer', + economy: 'Material and print time, from the first shape on', }; const small = { nozzle: 0.4, layer: 0.2 }; @@ -39,7 +40,7 @@ describe('design rules in the print note (decided 2026-11-07)', () => { const note = printNote(profiles.part()); for (const marker of Object.values(MARKERS)) expect(note).toContain(marker); expect(note).toContain('Sizes the user names are fixed: build them exactly'); - expect(note).toContain('name what you added on your own and the sizes you snapped in one short line'); + expect(note).toContain('name what you added on your own, what you did for material and print time and the sizes you snapped in one short line'); expect(note).toContain('walls and ribs to whole lines of 0.45 mm (0.9, 1.35, 1.8, 2.25 mm)'); expect(note).toContain('to whole layers of 0.2 mm (0.6, 0.8, 1, 1.2 mm)'); expect(note).toContain('print-in-place gaps 0.4 mm'); @@ -89,7 +90,7 @@ describe('design rules in the print note (decided 2026-11-07)', () => { expect(profiles.part().design).toMatchObject({ rounding: false, details: false }); // The chat turns rounding back on for this project; details keep the setting. profiles.setDesign({ rounding: true, printInPlace: false }); - expect(profiles.part().design).toEqual({ rounding: true, printInPlace: false, details: false, snap: true }); + expect(profiles.part().design).toEqual({ rounding: true, printInPlace: false, details: false, snap: true, economy: true }); // null hands the group back to the setting. profiles.setDesign({ rounding: null }); expect(profiles.part().design.rounding).toBe(false); @@ -99,6 +100,40 @@ describe('design rules in the print note (decided 2026-11-07)', () => { }); }); +describe('material and print time as a rule group of its own (2026-11-12)', () => { + it('is on by default, from the first shape on, with walls and layers of the printer in use', () => { + const { profiles } = setup(); + const note = printNote(profiles.part()); + // 0.4 mm nozzle, 0.2 mm layer: lines of 0.45 mm. + expect(note).toContain('Slicers print 0.9, 1.35, 1.8 mm (two to four lines) as wall and fill anything thicker with sparse infill'); + expect(note).toContain('every 0.2 mm of height is one more layer'); + expect(note).toContain('shell 1.35 mm with two ribs instead of a solid block'); + // It never outranks function, and it promises nothing the slicer decides. + expect(note).toContain('Loaded regions, sizes the user names and what the user wants solid stay as they are.'); + expect(note).toContain('never promise grams or minutes'); + expect(note).toContain('A refinement never makes function or printing worse'); + const settings = profiles.settings(); + profiles.save({ ...settings, printers: settings.printers.map((printer) => ({ ...printer, nozzle: 0.6, layer: 0.3 })) }); + const wide = printNote(profiles.part()); + expect(wide).toContain('Slicers print 1.35, 2.025, 2.7 mm (two to four lines) as wall'); + expect(wide).toContain('every 0.3 mm of height is one more layer'); + }); + + it('stays off once the user turned it off in the chat, and the report line asks for nothing of it', async () => { + const { profiles, make } = setup(); + const tools = withDesignTool({ instructions: () => undefined, listTools: () => Promise.resolve([]), callTool: () => Promise.reject(new Error('upstream')) }, (change) => profiles.setDesign(change).design); + const reply = await tools.callTool(DESIGN_TOOL, { economy: false }); + expect(reply.content[0]).toMatchObject({ text: 'Kept for this project. On: rounding, printInPlace, details, snap. Off: economy.' }); + // After a restart and after another group changed, it is still off. + make().setDesign({ rounding: false }); + const note = printNote(make().part()); + expect(note).not.toContain(MARKERS.economy); + expect(note).toContain('The user turned off rounded and chamfered edges, saving material and print time'); + expect(note).toContain('name what you added on your own and the sizes you snapped in one short line'); + expect(note).not.toContain('instead of a solid block'); + }); +}); + describe('sizes fitted to nozzle and layer', () => { it('snaps the assistant\'s sizes for 0.4 / 0.2', () => { expect(snapSize('wall', 1.2, small)).toBe(1.35); From b159c1276c18bd9c6f9e13441cd6a1e2cddbac34 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:33:35 +0100 Subject: [PATCH 13/24] fix(desktop): the check after a change comes at once, once, and only when the design changed Review of PR 357. With two bodies or a joint the mesh check now goes ahead of the clearance and joint measures and is told as preliminary, so no step waits for SolidCraft's measuring (waits set from timings at the engine). A call counts as a change by the undo history, not by the revision that trials and saves move too. The same verdict is told in full once per turn and then in one line. The drawing tools are changing tools. A body that cannot be measured no longer holds the report back, and the measures say what solid volume overstates and carry a shell estimate. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/agents/workLog.ts | 15 +- apps/desktop/src/main/index.ts | 11 +- apps/desktop/src/main/print/printCheckNote.ts | 135 +++++++--- .../src/renderer/src/print/bodyMeasures.ts | 45 +++- apps/desktop/src/renderer/src/print/report.ts | 4 +- .../src/renderer/src/print/usePrintCheck.ts | 14 +- apps/desktop/src/shared/contract.ts | 15 +- apps/desktop/src/shared/stepPhrases.ts | 2 + apps/desktop/test/bodyMeasures.test.ts | 54 +++- apps/desktop/test/checkOfChange.test.ts | 237 +++++++++++++++--- 10 files changed, 441 insertions(+), 91 deletions(-) diff --git a/apps/desktop/src/main/agents/workLog.ts b/apps/desktop/src/main/agents/workLog.ts index d6ada6a5..fbaff460 100644 --- a/apps/desktop/src/main/agents/workLog.ts +++ b/apps/desktop/src/main/agents/workLog.ts @@ -20,10 +20,11 @@ export interface WorkLogOptions { emit(event: AgentEvent): void; /** The answer of a call that moved the design's revision, as the model gets it (the print check goes behind it). */ changed?(result: ToolResult, after: DesignHistory): Promise; + log?(line: string): void; } /** Tools that may change the design; every other tool only reads and is not tracked. */ -export const CHANGING_TOOLS = new Set(['execute', 'batch', 'set_parameter', 'undo', 'redo', 'partbuddy_mechanism', 'partbuddy_block']); +export const CHANGING_TOOLS = new Set(['execute', 'batch', 'set_parameter', 'undo', 'redo', 'partbuddy_mechanism', 'partbuddy_block', 'partbuddy_place_drawing', 'partbuddy_move_drawing']); /** Tools that move through the history instead of adding to it. */ const HISTORY_TOOLS = new Set(['undo', 'redo']); @@ -75,7 +76,9 @@ export class WorkLog { } else { this.publish(); } - return { result, moved: before && after && (before.documentId !== after.documentId || before.revision !== after.revision) ? after : null }; + // Without the history nobody knows whether the design changed: said in the log, since the model then gets no check. + if (!before || !after) this.options.log?.(`the design's history could not be read round ${name}; its answer carries no print check`); + return { result, moved: before && after && designChanged(before, after) ? after : null }; }); // Outside the queue: the user's own changes do not wait for the check. return moved && this.options.changed ? this.options.changed(result, moved) : result; @@ -211,6 +214,14 @@ export class WorkLog { } } +/** + * True when the call left another design behind: the undo depth moved, or a change took the place of what Redo held. + * The revision alone says nothing: a trial, a save and a block that was taken back move it and leave the design as it was. + */ +export function designChanged(before: DesignHistory, after: DesignHistory): boolean { + return before.documentId !== after.documentId || before.undo !== after.undo || (before.redo > 0 && after.redo === 0); +} + /** True when the history grew or a new change replaced what Redo could have brought back. */ export function addedToHistory(before: DesignHistory, after: DesignHistory): boolean { if (before.documentId !== after.documentId || before.revision === after.revision) return false; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 8aaf5f92..85706ade 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -57,7 +57,7 @@ import { readVersion } from './solidcraft/engineVersion'; import { safeDesignFileName } from './solidcraft/headless'; import { longPath, SolidCraftSupervisor } from './solidcraft/supervisor'; import { ChatArchive } from './chat/chatArchive'; -import { CHECK_PENDING, printCheckNote, withCheckOfChange } from './print/printCheckNote'; +import { CHECK_PENDING_VIEW, CheckTold, printCheckNote, withCheckOfChange } from './print/printCheckNote'; import { templatePrintFaces, templateSteps } from '../shared/templates'; import { testPieceSteps } from '../shared/testPieces'; import { sketchNote } from './agents/sketchNote'; @@ -371,10 +371,13 @@ if (!app.requestSingleInstanceLock()) { // The check as partbuddy_view shows it: a check of an older revision is still catching up. const checkForView = () => { if (!printCheck) return null; - if (printCheck.revision !== supervisor.currentRevision()?.revision) return CHECK_PENDING; + if (printCheck.revision !== supervisor.currentRevision()?.revision) return CHECK_PENDING_VIEW; return printCheckNote(printCheck); }; + // What the tool answers of this turn already said of the check: the same verdict is not told in full again. + const checkTold = new CheckTold(); bridge.handle(invokeChannels.printReportCheck, (report) => { + if (report.problem) log(`print check of revision ${String(report.revision)}: ${report.problem}`); printCheck = report; return undefined; }); @@ -550,7 +553,8 @@ if (!app.requestSingleInstanceLock()) { travel: (command, count) => supervisor.travel(command, count), emit: emitEvent, // The check of each change goes back with the tool's answer, so no turn ends on a part the model never saw checked. - changed: (result, after) => withCheckOfChange(result, after, () => ({ mirrored: supervisor.currentRevision(), report: printCheck })), + changed: (result, after) => withCheckOfChange(result, after, () => ({ mirrored: supervisor.currentRevision(), report: printCheck }), (report) => checkTold.note(report)), + log, }); workLog = steps; // Questions and plans as cards (M2a); changes wait until the user answered. @@ -730,6 +734,7 @@ if (!app.requestSingleInstanceLock()) { }); const conversation = agent.conversation(); const sentAt = new Date(); + checkTold.forget(); const lines = [ // Once after a restart: what the earlier conversation about this design was about. chats?.takeDigest() ?? null, diff --git a/apps/desktop/src/main/print/printCheckNote.ts b/apps/desktop/src/main/print/printCheckNote.ts index 62738384..04920e31 100644 --- a/apps/desktop/src/main/print/printCheckNote.ts +++ b/apps/desktop/src/main/print/printCheckNote.ts @@ -69,9 +69,12 @@ function line(finding: Finding, nozzle: number): string { } } -/** What the check looks at (the finding kinds of printCheck.ts, withClearance and withMotion), so "nothing found" claims no more. */ -const CHECKED = - 'build volume, wall and floor thickness, overhang angle, area on the bed, open bodies, failed features, bodies that touch, overlap or come closer than a push fit, joints that stop before a limit'; +/** What the mesh check looks at at once (the finding kinds of printCheck.ts), so "nothing found" claims no more. */ +const CHECKED_MESH = 'build volume, wall and floor thickness, overhang angle, area on the bed, open bodies, failed features, bodies that touch'; +/** What SolidCraft measures after it (withClearance, withMotion): it takes longer and comes with the full check. */ +const CHECKED_MEASURED = 'bodies that overlap or come closer than a push fit, joints that stop before a limit'; +/** Said with a check whose measures are still out (two bodies or more, or a joint). */ +const PRELIMINARY = `Preliminary: clearance and joints still being measured (${CHECKED_MEASURED}); call partbuddy_view for the full check before you call the part done.`; /** What a clean check says nothing about (audit R3); the model must not pass the verdict on as more. */ const NOT_CHECKED = 'Not checked: strength and layer direction under load, the fits and hole allowances you chose, details and text too fine to print, bed-edge chamfers. Judge those yourself.'; @@ -79,46 +82,106 @@ const NOT_CHECKED = 'Not checked: strength and layer direction under load, the f const MAX_BODIES = 6; /** - * What each body costs to print as far as its shape decides it, one line per body: the numbers the model compares - * before and after a change. No grams and no minutes: PartBuddy does not know the slicer's infill, walls or speed. + * Why the numbers are there and what they are not. Solid volume alone would mislead: turning a block into a shell + * with ribs takes most of it away, and little of the filament, since the slicer fills a block sparsely anyway. The + * shell estimate is two wall lines on every surface and 15 % infill inside (Prusa and Protolabs Network name 10 to + * 20 % as usual): an assumption, since PartBuddy does not know the slicer's settings. */ -function measureLines(report: PrintCheckReport): string[] { - const bodies = report.bodies ?? []; +const MEASURES_HEAD = + 'Per body as it prints. Solid volume overstates what thinning a thick region saves, because the slicer fills it sparsely anyway; the shell estimate ' + + '(two wall lines on every surface, 15 % infill inside: an assumption, not the slicer\'s settings) is nearer to the filament. What counts for sure: fewer layers, less area to support, less surface. Promise neither grams nor minutes:'; + +/** A volume as the model reads it: under 1 cm³ in mm³, so a small part still shows what a change saved. */ +const volume = (mm3: number) => (mm3 < 1000 ? `${String(Math.round(mm3))} mm³` : `${String(Math.round(mm3 / 10) / 100)} cm³`); + +type Body = NonNullable[number]; + +/** One body's measures: the numbers the model compares before and after a change. */ +function measureLine(body: Body): string { + return `- ${JSON.stringify(body.name)}: ${volume(body.volume)} solid, about ${volume(body.shell)} as a shell, ${mm(body.height)} high (${String(body.layers)} layers), ${String(body.surface)} mm² of surface, ${String(body.contact)} mm² on the bed, ${String(body.support)} mm² leaning past the overhang limit.`; +} + +function measureLines(report: PrintCheckReport, head = MEASURES_HEAD, only: (body: Body) => boolean = () => true): string[] { + const bodies = (report.bodies ?? []).slice(0, MAX_BODIES).filter(only); if (bodies.length === 0) return []; - const rest = bodies.length - MAX_BODIES + (report.bodiesOmitted ?? 0); - return [ - "Per body as it prints (solid volume; the slicer's infill, walls and speed decide grams and time, so promise neither):", - ...bodies - .slice(0, MAX_BODIES) - .map((body) => `- ${JSON.stringify(body.name)}: ${String(body.volume)} cm³, ${mm(body.height)} high (${String(body.layers)} layers), ${String(body.contact)} mm² on the bed, ${String(body.support)} mm² leaning past the overhang limit.`), - ...(rest > 0 ? [`- and ${String(rest)} more ${rest === 1 ? 'body' : 'bodies'}.`] : []), - ]; + const rest = (report.bodies ?? []).length - MAX_BODIES + (report.bodiesOmitted ?? 0); + return [head, ...bodies.map(measureLine), ...(rest > 0 ? [`- and ${String(rest)} more ${rest === 1 ? 'body' : 'bodies'}.`] : [])]; +} + +/** The findings as lines; what does not fit is counted, never dropped without a word. */ +function findingLines(report: PrintCheckReport): string[] { + const lines = report.findings.slice(0, 12).map((finding) => line(finding, report.nozzle)); + const rest = report.findings.length - lines.length + (report.omitted ?? 0); + if (rest > 0) lines.push(`- and ${String(rest)} more ${rest === 1 ? 'finding' : 'findings'} not listed here; the user sees them in the ready-to-print panel.`); + return lines; } /** * The ready-to-print check (M7) as the model reads it: PartBuddy checks after every revision, the * model reads it behind the answer of each tool that changed the design, and with the next message - * when the result changed. Face and feature names come - * from the design, so they are quoted. + * when the result changed. Face and feature names come from the design, so they are quoted. */ export function printCheckNote(report: PrintCheckReport): string { const head = '[PartBuddy: ready-to-print check of the part as it is now (each body lying on its print face when it has one, else as designed along +Z on its own lowest point; overhangs by angle only):'; - if (report.findings.length === 0) return [`${head} nothing found in what it checks (${CHECKED}).`, ...measureLines(report), `${NOT_CHECKED}]`].join('\n'); + const preliminary = report.measuring ? [PRELIMINARY] : []; + if (report.findings.length === 0) + return [`${head} nothing found ${report.measuring ? `so far (${CHECKED_MESH})` : `in what it checks (${CHECKED_MESH}, ${CHECKED_MEASURED})`}.`, ...preliminary, ...measureLines(report), `${NOT_CHECKED}]`].join('\n'); const verdict = report.status === 'block' ? 'not ready to print' : report.status === 'warn' ? 'prints, with warnings' : 'ready to print'; - const lines = report.findings.slice(0, 12).map((finding) => line(finding, report.nozzle)); - // What does not fit the note is counted, never dropped without a word. - const rest = report.findings.length - lines.length + (report.omitted ?? 0); - if (rest > 0) lines.push(`- and ${String(rest)} more ${rest === 1 ? 'finding' : 'findings'} not listed here; the user sees them in the ready-to-print panel.`); - return [`${head} ${verdict}.`, ...lines, ...measureLines(report), `Fix what blocks printing before you call the part done; the user sees the same findings. ${NOT_CHECKED}]`].join('\n'); + return [`${head} ${verdict}.`, ...findingLines(report), ...preliminary, ...measureLines(report), `Fix what blocks printing before you call the part done; the user sees the same findings. ${NOT_CHECKED}]`].join('\n'); } -/** Said in place of a check that has not caught up with the design yet. */ +/** + * The check behind the tool answers of one turn (review of PR 357): in full the first time and whenever the verdict + * or a finding changed, else as one line with the measures that changed. Thirty steps of a build would otherwise + * carry thirty copies of the same note. + */ +export class CheckTold { + private verdict: string | null = null; + private readonly bodies = new Map(); + + /** A new message of the user: the next check is told in full again. */ + forget(): void { + this.verdict = null; + this.bodies.clear(); + } + + note(report: PrintCheckReport): string { + const verdict = JSON.stringify([report.status, report.measuring === true, findingLines(report)]); + const same = verdict === this.verdict; + const changed = (body: Body) => this.bodies.get(body.name) !== measureLine(body); + const short = same + ? [ + `[PartBuddy: ready-to-print check after this change: unchanged, ${ + report.findings.length === 0 + ? 'nothing found' + : `${report.status === 'block' ? 'still not ready to print, ' : ''}the same ${String(report.findings.length + (report.omitted ?? 0))} ${report.findings.length + (report.omitted ?? 0) === 1 ? 'finding' : 'findings'} as before` + }${report.measuring ? '; clearance and joints still being measured' : ''}.`, + ...measureLines(report, 'Measures that changed:', changed), + ].join('\n') + ']' + : printCheckNote(report); + this.verdict = verdict; + this.bodies.clear(); + for (const body of report.bodies ?? []) this.bodies.set(body.name, measureLine(body)); + return short; + } +} + +/** Said behind a tool's answer in place of a check that has not caught up with the design yet. */ export const CHECK_PENDING = '[PartBuddy: the ready-to-print check of the latest change is still running; call partbuddy_view for it before you call the part done.]'; +/** The same from partbuddy_view itself, which must not send a small model round in circles. */ +export const CHECK_PENDING_VIEW = '[PartBuddy: the ready-to-print check of the latest change is still running; it will be here in a moment.]'; -/** How long a changing tool's answer waits for the check of what it built. */ -export const CHECK_WAIT_MS = 5000; +/** + * How long a changing tool's answer waits for the check of what it built, and how long of that for the full check + * once clearance and joints are measured as well. Measured 2026-11-12 on SolidCraft 0.40.1 without a window + * (tools/audit, check-timing): mirror and mesh check 0.21 s for a box with its lid and 0.14 s for a hinge, the + * hinge's two joint sweeps 0.5 s more; the renderer adds 0.25 s of quiet time. So the mesh check is there after + * about half a second and the full one after about one: three times that each. + */ +export const CHECK_WAIT_MS = 3000; +export const CHECK_FULL_MS = 1500; /** What PartBuddy holds right now: the revision the mirror last delivered and the check last reported. */ export interface CheckState { @@ -130,15 +193,25 @@ export interface CheckState { * The answer of a tool that changed the design, with the check of that change behind it (audit * R1): the model reads the findings in the turn that caused them, on every route, instead of being * asked to fetch them. The check runs in the renderer after the mirror, so the answer waits for - * it, but no longer than `waitMs`; after that it says the check is still running. + * it: up to `fullMs` for the full check, then it takes the mesh check and says that it is + * preliminary, and after `waitMs` without any it says the check is still running. */ -export async function withCheckOfChange(result: ToolResult, change: { documentId: string; revision: number }, state: () => CheckState, waitMs = CHECK_WAIT_MS): Promise { - const until = Date.now() + waitMs; +export async function withCheckOfChange( + result: ToolResult, + change: { documentId: string; revision: number }, + state: () => CheckState, + tell: (report: PrintCheckReport) => string = printCheckNote, + waitMs = CHECK_WAIT_MS, + fullMs = CHECK_FULL_MS, +): Promise { + const started = Date.now(); for (;;) { const { mirrored, report } = state(); // SolidCraft's revision also moves without a mirror (a save), so the mirror may be past the change. const checked = mirrored?.documentId === change.documentId && mirrored.revision >= change.revision && report?.revision === mirrored.revision ? report : null; - if (checked || Date.now() >= until) return { ...result, content: [...result.content, { type: 'text', text: checked ? printCheckNote(checked) : CHECK_PENDING }] }; - await new Promise((resolve) => setTimeout(resolve, 100)); + const waited = Date.now() - started; + if (checked && (!checked.measuring || waited >= fullMs)) return { ...result, content: [...result.content, { type: 'text', text: tell(checked) }] }; + if (waited >= waitMs) return { ...result, content: [...result.content, { type: 'text', text: checked ? tell(checked) : CHECK_PENDING }] }; + await new Promise((resolve) => setTimeout(resolve, 50)); } } diff --git a/apps/desktop/src/renderer/src/print/bodyMeasures.ts b/apps/desktop/src/renderer/src/print/bodyMeasures.ts index 338ca180..ef57ec2c 100644 --- a/apps/desktop/src/renderer/src/print/bodyMeasures.ts +++ b/apps/desktop/src/renderer/src/print/bodyMeasures.ts @@ -4,19 +4,25 @@ import { Quaternion, Vector3 } from 'three'; import type { PrintCheckReport } from '../../../shared/contract'; export type BodyMeasures = NonNullable[number]; +type Profile = Pick; const round = (value: number, digits: number) => Math.round(value * 10 ** digits) / 10 ** digits; +/** The infill the shell estimate reckons with: Prusa and Protolabs Network name 10 to 20 % as usual. An assumption, said as one. */ +const INFILL = 0.15; + /** * What a body costs to print as far as its shape decides it: solid volume, height and layers as it - * lies on the bed, the area on the bed and the area that leans past the material's limit. The + * lies on the bed, its surface, the area on the bed and the area that leans past the material's limit. The * assistant reads them with every check, so it sees what a change saved without being asked to - * measure. Grams and minutes are not among them: infill, walls and speed are the slicer's. + * measure. Grams and minutes are not among them: infill, walls and speed are the slicer's. The shell + * estimate comes nearer to filament than the solid volume: two wall lines on every surface and + * sparse infill in what is left, never more than the solid. * shortcut: the pose (print face down) and the reach of small overhangs repeat what printCheck.ts * and overhang.ts of packages/geometry keep to themselves (`laid`, `smallOverhangReach`); take them * from there once the package exports them. */ -export function bodyMeasures(body: PrintCheckBody, profile: Pick): BodyMeasures { +export function bodyMeasures(body: PrintCheckBody, profile: Profile): BodyMeasures { const { model } = body; const down = body.printFace ? model.table.faces.find((face) => face.name === body.printFace)?.planeNormal : undefined; const turn = down ? new Quaternion().setFromUnitVectors(new Vector3(...down).normalize(), new Vector3(0, 0, -1)) : null; @@ -40,12 +46,39 @@ export function bodyMeasures(body: PrintCheckBody, profile: Pick sum + area, 0); + const walls = Math.min(solid, surface * 2 * 1.125 * profile.nozzle); + const measures = { + // A name longer than the report takes is cut, not a reason to lose the report. + name: body.name.slice(0, 200), + volume: round(solid, 0), + shell: round(walls + (solid - walls) * INFILL, 0), height: round(height, 1), layers: profile.layer > 0 ? Math.ceil(height / profile.layer - 1e-6) : 0, + surface: round(surface, 0), contact: round(map.area[Overhang.Bed], 0), support: round(map.area[Overhang.Support], 0), }; + const odd = Object.entries(measures).find(([, value]) => typeof value === 'number' && !(Number.isFinite(value) && value >= 0)); + if (odd) throw new Error(`${odd[0]} is ${String(odd[1])}`); + return measures; +} + +/** + * The measures of every closed body. One that cannot be measured is left out and named in `problem`, for the log: + * the check itself must still reach the assistant, which otherwise waits for it with every step. + */ +export function measuresOf(bodies: readonly PrintCheckBody[], profile: Profile): { bodies: BodyMeasures[]; problem?: string } { + const measured: BodyMeasures[] = []; + const problems: string[] = []; + for (const body of bodies) { + if (body.model.table.faces.length === 0 || body.model.openEdges > 0) continue; + try { + measured.push(bodyMeasures(body, profile)); + } catch (error) { + problems.push(`${body.name.slice(0, 40)}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return { bodies: measured, ...(problems.length > 0 ? { problem: `no measures for ${problems.join('; ')}`.slice(0, 300) } : {}) }; } diff --git a/apps/desktop/src/renderer/src/print/report.ts b/apps/desktop/src/renderer/src/print/report.ts index 3bfed0c5..eae9ec40 100644 --- a/apps/desktop/src/renderer/src/print/report.ts +++ b/apps/desktop/src/renderer/src/print/report.ts @@ -3,11 +3,13 @@ import type { PrintCheck } from '@partbuddy/geometry'; import type { PrintCheckReport } from '../../../shared/contract'; /** The check without the camera boxes, as the main process words it for the assistant; `bodies`: what each body costs to print. */ -export function reportOf(result: PrintCheck, nozzle: number, revision: number, bodies: NonNullable = []): PrintCheckReport { +export function reportOf(result: PrintCheck, nozzle: number, revision: number, bodies: NonNullable = [], more: Pick = {}): PrintCheckReport { return { status: result.status, nozzle, revision, + ...(more.measuring ? { measuring: true } : {}), + ...(more.problem ? { problem: more.problem.slice(0, 300) } : {}), ...(bodies.length > 0 ? { bodies: bodies.slice(0, 40) } : {}), ...(bodies.length > 40 ? { bodiesOmitted: bodies.length - 40 } : {}), ...(result.findings.length > 40 ? { omitted: result.findings.length - 40 } : {}), diff --git a/apps/desktop/src/renderer/src/print/usePrintCheck.ts b/apps/desktop/src/renderer/src/print/usePrintCheck.ts index 1483d9a7..f82c3b35 100644 --- a/apps/desktop/src/renderer/src/print/usePrintCheck.ts +++ b/apps/desktop/src/renderer/src/print/usePrintCheck.ts @@ -4,7 +4,7 @@ import { useEffect } from 'react'; import { platform } from '../platform'; import { useStudio } from '../state/store'; -import { bodyMeasures } from './bodyMeasures'; +import { measuresOf } from './bodyMeasures'; import { reportOf } from './report'; /** Quiet time after a revision before the check runs, so a burst of steps is checked once. */ @@ -39,16 +39,22 @@ export function usePrintCheck(): void { const measuring = parts.length >= 2 || joints.length > 0; useStudio.getState().setPrintCheck({ revision: model.revision, result: joints.length > 0 ? withMotion(result, { joints, stops: [] }) : result, ...(measuring ? { measuring } : {}) }); // What each closed body costs to print, for the assistant to see what a change saved. - const measures = printed.filter((body) => body.model.table.faces.length > 0 && !(body.model.openEdges > 0)).map((body) => bodyMeasures(body, { nozzle: print.printer.nozzle, layer: print.printer.layer, overhang: print.material.overhang })); - const report = (final: typeof result) => { + const measures = measuresOf(printed, { nozzle: print.printer.nozzle, layer: print.printer.layer, overhang: print.material.overhang }); + const report = (final: typeof result, pending = false) => { + const more = { ...(pending ? { measuring: true } : {}), ...(measures.problem ? { problem: measures.problem } : {}) }; void platform() - .print.reportCheck(reportOf(final, print.printer.nozzle, model.revision, measures)) + .print.reportCheck(reportOf(final, print.printer.nozzle, model.revision, measures.bodies, more)) + // A report the main process does not take would leave the assistant waiting for a check that never comes: + // the check goes again without the measures, and says why. + .catch((error: unknown) => platform().print.reportCheck(reportOf(final, print.printer.nozzle, model.revision, [], { ...more, problem: `report refused, sent again without measures: ${String(error)}` }))) .catch(() => undefined); }; if (!measuring) { report(result); return; } + // The mesh check goes ahead at once: the assistant reads it with its tool's answer, marked as preliminary. + report(result, true); // What SolidCraft measures exactly. The mesh check shows at once; the light takes the measures // when they come, if nothing changed. A measure that fails leaves its findings out. const measure = async (): Promise => { diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index 18f192b9..ba645ddf 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -1166,14 +1166,23 @@ export const printCheckReportSchema = z nozzle: range(0.1, 2), /** The mirrored revision the check belongs to, so a check of an older revision reads as pending. */ revision: z.number().int().nonnegative(), + /** True for the mesh check sent ahead while SolidCraft still measures clearance and joints; the full check follows. */ + measuring: z.boolean().optional(), /** - * Per printed body, as it lies on the bed: solid volume in cm³, height in mm and its layers, mm² on the bed and - * mm² leaning past the material's limit. What the shape decides of material and print time; the rest is the slicer's. + * Per printed body, as it lies on the bed: solid volume and the shell estimate in mm³ (two wall lines on every + * surface, 15 % infill inside), height in mm and its layers, mm² of surface, on the bed and leaning past the + * material's limit. What the shape decides of material and print time; the rest is the slicer's. */ bodies: z - .array(z.object({ name: z.string().max(200), volume: range(0, 1e9), height: range(0, 1e6), layers: z.number().int().nonnegative(), contact: range(0, 1e9), support: range(0, 1e9) }).strict()) + .array( + z + .object({ name: z.string().max(200), volume: range(0, 1e12), shell: range(0, 1e12), height: range(0, 1e6), layers: z.number().int().nonnegative(), surface: range(0, 1e9), contact: range(0, 1e9), support: range(0, 1e9) }) + .strict(), + ) .max(40) .optional(), + /** What the renderer could not measure or report, for the log. */ + problem: z.string().max(300).optional(), /** How many more bodies the design has than `bodies` carries. */ bodiesOmitted: z.number().int().positive().max(100_000).optional(), }) diff --git a/apps/desktop/src/shared/stepPhrases.ts b/apps/desktop/src/shared/stepPhrases.ts index e1256648..4c1e72a6 100644 --- a/apps/desktop/src/shared/stepPhrases.ts +++ b/apps/desktop/src/shared/stepPhrases.ts @@ -322,6 +322,8 @@ export function describeToolCall(tool: string, args: Params): StepPhrase[] { 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) } }]; + if (tool === 'partbuddy_move_drawing') return [{ key: 'logoMoved', values: { name: text(args.logo, 'SVG').slice(0, 40) } }]; + if (tool === 'partbuddy_place_drawing' && args.mode !== 'part') return [{ key: 'logo', values: { name: text(args.drawing, 'SVG').slice(0, 40), mode: args.mode === 'emboss' ? 'emboss' : 'deboss' } }]; const steps = commandsOf(tool, args); const phrases = steps ? describeCommands(steps).slice(0, MAX_PHRASES_PER_STEP) : []; return phrases.length > 0 ? phrases : [{ key: 'command', values: { command: tool.slice(0, 80) } }]; diff --git a/apps/desktop/test/bodyMeasures.test.ts b/apps/desktop/test/bodyMeasures.test.ts index 2ccf3c79..ef40574d 100644 --- a/apps/desktop/test/bodyMeasures.test.ts +++ b/apps/desktop/test/bodyMeasures.test.ts @@ -2,7 +2,7 @@ import { buildRenderMesh, buildViewerModel, type FaceTable, type PrintCheckBody, import { describe, expect, it } from 'vitest'; import { printCheckNote } from '../src/main/print/printCheckNote'; -import { bodyMeasures } from '../src/renderer/src/print/bodyMeasures'; +import { bodyMeasures, measuresOf } from '../src/renderer/src/print/bodyMeasures'; import { reportOf } from '../src/renderer/src/print/report'; import { printCheckReportSchema } from '../src/shared/contract'; @@ -27,34 +27,70 @@ const PLA = { nozzle: 0.4, layer: 0.2, overhang: 45 }; describe('what a body costs to print, as the assistant reads it with every check (2026-11-12)', () => { it('measures a 20 × 30 × 10 mm box as designed: 6 cm³, 50 layers, 600 mm² on the bed, nothing to support', () => { - expect(bodyMeasures(box('Box', [20, 30, 10]), PLA)).toEqual({ name: 'Box', volume: 6, height: 10, layers: 50, contact: 600, support: 0 }); + expect(bodyMeasures(box('Box', [20, 30, 10]), PLA)).toEqual({ name: 'Box', volume: 6000, shell: 2583, height: 10, layers: 50, surface: 2200, contact: 600, support: 0 }); // A coarser layer: fewer layers for the same height. expect(bodyMeasures(box('Box', [20, 30, 10]), { ...PLA, layer: 0.3 }).layers).toBe(34); }); it('measures it as it lies on its print face: stood on its small side it is twice as high', () => { - expect(bodyMeasures({ ...box('Box', [20, 30, 10]), printFace: 'F1:-x' }, PLA)).toEqual({ name: 'Box', volume: 6, height: 20, layers: 100, contact: 300, support: 0 }); + expect(bodyMeasures({ ...box('Box', [20, 30, 10]), printFace: 'F1:-x' }, PLA)).toMatchObject({ volume: 6000, height: 20, layers: 100, contact: 300, support: 0 }); + }); + + it('reckons a block mostly as sparse infill and a thin wall in full, so the shell estimate does not promise what thinning a block saves', () => { + // 40 × 40 × 20 mm: 32 cm³ solid; two lines on 6400 mm² of surface are 5.76 cm³, the rest counts at 15 %. + expect(bodyMeasures(box('Block', [40, 40, 20]), PLA)).toMatchObject({ volume: 32000, surface: 6400, shell: 5760 + Math.round((32000 - 5760) * 0.15) }); + // A wall of four lines is wall all through: nothing in it is infill. + expect(bodyMeasures(box('Wall', [40, 40, 1.8]), PLA)).toMatchObject({ volume: 2880, shell: 2880 }); }); it('goes into the note one line per body, with what it is not: grams and minutes', () => { - const report = reportOf({ status: 'ready', findings: [] }, 0.4, 3, [bodyMeasures(box('Box', [20, 30, 10]), PLA)]); + const report = reportOf({ status: 'ready', findings: [] }, 0.4, 3, [bodyMeasures(box('Box', [20, 30, 10]), PLA), bodyMeasures(box('Pin', [3, 3, 10]), PLA)]); expect(printCheckReportSchema.safeParse(report).success).toBe(true); const note = printCheckNote(report); - expect(note).toContain("Per body as it prints (solid volume; the slicer's infill, walls and speed decide grams and time, so promise neither):"); - expect(note).toContain('- "Box": 6 cm³, 10 mm high (50 layers), 600 mm² on the bed, 0 mm² leaning past the overhang limit.'); - // A check without measures (an older renderer, no closed body) says nothing of them. + expect(note).toContain('Solid volume overstates what thinning a thick region saves, because the slicer fills it sparsely anyway;'); + expect(note).toContain('What counts for sure: fewer layers, less area to support, less surface. Promise neither grams nor minutes:'); + expect(note).toContain('- "Box": 6 cm³ solid, about 2.58 cm³ as a shell, 10 mm high (50 layers), 2200 mm² of surface, 600 mm² on the bed, 0 mm² leaning past the overhang limit.'); + // Under 1 cm³ in mm³: a small part still shows what a change saved. + expect(note).toContain('- "Pin": 90 mm³ solid, about 90 mm³ as a shell, 10 mm high'); + // A check without measures (no closed body) says nothing of them. expect(printCheckNote({ status: 'ready', findings: [], nozzle: 0.4, revision: 3 })).not.toContain('Per body'); }); it('lists six bodies and counts the rest, also what the report itself left out', () => { - const many = Array.from({ length: 45 }, (_, i) => ({ name: `Pin ${String(i + 1)}`, volume: 0.1, height: 5, layers: 25, contact: 20, support: 0 })); + const many = Array.from({ length: 45 }, (_, i) => ({ name: `Pin ${String(i + 1)}`, volume: 100, shell: 100, height: 5, layers: 25, surface: 100, contact: 20, support: 0 })); const report = reportOf({ status: 'warn', findings: [{ kind: 'open', severity: 'warn', bodies: ['Shell'] }] }, 0.4, 3, many); expect(report.bodies).toHaveLength(40); expect(report.bodiesOmitted).toBe(5); expect(printCheckReportSchema.safeParse(report).success).toBe(true); const note = printCheckNote(report); - expect(note).toContain('- "Pin 6": 0.1 cm³'); + expect(note).toContain('- "Pin 6": 100 mm³ solid'); expect(note).not.toContain('"Pin 7"'); expect(note).toContain('- and 39 more bodies.'); }); }); + +describe('a body that cannot be measured does not hold the check back (review of PR 357)', () => { + it('leaves that body out, names it for the log and still measures the others', () => { + const broken = box('Broken', [10, 10, 10]); + broken.model.geometry.getAttribute('position').array.fill(Number.NaN); + const { bodies, problem } = measuresOf([box('Box', [20, 30, 10]), broken], PLA); + expect(bodies.map((body) => body.name)).toEqual(['Box']); + expect(problem).toMatch(/^no measures for Broken: /); + // The report still fits the contract and carries the problem to the main process, which logs it. + const report = reportOf({ status: 'ready', findings: [] }, 0.4, 3, bodies, { ...(problem ? { problem } : {}) }); + expect(printCheckReportSchema.safeParse(report).success).toBe(true); + expect(report.problem).toContain('Broken'); + expect(measuresOf([box('Box', [20, 30, 10])], PLA).problem).toBeUndefined(); + }); + + it('cuts a name the report would refuse instead of losing the report', () => { + const { bodies } = measuresOf([box('N'.repeat(300), [10, 10, 10])], PLA); + expect(bodies[0]?.name).toHaveLength(200); + expect(printCheckReportSchema.safeParse(reportOf({ status: 'ready', findings: [] }, 0.4, 3, bodies)).success).toBe(true); + }); + + it('marks the mesh check that goes ahead of the measures', () => { + expect(reportOf({ status: 'ready', findings: [] }, 0.4, 3, [], { measuring: true }).measuring).toBe(true); + expect('measuring' in reportOf({ status: 'ready', findings: [] }, 0.4, 3)).toBe(false); + }); +}); diff --git a/apps/desktop/test/checkOfChange.test.ts b/apps/desktop/test/checkOfChange.test.ts index 3ab514c0..bdea9b1e 100644 --- a/apps/desktop/test/checkOfChange.test.ts +++ b/apps/desktop/test/checkOfChange.test.ts @@ -3,9 +3,11 @@ import { afterEach, describe, expect, it } from 'vitest'; import { GateProxy, type ToolResult, type UpstreamTools } from '../src/main/agents/gateProxy'; import { policyToolSet } from '../src/main/agents/loop'; -import { WorkLog } from '../src/main/agents/workLog'; -import { CHECK_PENDING, withCheckOfChange, type CheckState } from '../src/main/print/printCheckNote'; +import { Questions } from '../src/main/agents/questions'; +import { CHANGING_TOOLS, designChanged, WorkLog } from '../src/main/agents/workLog'; +import { CHECK_PENDING, CHECK_PENDING_VIEW, CheckTold, printCheckNote, withCheckOfChange, type CheckState } from '../src/main/print/printCheckNote'; import type { PrintCheckReport } from '../src/shared/contract'; +import { describeToolCall } from '../src/shared/stepPhrases'; const cleanups: (() => Promise | void)[] = []; afterEach(async () => { @@ -13,82 +15,112 @@ afterEach(async () => { }); const thinWall: PrintCheckReport['findings'][number] = { kind: 'thinWall', severity: 'block', floor: false, thickness: 0.3, minimum: 0.9, area: 400, places: [{ body: 'Plate', faces: ['F2:side:0'] }] }; +const box = { name: 'Plate', volume: 6000, shell: 2100, height: 10, layers: 50, surface: 2200, contact: 600, support: 0 }; + +interface Options { + /** How long mirror and mesh check take after a change; null: they never come. */ + checkAfterMs?: number | null; + /** How long SolidCraft measures clearance and joints after that; null: one body, nothing to measure. */ + measureMs?: number | null; + waitMs?: number; + fullMs?: number; +} /** - * The tool chain as index.ts builds it for every route, with a design whose `solid.extrude` leaves a - * 0.3 mm wall. `checkAfterMs`: how long mirror and check take after a change; null: they never come. + * The tool chain as index.ts builds it for every route, on a design with SolidCraft's linear history: `solid.extrude` + * leaves a 0.3 mm wall, a trial moves the revision alone, `fail` changes the design and answers as an error. */ -function setup(checkAfterMs: number | null, waitMs = 1000) { - let revision = 1; +function setup({ checkAfterMs = 100, measureMs = null, waitMs = 1000, fullMs = 500 }: Options = {}) { + const design = { revision: 1, undo: 0, redo: 0 }; const state: CheckState = { mirrored: { documentId: 'doc-1', revision: 1 }, report: { status: 'ready', findings: [], nozzle: 0.4, revision: 1 } }; + const logged: string[] = []; + let historyFails = false; + const changed = () => { + design.revision += 1; + const built = design.revision; + if (checkAfterMs === null) return; + const report: PrintCheckReport = { status: 'block', findings: [thinWall], nozzle: 0.4, revision: built, bodies: [box] }; + setTimeout(() => { + // A later change has its own check; an older one never overwrites it. + if (state.mirrored && state.mirrored.revision > built) return; + state.mirrored = { documentId: 'doc-1', revision: built }; + state.report = measureMs === null ? report : { ...report, measuring: true }; + if (measureMs !== null) setTimeout(() => state.report?.revision === built && (state.report = report), measureMs); + }, checkAfterMs); + }; const upstream: UpstreamTools = { instructions: () => undefined, - listTools: () => Promise.resolve([{ name: 'execute', inputSchema: { type: 'object' } }]), - callTool: (_name, args): Promise => { - if (args.command === 'solid.extrude') { - revision += 1; - const built = revision; - if (checkAfterMs !== null) - setTimeout(() => { - state.mirrored = { documentId: 'doc-1', revision: built }; - state.report = { status: 'block', findings: [thinWall], nozzle: 0.4, revision: built }; - }, checkAfterMs); + listTools: () => Promise.resolve(['execute', 'undo', 'partbuddy_place_drawing', 'partbuddy_move_drawing'].map((name) => ({ name, inputSchema: { type: 'object' as const } }))), + callTool: (name, args): Promise => { + if (args.command === 'solid.extrude' || args.command === 'fail' || name.endsWith('_drawing')) { + design.undo += 1; + design.redo = 0; + changed(); } - return Promise.resolve({ content: [{ type: 'text', text: 'ok' }] }); + if (name === 'undo') { + design.undo -= 1; + design.redo += 1; + changed(); + } + // A trial, a save, a block that was taken back: the revision moves, the design and its history stay. + if (args.command === 'trial') design.revision += 1; + return Promise.resolve({ content: [{ type: 'text', text: args.command === 'fail' ? 'failed half way' : 'ok' }], ...(args.command === 'fail' ? { isError: true } : {}) }); }, }; + const told = new CheckTold(); const log = new WorkLog({ - history: () => Promise.resolve({ documentId: 'doc-1', revision, undo: revision - 1, redo: 0 }), + history: () => (historyFails ? Promise.reject(new Error('no answer')) : Promise.resolve({ documentId: 'doc-1', ...design })), travel: () => Promise.resolve(), emit: () => undefined, - changed: (result, after) => withCheckOfChange(result, after, () => state, waitMs), + changed: (result, after) => withCheckOfChange(result, after, () => state, (report) => told.note(report), waitMs, fullMs), + log: (line) => logged.push(line), }); - return log.tools(upstream); + return { tools: log.tools(upstream), told, logged, state, failHistory: () => (historyFails = true) }; } const extrude = { command: 'solid.extrude', params: { distance: 5 } }; const BLOCKED = 'not ready to print.\n- blocks printing: walls down to 0.3 mm, thinner than the 0.4 mm nozzle'; +const textOf = (result: ToolResult) => result.content.map((part) => String(part.text)).join('\n'); /** What Claude Code reads: the tool result through the gate proxy. */ -async function throughProxy(tools: UpstreamTools, args: Record): Promise { +async function throughProxy(tools: UpstreamTools, args: Record, name = 'execute'): Promise { const proxy = new GateProxy(tools, () => undefined); await proxy.start(); cleanups.push(() => proxy.stop()); const client = new Client({ name: 'test', version: '1.0.0' }); await client.connect(new StreamableHTTPClientTransport(new URL(proxy.url), { requestInit: { headers: { Authorization: `Bearer ${proxy.token}` } } })); cleanups.push(() => client.close()); - const result = await client.callTool({ name: 'execute', arguments: args }); + const result = await client.callTool({ name, arguments: args }); return (result.content as { text: string }[]).map((part) => part.text).join('\n'); } /** What ChatGPT and local models read: the tool result of PartBuddy's own loop. */ -async function throughLoop(tools: UpstreamTools, args: Record): Promise { +async function throughLoop(tools: UpstreamTools, args: Record, name = 'execute'): Promise { const set = await policyToolSet(tools, { nextId: () => 'call-1', event: () => undefined }); - const execute = set.execute?.execute as (input: unknown, options: unknown) => Promise; + const execute = set[name]?.execute as (input: unknown, options: unknown) => Promise; return execute(args, { toolCallId: 'call-1', messages: [] }); } describe('the print check of a change comes back with the tool that made it (audit R1)', () => { it('puts a blocking check into the answer of the changing call on the Claude Code route', async () => { - const text = await throughProxy(setup(150), extrude); + const text = await throughProxy(setup().tools, extrude); expect(text).toContain('ok'); expect(text).toContain(BLOCKED); }); it('does the same in the loop of ChatGPT and local models', async () => { - const text = await throughLoop(setup(150), extrude); - expect(text).toContain(BLOCKED); + expect(await throughLoop(setup().tools, extrude)).toContain(BLOCKED); }); - it('adds nothing to a call that changed nothing', async () => { + it('adds nothing to a call that changed nothing, and does not wait for it', async () => { const read = { command: 'inspect.measure', params: {} }; - expect(await throughProxy(setup(150), read)).toBe('ok'); - expect(await throughLoop(setup(150), read)).toBe('ok'); + expect(await throughProxy(setup().tools, read)).toBe('ok'); + expect(await throughLoop(setup().tools, read)).toBe('ok'); }); it('waits a bounded time and then says the check is still running, never an older verdict', async () => { const started = Date.now(); - const text = await throughLoop(setup(null, 300), extrude); + const text = await throughLoop(setup({ checkAfterMs: null, waitMs: 300 }).tools, extrude); expect(Date.now() - started).toBeLessThan(2000); expect(text).toBe(`ok\n${CHECK_PENDING}`); expect(text).not.toContain('nothing found'); @@ -97,10 +129,151 @@ describe('the print check of a change comes back with the tool that made it (aud it('takes a mirror that is past the change, and no check of another design', async () => { const result: ToolResult = { content: [{ type: 'text', text: 'ok' }] }; const report: PrintCheckReport = { status: 'block', findings: [thinWall], nozzle: 0.4, revision: 9 }; - const text = async (state: CheckState) => (await withCheckOfChange(result, { documentId: 'doc-1', revision: 7 }, () => state, 0)).content.at(-1)?.text; + const text = async (state: CheckState) => (await withCheckOfChange(result, { documentId: 'doc-1', revision: 7 }, () => state, printCheckNote, 0)).content.at(-1)?.text; expect(await text({ mirrored: { documentId: 'doc-1', revision: 9 }, report })).toContain('blocks printing'); expect(await text({ mirrored: { documentId: 'doc-2', revision: 9 }, report })).toBe(CHECK_PENDING); expect(await text({ mirrored: { documentId: 'doc-1', revision: 9 }, report: { ...report, revision: 6 } })).toBe(CHECK_PENDING); expect(await text({ mirrored: null, report: null })).toBe(CHECK_PENDING); }); + + it('tells partbuddy_view to wait, not to call partbuddy_view', () => { + expect(CHECK_PENDING_VIEW).not.toContain('call partbuddy_view'); + expect(CHECK_PENDING_VIEW).toContain('in a moment'); + }); +}); + +describe('designs of several bodies: the mesh check goes ahead of the measures (review of PR 357)', () => { + it('takes the full check when SolidCraft has measured in time', async () => { + const text = textOf(await setup({ measureMs: 100 }).tools.callTool('execute', extrude)); + expect(text).toContain(BLOCKED); + expect(text).not.toContain('Preliminary'); + }); + + it('does not wait for a long measure: the mesh check comes as preliminary, with its findings and measures', async () => { + const started = Date.now(); + const text = textOf(await setup({ measureMs: 60_000, waitMs: 2000, fullMs: 300 }).tools.callTool('execute', extrude)); + expect(Date.now() - started).toBeLessThan(1500); + expect(text).toContain(BLOCKED); + expect(text).toContain('Preliminary: clearance and joints still being measured (bodies that overlap or come closer than a push fit, joints that stop before a limit); call partbuddy_view for the full check before you call the part done.'); + expect(text).toContain('- "Plate": 6 cm³ solid'); + expect(text).not.toContain(CHECK_PENDING); + }); + + it('does not call a preliminary clean check clean of what is still measured', () => { + const note = printCheckNote({ status: 'ready', findings: [], nozzle: 0.4, revision: 1, measuring: true }); + expect(note).toContain('nothing found so far (build volume, wall and floor thickness, overhang angle, area on the bed, open bodies, failed features, bodies that touch).'); + expect(note).not.toContain('in what it checks'); + }); +}); + +describe('only a call that changed the design gets a check (review of PR 357)', () => { + it('reads a moved revision alone as no change: a trial, a save, a block that was taken back', async () => { + expect(designChanged({ documentId: 'a', revision: 4, undo: 2, redo: 1 }, { documentId: 'a', revision: 9, undo: 2, redo: 1 })).toBe(false); + // A block that was built and undone again leaves more to redo, and the design as it was. + expect(designChanged({ documentId: 'a', revision: 4, undo: 2, redo: 0 }, { documentId: 'a', revision: 9, undo: 2, redo: 3 })).toBe(false); + expect(designChanged({ documentId: 'a', revision: 4, undo: 2, redo: 0 }, { documentId: 'a', revision: 5, undo: 3, redo: 0 })).toBe(true); + expect(designChanged({ documentId: 'a', revision: 4, undo: 2, redo: 1 }, { documentId: 'a', revision: 5, undo: 1, redo: 2 })).toBe(true); + // One step undone and another built in its place: the depth is the same, what Redo held is gone. + expect(designChanged({ documentId: 'a', revision: 4, undo: 2, redo: 1 }, { documentId: 'a', revision: 6, undo: 2, redo: 0 })).toBe(true); + expect(designChanged({ documentId: 'a', revision: 4, undo: 2, redo: 0 }, { documentId: 'b', revision: 1, undo: 0, redo: 0 })).toBe(true); + const started = Date.now(); + expect(textOf(await setup({ checkAfterMs: null, waitMs: 1500 }).tools.callTool('execute', { command: 'trial' }))).toBe('ok'); + expect(Date.now() - started).toBeLessThan(1000); + }); + + it('gives the check to a call that failed half way and left the design changed', async () => { + const text = textOf(await setup().tools.callTool('execute', { command: 'fail' })); + expect(text).toContain('failed half way'); + expect(text).toContain(BLOCKED); + }); + + it('gives it after undo as well', async () => { + const { tools } = setup(); + await tools.callTool('execute', extrude); + expect(textOf(await tools.callTool('undo', {}))).toContain('ready-to-print check'); + }); + + it('gives each of two calls sent together the check of its own change or a later one, never an older', async () => { + const { tools, state } = setup(); + const [first, second] = await Promise.all([tools.callTool('execute', extrude), tools.callTool('execute', extrude)]); + // Both end on the check of revision 3, the design as it is after both. + expect(state.report?.revision).toBe(3); + expect(textOf(first)).toContain('ready-to-print check'); + expect(textOf(second)).toContain('ready-to-print check'); + expect(textOf(first) + textOf(second)).not.toContain('nothing found'); + }); + + it('says in the log when the history cannot be read, since the answer then carries no check', async () => { + const { tools, logged, failHistory } = setup(); + failHistory(); + expect(textOf(await tools.callTool('execute', extrude))).toBe('ok'); + expect(logged).toEqual(["the design's history could not be read round execute; its answer carries no print check"]); + }); +}); + +describe('the drawing tools change the design like any other (review of PR 357)', () => { + it('lists every tool of PartBuddy that builds, moves or removes geometry', () => { + // partbuddy_view, _standard, _ask, _plan, _assembly and _design_rules change no geometry. + expect([...CHANGING_TOOLS].filter((name) => name.startsWith('partbuddy_')).sort()).toEqual(['partbuddy_block', 'partbuddy_mechanism', 'partbuddy_move_drawing', 'partbuddy_place_drawing']); + }); + + it('names them in the work log as the user\'s own logo steps are named', () => { + expect(describeToolCall('partbuddy_place_drawing', { drawing: 'svg-1', mode: 'emboss', depth: 1 })).toEqual([{ key: 'logo', values: { name: 'svg-1', mode: 'emboss' } }]); + expect(describeToolCall('partbuddy_move_drawing', { logo: 'SVG-Logo Badge', by: [1, 0] })).toEqual([{ key: 'logoMoved', values: { name: 'SVG-Logo Badge' } }]); + }); + + it('puts the check behind a placed and a moved logo on both routes', async () => { + expect(await throughProxy(setup().tools, { drawing: 'svg-1', mode: 'deboss', depth: 1 }, 'partbuddy_place_drawing')).toContain(BLOCKED); + expect(await throughLoop(setup().tools, { logo: 'SVG-Logo Badge', by: [1, 0] }, 'partbuddy_move_drawing')).toContain(BLOCKED); + }); + + it('holds them back while a question is open', async () => { + const questions = new Questions(() => undefined); + const tools = questions.tools(setup().tools); + await tools.callTool('partbuddy_ask', { questions: [{ question: 'Which side?', options: [{ label: 'Top' }, { label: 'Front' }] }] }); + for (const name of ['partbuddy_place_drawing', 'partbuddy_move_drawing']) { + const answer = await tools.callTool(name, {}); + expect(answer.isError).toBe(true); + expect(textOf(answer)).toContain('The user has not answered your question yet'); + } + }); +}); + +describe('the same check is not told in full with every step (review of PR 357)', () => { + const blocked: PrintCheckReport = { status: 'block', findings: [thinWall], nozzle: 0.4, revision: 2, bodies: [box] }; + + it('tells it in full once, then in one line, and names only the measures that changed', () => { + const told = new CheckTold(); + expect(told.note(blocked)).toBe(printCheckNote(blocked)); + expect(told.note({ ...blocked, revision: 3 })).toBe('[PartBuddy: ready-to-print check after this change: unchanged, still not ready to print, the same 1 finding as before.]'); + const lower = { ...blocked, revision: 4, bodies: [{ ...box, height: 8, layers: 40 }, { ...box, name: 'Lid' }] }; + expect(told.note(lower)).toBe( + '[PartBuddy: ready-to-print check after this change: unchanged, still not ready to print, the same 1 finding as before.\nMeasures that changed:\n' + + '- "Plate": 6 cm³ solid, about 2.1 cm³ as a shell, 8 mm high (40 layers), 2200 mm² of surface, 600 mm² on the bed, 0 mm² leaning past the overhang limit.\n' + + '- "Lid": 6 cm³ solid, about 2.1 cm³ as a shell, 10 mm high (50 layers), 2200 mm² of surface, 600 mm² on the bed, 0 mm² leaning past the overhang limit.]', + ); + // Thirty steps on a clean part: one full note, then a line each. + const clean: PrintCheckReport = { status: 'ready', findings: [], nozzle: 0.4, revision: 5, bodies: [box] }; + const notes = Array.from({ length: 30 }, (_, i) => told.note({ ...clean, revision: 5 + i })); + expect(notes[0]).toBe(printCheckNote(clean)); + expect(new Set(notes.slice(1))).toEqual(new Set(['[PartBuddy: ready-to-print check after this change: unchanged, nothing found.]'])); + expect(notes.join('').length).toBeLessThan(printCheckNote(clean).length + 29 * 90); + }); + + it('tells it in full again when a finding changed, the measures came in, or the user wrote', () => { + const told = new CheckTold(); + told.note(blocked); + const thinner = { ...blocked, findings: [{ ...thinWall, thickness: 0.2 }] }; + expect(told.note(thinner)).toBe(printCheckNote(thinner)); + expect(told.note({ ...thinner, measuring: true })).toContain('Preliminary'); + expect(told.note(thinner)).toBe(printCheckNote(thinner)); + told.forget(); + expect(told.note(thinner)).toBe(printCheckNote(thinner)); + }); + + it('keeps it short through the tool chain: the second step of a turn gets one line', async () => { + const { tools } = setup(); + expect(textOf(await tools.callTool('execute', extrude))).toContain(BLOCKED); + expect(textOf(await tools.callTool('execute', extrude))).toBe('ok\n[PartBuddy: ready-to-print check after this change: unchanged, still not ready to print, the same 1 finding as before.]'); + }); }); From 1409f32643a52c3c4df909bad9689f099368e131 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:33:42 +0100 Subject: [PATCH 14/24] fix(desktop): ribs are as thick as the wall, never under two lines Review of PR 357: half to three quarters of the wall is the rule of injection moulding against sink marks; on a 0.9 mm wall it gave a rib of one line, under the thinnest wall the print check accepts. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/print/printProfiles.ts | 2 +- apps/desktop/test/designRules.test.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/print/printProfiles.ts b/apps/desktop/src/main/print/printProfiles.ts index 8246dd94..ba13db66 100644 --- a/apps/desktop/src/main/print/printProfiles.ts +++ b/apps/desktop/src/main/print/printProfiles.ts @@ -288,7 +288,7 @@ export function designLines(design: DesignRules, printer: FitPrinter, material: // Network, Markforged, Stratasys Direct); what the slicer decides is named as not the assistant's to promise. if (design.economy) lines.push( - `- Material and print time, from the first shape on and not as a clean-up: build a shell, not a block. Slicers print ${series(line, 2, 4)} (two to four lines) as wall and fill anything thicker with sparse infill, so stiffen with ribs about half to three quarters of the wall thick, in whole lines, instead of thickening the wall. Keep the part low as it prints: every ${mm(layer)} of height is one more layer, and print time grows with the layers. Shape it so it needs no support, which costs more time and material than any other choice. Leave out whole regions that do nothing (an open frame instead of a plate, a shorter body); small pockets and holes in a filled region add walls and save little or nothing. Split a part only when that saves support or much height and a joint is acceptable. Loaded regions, sizes the user names and what the user wants solid stay as they are. Infill, speed and layer height are set in the slicer: never promise grams or minutes. The print check lists each body's solid volume, height, layers and support area: compare them before and after a change.`, + `- Material and print time, from the first shape on and not as a clean-up: build a shell, not a block. Slicers print ${series(line, 2, 4)} (two to four lines) as wall and fill anything thicker with sparse infill, so stiffen with ribs as thick as the wall, at least two lines (${mm(line * 2)}), instead of thickening the wall. Keep the part low as it prints: every ${mm(layer)} of height is one more layer, and print time grows with the layers. Shape it so it needs no support, which costs more time and material than any other choice. Leave out whole regions that do nothing (an open frame instead of a plate, a shorter body); small pockets and holes in a filled region add walls and save little or nothing. Split a part only when that saves support or much height and a joint is acceptable. Loaded regions, sizes the user names and what the user wants solid stay as they are. Infill, speed and layer height are set in the slicer: never promise grams or minutes. The print check lists each body's solid volume, height, layers and support area: compare them before and after a change.`, ); if (off.length > 0) lines.push(`- The user turned off ${off.map((rule) => RULE_NAMES[rule]).join(', ')}: do not add them unless the user asks for them.`); diff --git a/apps/desktop/test/designRules.test.ts b/apps/desktop/test/designRules.test.ts index 86edd27f..f6f5d66d 100644 --- a/apps/desktop/test/designRules.test.ts +++ b/apps/desktop/test/designRules.test.ts @@ -106,6 +106,9 @@ describe('material and print time as a rule group of its own (2026-11-12)', () = const note = printNote(profiles.part()); // 0.4 mm nozzle, 0.2 mm layer: lines of 0.45 mm. expect(note).toContain('Slicers print 0.9, 1.35, 1.8 mm (two to four lines) as wall and fill anything thicker with sparse infill'); + // Ribs are no thinner than the thinnest wall that prints (review of PR 357: half a 0.9 mm wall is one line). + expect(note).toContain('stiffen with ribs as thick as the wall, at least two lines (0.9 mm), instead of thickening the wall'); + expect(note).not.toContain('three quarters'); expect(note).toContain('every 0.2 mm of height is one more layer'); expect(note).toContain('shell 1.35 mm with two ribs instead of a solid block'); // It never outranks function, and it promises nothing the slicer decides. @@ -116,6 +119,7 @@ describe('material and print time as a rule group of its own (2026-11-12)', () = profiles.save({ ...settings, printers: settings.printers.map((printer) => ({ ...printer, nozzle: 0.6, layer: 0.3 })) }); const wide = printNote(profiles.part()); expect(wide).toContain('Slicers print 1.35, 2.025, 2.7 mm (two to four lines) as wall'); + expect(wide).toContain('at least two lines (1.35 mm)'); expect(wide).toContain('every 0.3 mm of height is one more layer'); }); From 4f4663d393f65aa5e91dff47169b524ac7f6d59b Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:33:48 +0100 Subject: [PATCH 15/24] fix(desktop): insert seats and wood screw holes are measured too, and curved faces pass Review of PR 357. The plastic a block needs is measured from under the face on, so a curved or sloping face is not read as a gap. The seat of an insert and a countersunk hole are given no direction: it is read from what the cut took out of the body, and the wall round and the plastic under them are measured after the cut, which is taken back when they are missing (a plate just as thick as the seat is deep, a pilot hole through the part). The threshold stands on shares measured at SolidCraft 0.40.1. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/e2e/solidcraft.contract.spec.ts | 9 +- apps/desktop/src/main/agents/blockTool.ts | 127 +++++++++++++++---- apps/desktop/src/shared/magnet.ts | 11 +- apps/desktop/src/shared/wallMount.ts | 36 +++++- apps/desktop/test/blockTool.test.ts | 121 +++++++++++++----- 5 files changed, 235 insertions(+), 69 deletions(-) diff --git a/apps/desktop/e2e/solidcraft.contract.spec.ts b/apps/desktop/e2e/solidcraft.contract.spec.ts index d702eb3c..9dd04c27 100644 --- a/apps/desktop/e2e/solidcraft.contract.spec.ts +++ b/apps/desktop/e2e/solidcraft.contract.spec.ts @@ -3393,12 +3393,17 @@ test('the assistant hangs a part on the wall with its blocks: keyhole slots on t 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 wall plate'); await page.getByRole('button', { name: 'Send' }).click(); - await expect(page.getByText(/Built 2 keyhole slots for 4 mm wood screws in Plate: entry 9 mm for the head, a slot 4\.5 mm wide running 8 mm up .* Built 2 countersunk holes for 4 mm wood screws in Plate: 4\.7 mm through, countersink 8\.8 mm at 90°/)).toBeVisible({ timeout: 60_000 }); + // Each block says that the plastic it needs was measured at the real engine (audit R54): the 8 mm plate has it. + await expect( + page.getByText( + /Built 2 keyhole slots for 4 mm wood screws in Plate: entry 9 mm for the head, a slot 4\.5 mm wide running 8 mm up .* PartBuddy measured that plastic before building: it is there\..* Built 2 countersunk holes for 4 mm wood screws in Plate: 4\.7 mm through, countersink 8\.8 mm at 90°.* PartBuddy measured that plastic: it is there\./, + ), + ).toBeVisible({ timeout: 60_000 }); await expect(page.getByTestId('mesh-summary')).toContainText('1 body', { timeout: 30_000 }); // Each block is one line of the work log, and no feature failed. await expect(page.getByText('Keyhole slot for a 4 mm screw, to hang on a wall')).toHaveCount(1); await expect(page.getByText('Countersunk hole for a 4 mm wood screw')).toHaveCount(1); - await expect(page.getByText(/Built 2 pockets for 6 × 2 mm disc magnets in Plate: 6\.3 mm wide and 2\.2 mm deep/)).toBeVisible(); + await expect(page.getByText(/Built 2 pockets for 6 × 2 mm disc magnets in Plate: 6\.3 mm wide and 2\.2 mm deep.* PartBuddy measured that plastic before building: it is there\./)).toBeVisible(); await expect(page.getByText('Pocket for a 6 × 2 mm magnet')).toHaveCount(1); await expect(page.getByText(/SolidCraft warns:/)).toHaveCount(0); await expect(page.locator('[data-testid="timeline-feature"][data-state="error"]')).toHaveCount(0); diff --git a/apps/desktop/src/main/agents/blockTool.ts b/apps/desktop/src/main/agents/blockTool.ts index 583e9273..c69ffc26 100644 --- a/apps/desktop/src/main/agents/blockTool.ts +++ b/apps/desktop/src/main/agents/blockTool.ts @@ -3,7 +3,7 @@ import { featureBlockSchema } from '../../shared/contract'; import { KNOB, KNOB_CLEARANCE, KNOB_SHAFTS, knobFlat, knobFlutes, knobInsert, knobProblem, knobSteps, type KnobShaft } from '../../shared/knob'; import { TAPPED_FROM } from '../../shared/mechanisms'; import { MAGNET, MAGNET_HOLDS, magnetPocket, magnetProblem, magnetSteps, magnetStock, type MagnetHold } from '../../shared/magnet'; -import { KEYHOLE, WALL, WOOD_SCREWS, keyholeDepth, keyholeProblem, keyholeSteps, keyholeStock, woodScrewSteps, type WoodScrew } from '../../shared/wallMount'; +import { KEYHOLE, WALL, WOOD_SCREWS, WOOD_SCREW_UNDER, countersinkDepth, keyholeDepth, keyholeProblem, keyholeSteps, keyholeStock, woodScrewSteps, woodScrewStock, type WoodScrew } from '../../shared/wallMount'; import { componentOf } from '../solidcraft/actions'; import { count } from '../solidcraft/mirror'; import type { ToolResult, UpstreamTool, UpstreamTools } from './gateProxy'; @@ -125,16 +125,46 @@ type Step = { command: string; params: Record }; /** Plastic a block needs where it cuts (audit R54): a cylinder from `base` along `axis` that must lie wholly in the body. */ export interface Stock { - /** The point the model gave, for the answer. */ + /** The point on the face it is measured behind, for the answer. */ at: Vec3; base: Vec3; axis: Vec3; diameter: number; depth: number; + /** How far under the face the cylinder starts, in mm. */ + from: number; + /** mm³ of the cylinder the block itself has cut away, when it is measured after the block was built. */ + hole?: number; + /** mm³ more the block may have cut away: on a curved face the hole does not end where it ends on a flat one. */ + slack?: number; +} + +/** In mm: how much deeper than asked a hole may reach on a curved face (measured 2026-11-12: an M3 seat on a rod of 20 mm radius took 0.02 mm more of the stock than on a flat face). */ +const CURVED_FACE = 0.3; + +/** In mm: the plastic that must stay under the seat of an insert (three layers of 0.2 mm, as under a magnet pocket; our own choice). */ +export const SEAT_UNDER = 0.6; +/** The plastic round an insert: what its maker asks for, and never under 2 mm (as a boss has it). */ +const seatWall = (size: BlockSize) => Math.max(2, INSERT[size]?.wall ?? 2); + +/** The plastic the seats of a block need, measured after they are cut: from half their depth on, the seat itself taken off. */ +export function seatStock(block: Extract, axis: Vec3): Stock[] { + const from = Math.round(block.depth * 500) / 1000; + const round = (value: number) => Math.round(value * 1000) / 1000; + return [block.point, ...(block.more ?? [])].map((point) => ({ + at: point, + base: point.map((value, k) => round(value + (axis[k] ?? 0) * from)) as Vec3, + axis, + diameter: round(block.diameter + 2 * seatWall(block.size)), + depth: round(block.depth - from + SEAT_UNDER), + from, + hole: ((Math.PI * block.diameter ** 2) / 4) * (block.depth - from), + slack: ((Math.PI * block.diameter ** 2) / 4) * CURVED_FACE, + })); } /** A wall mounting the model asked for: its steps and what to say; null when the block is none of them; or what is wrong. */ -export function wallOf(args: Record): { body: string; steps: Step[]; text: string; stock?: Stock[]; need?: string } | string | null { +export function wallOf(args: Record): { body: string; steps: Step[]; text: string; stock?: Stock[]; cut?: (axis: Vec3) => Stock[]; need: string } | string | null { if (args.block !== 'keyhole' && args.block !== 'woodScrew') return null; const body = typeof args.body === 'string' ? args.body.trim() : ''; const point = vec3(args.point); @@ -149,7 +179,9 @@ export function wallOf(args: Record): { body: string; steps: St return { body, steps: woodScrewSteps({ body, point, more: more as Vec3[], screw, name: 'Wall screw' }), - text: `Built ${String(count)} countersunk hole${count > 1 ? 's' : ''} for ${String(screw)} mm wood screws in ${body}: ${String(size.clearance)} mm through, countersink ${String(size.countersink)} mm at 90°, so the head lies flush. Not checked: keep at least ${String(screw)} mm of plastic between a hole and an edge, and the plate thicker than the countersink is deep (${String(Math.round(((size.countersink - size.clearance) / 2) * 10) / 10)} mm).`, + cut: (axis) => woodScrewStock({ body, point, more: more as Vec3[], screw, name: 'Wall screw' }, axis), + need: `a countersunk hole for a ${String(screw)} mm wood screw needs ${String(screw)} mm of plastic round the countersink and a plate at least ${String(Math.round((countersinkDepth(screw) + WOOD_SCREW_UNDER) * 100) / 100)} mm thick. Thicken the plate, or move the hole away from edges and other cuts`, + text: `Built ${String(count)} countersunk hole${count > 1 ? 's' : ''} for ${String(screw)} mm wood screws in ${body}: ${String(size.clearance)} mm through, countersink ${String(size.countersink)} mm at 90°, so the head lies flush. Keep at least ${String(screw)} mm of plastic between a hole and an edge, and the plate thicker than the countersink is deep (${String(Math.round(((size.countersink - size.clearance) / 2) * 10) / 10)} mm).`, }; } const normal = vec3(args.normal); @@ -254,6 +286,24 @@ function jsonOf(result: ToolResult): unknown { throw new Error(failed ? `SolidCraft refused ${String(failed.command)}: ${String(failed.error).slice(0, 300)}` : text.slice(0, 300)); } +/** + * The way a block's holes went into the body, from what `inspect.measure` says before and after: the plastic they + * took lies on their axes, so its centre of mass lies under the middle of their points. SolidCraft's answer to + * `solid.hole` names no direction, and these blocks are given none. Null when it cannot be told. + */ +export function cutAxis(body: string, before: unknown, after: unknown, points: Vec3[]): Vec3 | null { + const of = (measured: unknown) => (measured as { bodies?: { name?: unknown; volume_mm3?: unknown; center_of_mass?: unknown }[] } | undefined)?.bodies?.find((entry) => entry.name === body); + const [was, is] = [of(before), of(after)]; + const [from, to] = [vec3(was?.center_of_mass), vec3(is?.center_of_mass)]; + const [full, left] = [was?.volume_mm3, is?.volume_mm3]; + if (typeof full !== 'number' || typeof left !== 'number' || !from || !to) return null; + const taken = full - left; + if (!(taken > 1e-6)) return null; + const way = from.map((value, k) => (value * full - (to[k] ?? 0) * left) / taken - points.reduce((sum, point) => sum + (point[k] ?? 0), 0) / points.length) as Vec3; + const length = Math.hypot(...way); + return length > 1e-6 ? (way.map((value) => Math.round((value / length) * 1e6) / 1e6 + 0) as Vec3) : null; +} + /** Volume in mm³ of every body by name as `inspect.measure` answers; NaN for a body that is not closed. */ function volumesOf(measured: unknown): Map { const bodies = (measured as { bodies?: { name?: unknown; volume_mm3?: unknown }[] } | undefined)?.bodies ?? []; @@ -277,10 +327,18 @@ export function misplaced(body: string, before: Map, after: Map< const PROBE = 'PartBuddy probe'; /** - * Where the body has less plastic than a block's cuts need, measured before anything is built (audit R54): each - * stock cylinder is laid in as a body on trial and SolidCraft says how much of it lies in the part. A step that must - * fail ends the batch, and SolidCraft takes the probes back (as mechanismTool.ts measures a hinge line). Null when - * all of it is plastic; `undefined` when it could not be measured. + * How much of a stock cylinder must be plastic. SolidCraft 0.40.1 measures the shared volume exactly, not on + * facets: seven cylinders wholly inside a block, a rod and a ball, along the axes and askew, each gave 1.0000000 + * of their volume; one with 0.02 mm of its 2.8 mm outside gave 0.99286, one with 0.01 mm of its width over an edge + * 0.99993 (tools/audit, r54-shares, 2026-11-12). + */ +export const STOCK_FULL = 0.999; + +/** + * Where the body has less plastic than a block's cuts need (audit R54): each stock cylinder is laid in as a body + * on trial and SolidCraft says how much of it lies in the part. A step that must fail ends the batch, and + * SolidCraft takes the probes back, leaving revision, undo and redo as they were (as mechanismTool.ts measures a + * hinge line). Null when all of it is plastic; `undefined` when it could not be measured. */ export async function shortOf(upstream: UpstreamTools, body: string, stock: Stock[], callId: string | undefined): Promise { try { @@ -305,10 +363,14 @@ export async function shortOf(upstream: UpstreamTools, body: string, stock: Stoc const found = (results[i * 2 + 1] as { interferences?: { volume_mm3?: unknown }[] } | undefined)?.interferences; if (!Array.isArray(found)) return undefined; const area = (Math.PI * room.diameter ** 2) / 4; + const hole = room.hole ?? 0; const plastic = found.reduce((sum, pair) => sum + (typeof pair.volume_mm3 === 'number' ? pair.volume_mm3 : 0), 0); - // A thousandth of the cylinder is the noise of the measure, not a missing wall. - if (plastic < area * room.depth * 0.999) - return `at [${room.at.join(', ')}] ${body} has ${String(Math.round((plastic / area) * 100) / 100)} mm of plastic where ${String(room.depth)} mm are needed (the mean over a width of ${String(room.diameter)} mm; the far side, an edge or another cut takes the rest)`; + const full = area * room.depth - hole; + if (plastic < full * STOCK_FULL - (room.slack ?? 0)) { + // The cylinder starts under the face: with none of it in the part, all that is known is that the part ends above it. + const has = plastic < full * 0.001 ? `less than ${String(room.from)}` : String(Math.round((room.from + (room.depth * plastic) / full) * 100) / 100); + return `at [${room.at.join(', ')}] ${body} has ${has} mm of plastic where ${String(Math.round((room.from + room.depth) * 100) / 100)} mm are needed (the mean over a width of ${String(room.diameter)} mm; the far side, an edge or another cut takes the rest)`; + } } return null; } catch { @@ -362,29 +424,37 @@ export function withBlockTool(upstream: UpstreamTools, keepFace: (body: string, const missing = fresh ? null : misplaced(body, before, before); if (missing?.startsWith('there is no body')) return { content: [{ type: 'text', text: `Not built: ${missing}` }], isError: true }; // A pocket that comes out of the far side or of an edge is not built at all. - const { stock, need } = (wall ?? {}) as { stock?: Stock[]; need?: string }; + const { stock, cut, need } = (wall ?? {}) as { stock?: Stock[]; cut?: (axis: Vec3) => Stock[]; need?: string }; const short = stock ? await shortOf(upstream, body, stock, callId) : null; if (short) return { content: [{ type: 'text', text: `Not built: ${short}; ${need ?? ''}.` }], isError: true }; - // A seat is a blind hole: cut out of solid plastic it takes its whole cylinder from the body. - const seat = block?.block === 'insertSeat' ? { volume: ((Math.PI * block.diameter ** 2) / 4) * block.depth * ((block.more?.length ?? 0) + 1), area: (Math.PI * block.diameter ** 2) / 4, depth: block.depth } : null; + // Holes that are given no direction are measured once they are cut, along the way they went in. + const holes: { points: Vec3[]; stock: (axis: Vec3) => Stock[]; need: string } | null = + block?.block === 'insertSeat' + ? { + points: [block.point, ...(block.more ?? [])], + stock: (axis) => seatStock(block, axis), + need: `the seat of an ${block.size} insert needs ${String(seatWall(block.size))} mm of plastic round it and ${String(SEAT_UNDER)} mm under it. Thicken the part there, move the seat away from edges and other cuts, or use a boss`, + } + : cut && need + ? { points: [vec3(args.point) ?? [0, 0, 0], ...(Array.isArray(args.more) ? args.more.flatMap((entry) => { const point = vec3(entry); return point ? [point] : []; }) : [])], stock: cut, need } + : null; let results: unknown[]; + let after: boolean | null = null; try { results = await run([...sequence, measure]); // The point decides what SolidCraft cuts, so the named body is checked before the block stays. - const after = volumesOf(results[results.length - 1]); - const wrong = fresh ? null : misplaced(body, before, after); + const wrong = fresh ? null : misplaced(body, before, volumesOf(results[results.length - 1])); if (wrong) throw new Error(wrong); - const cut = (before.get(body) ?? NaN) - (after.get(body) ?? NaN); - if (seat && cut < seat.volume * 0.999) - throw new Error( - `the seat${block && 'more' in block && block.more?.length ? 's' : ''} would cut ${String(Math.round(cut * 10) / 10)} mm³ of plastic where a blind hole ${String(seat.depth)} mm deep takes ${String(Math.round(seat.volume * 10) / 10)} mm³: it comes out of the far side or of an edge` + - (block && !('more' in block && block.more?.length) ? ` (${String(Math.round((cut / seat.area) * 100) / 100)} mm of plastic at the point)` : '') + - '. Thicken the part there, or use a boss', - ); + if (holes) { + const axis = cutAxis(body, measured, results[results.length - 1], holes.points); + const thin = axis ? await shortOf(upstream, body, holes.stock(axis), callId) : undefined; + if (thin) throw new Error(`${thin}; ${holes.need}.`); + after = thin === null; + } } catch (cause) { - // Nothing of a block that failed half way or landed on another body stays: undone step by step, as the work - // log goes back. (A batch with `rollback` or a checkpoint is no way back in SolidCraft 0.40.1: after the one - // the same modelled thread fails for real, and the other is gone once a batch has run.) + // Nothing of a block that failed half way, landed on another body or found too little plastic stays: undone + // step by step, as the work log goes back. (A batch with `rollback` or a checkpoint is no way back in + // SolidCraft 0.40.1: after the one the same modelled thread fails for real, and the other is gone once a batch has run.) const steps = depth((await run([inspect]))[0]) - depth(inspected); if (steps > 0) await run(Array.from({ length: steps }, () => ({ command: 'edit.undo', params: {} }))); throw cause; @@ -397,8 +467,9 @@ export function withBlockTool(upstream: UpstreamTools, keepFace: (body: string, return typeof warning === 'string' ? [warning.slice(0, 200)] : []; }); // What was to be measured and was not is said, never passed over. - const stockSaid = !stock ? '' : short === null ? ' PartBuddy measured that plastic before building: it is there.' : ' PartBuddy could not measure that plastic this time: check it yourself.'; - const said = (wall ? wall.text : block ? built(block) : '') + (seat ? ' Measured: the seat is a blind hole cut out of solid plastic. Not checked: the plastic left round and under it.' : ''); + const unmeasured = ' PartBuddy could not measure that plastic this time: check it yourself.'; + const stockSaid = stock ? (short === null ? ' PartBuddy measured that plastic before building: it is there.' : unmeasured) : holes ? (after ? ' PartBuddy measured that plastic: it is there.' : unmeasured) : ''; + const said = (wall ? wall.text : block ? built(block) : '') + (block?.block === 'insertSeat' ? ` Each seat needs ${String(seatWall(block.size))} mm of plastic round it and ${String(SEAT_UNDER)} mm under it.` : ''); return { content: [{ type: 'text', text: `${said}${stockSaid}${warnings.length > 0 ? ` SolidCraft warns: ${warnings.join(' ')}` : ''}` }] }; } catch (cause) { return { content: [{ type: 'text', text: `Not built: ${(cause as Error).message}` }], isError: true }; diff --git a/apps/desktop/src/shared/magnet.ts b/apps/desktop/src/shared/magnet.ts index 62dd3425..e26c4c8c 100644 --- a/apps/desktop/src/shared/magnet.ts +++ b/apps/desktop/src/shared/magnet.ts @@ -38,12 +38,17 @@ export function magnetPocket(spec: Pick round(-value / length) + 0) as Vec3; const pocket = magnetPocket(spec); - return [spec.point, ...spec.more].map((point) => ({ at: point, base: point.map(round) as Vec3, axis, diameter: round(pocket.diameter + 2 * MAGNET.around), depth: round(pocket.depth + MAGNET.under) })); + const from = round(pocket.depth / 2); + return [spec.point, ...spec.more].map((point) => ({ at: point, base: point.map((value, k) => round(value + (axis[k] ?? 0) * from)) as Vec3, axis, diameter: round(pocket.diameter + 2 * MAGNET.around), depth: round(pocket.depth - from + MAGNET.under), from })); } /** What is wrong with a magnet pocket before anything is built; null when it can be built. */ diff --git a/apps/desktop/src/shared/wallMount.ts b/apps/desktop/src/shared/wallMount.ts index 83d2d8cd..3f3c6193 100644 --- a/apps/desktop/src/shared/wallMount.ts +++ b/apps/desktop/src/shared/wallMount.ts @@ -55,15 +55,37 @@ export function keyholeDepth(screw: WoodScrew): number { return round(KEYHOLE.lip + WALL[screw].pocket + KEYHOLE.behind); } -/** The plastic each keyhole needs: as thick as {@link keyholeDepth} behind the entry and behind where the screw rests. */ -export function keyholeStock(spec: KeyholeSpec): { at: Vec3; base: Vec3; axis: Vec3; diameter: number; depth: number }[] { +/** + * The plastic each keyhole needs: as thick as {@link keyholeDepth} behind the entry and behind where the screw + * rests, measured from behind the lip on, so a face that is not quite flat is not read as missing plastic. + */ +export function keyholeStock(spec: KeyholeSpec): { at: Vec3; base: Vec3; axis: Vec3; diameter: number; depth: number; from: number }[] { const [n, up] = [unit(spec.normal), unit(spec.up)]; const axis = n.map((value) => round(-value) + 0) as Vec3; - const room = { axis, diameter: WALL[spec.screw].entry, depth: keyholeDepth(spec.screw) }; - return [spec.point, ...spec.more].flatMap((rest) => [ - { at: rest, base: rest.map(round) as Vec3, ...room }, - { at: rest, base: rest.map((value, k) => round(value - (up[k] ?? 0) * KEYHOLE.travel)) as Vec3, ...room }, - ]); + const room = { axis, diameter: WALL[spec.screw].entry, depth: round(keyholeDepth(spec.screw) - KEYHOLE.lip), from: KEYHOLE.lip }; + const behind = (point: Vec3) => ({ at: point, base: point.map((value, k) => round(value + (axis[k] ?? 0) * KEYHOLE.lip)) as Vec3, ...room }); + return [spec.point, ...spec.more].flatMap((rest) => [behind(rest), behind(rest.map((value, k) => round(value - (up[k] ?? 0) * KEYHOLE.travel)) as Vec3)]); +} + +/** In mm: the clearance hole that must stay under a countersink for the screw to hold the plate (three layers of 0.2 mm; our own choice). */ +export const WOOD_SCREW_UNDER = 0.6; + +/** How deep the 90° countersink of a wood screw goes. */ +export function countersinkDepth(screw: WoodScrew): number { + return round((WALL[screw].countersink - WALL[screw].clearance) / 2); +} + +/** + * The plastic each countersunk hole needs: the screw's diameter of plastic round the countersink, and the plate + * thicker than the countersink is deep. `axis` is the way the holes go into the part; the hole itself is taken off. + */ +export function woodScrewStock(spec: WoodScrewSpec, axis: Vec3): { at: Vec3; base: Vec3; axis: Vec3; diameter: number; depth: number; from: number; hole: number }[] { + const size = WALL[spec.screw]; + const from = round(countersinkDepth(spec.screw) / 2); + const [wide, narrow] = [(size.clearance + size.countersink) / 4, size.clearance / 2]; + // The lower half of the cone, then the clearance hole. + const hole = (Math.PI * from * (wide ** 2 + wide * narrow + narrow ** 2)) / 3 + Math.PI * narrow ** 2 * WOOD_SCREW_UNDER; + return [spec.point, ...spec.more].map((point) => ({ at: point, base: point.map((value, k) => round(value + (axis[k] ?? 0) * from)) as Vec3, axis, diameter: round(size.countersink + 2 * spec.screw), depth: round(from + WOOD_SCREW_UNDER), from, hole })); } /** What is wrong with a keyhole before anything is built; null when it can be built. */ diff --git a/apps/desktop/test/blockTool.test.ts b/apps/desktop/test/blockTool.test.ts index 674ea9a5..a1e4d761 100644 --- a/apps/desktop/test/blockTool.test.ts +++ b/apps/desktop/test/blockTool.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { blockOf, knobOf, magnetOf, misplaced, shortOf, wallOf, withBlockTool } from '../src/main/agents/blockTool'; +import { blockOf, cutAxis, knobOf, magnetOf, misplaced, seatStock, shortOf, wallOf, withBlockTool } from '../src/main/agents/blockTool'; import type { ToolResult, UpstreamTools } from '../src/main/agents/gateProxy'; import { decide } from '../src/main/agents/policy'; import { KNOB, knobProblem, knobSteps, type KnobSpec } from '../src/shared/knob'; import { MAGNET, magnetPocket, magnetSteps, magnetStock } from '../src/shared/magnet'; import { describeToolCall } from '../src/shared/stepPhrases'; -import { KEYHOLE, WALL, keyholeDepth, keyholeProblem, keyholeSteps, keyholeStock, woodScrewSteps, type KeyholeSpec } from '../src/shared/wallMount'; +import { KEYHOLE, WALL, keyholeDepth, keyholeProblem, keyholeSteps, keyholeStock, woodScrewSteps, woodScrewStock, type KeyholeSpec } from '../src/shared/wallMount'; describe('the function blocks for the assistant (standards)', () => { it('fills a block in from the standard tables and lays over it only what the model gave', () => { @@ -264,54 +264,87 @@ describe('a block that lands on another body is taken back (audit W1)', () => { }); describe('a block is built only where the part has the plastic for it (audit R54)', () => { - /** A plate `thick` mm thick, as SolidCraft 0.40.1 answers: the probe's share in the plate, each loose piece a body of its own. */ + /** + * A plate 40 × 40 mm and `thick` mm thick with its top at z = thick, as SolidCraft 0.40.1 answers: the share of a + * probe cylinder along Z that lies in the plate, holes drilled from the top, each loose piece a body of its own. + */ function plate(thick: number, split = false) { const batches: string[] = []; - let built = 0; + const holes: { diameter: number; depth: number }[] = []; + const num = (value: unknown) => parseFloat(String(value)); + const area = (diameter: number) => (Math.PI * diameter ** 2) / 4; + const measured = () => { + const taken = holes.map((hole) => ({ volume: area(hole.diameter) * Math.min(hole.depth, thick), z: thick - Math.min(hole.depth, thick) / 2 })); + const volume = 1600 * thick - taken.reduce((sum, hole) => sum + hole.volume, 0); + const z = (1600 * thick * (thick / 2) - taken.reduce((sum, hole) => sum + hole.volume * hole.z, 0)) / volume; + return { bodies: [{ name: 'Plate', volume_mm3: volume, center_of_mass: [20, 20, z] }, ...(split && holes.length > 0 ? [{ name: 'Plate (1)', volume_mm3: 100, center_of_mass: [0, 0, 0] }] : [])] }; + }; const upstream: UpstreamTools = { instructions: () => undefined, listTools: () => Promise.resolve([]), callTool: (_name, args) => { - const commands = args.commands as { command: string; params: { diameter?: string; height?: string } }[]; + const commands = args.commands as { command: string; params: Record }[]; batches.push(commands.map((step) => step.command).join(' ')); const trial = args.rollback === true; - let probe = { diameter: 0, height: 0 }; + let probe = { diameter: 0, top: 0, bottom: 0 }; const results = commands.map((step) => { - if (trial && step.command === 'solid.cylinder') probe = { diameter: parseFloat(step.params.diameter ?? ''), height: parseFloat(step.params.height ?? '') }; - if (step.command === 'inspect.interference') return { interferences: [{ a: 'Plate', b: 'PartBuddy probe 0', volume_mm3: ((Math.PI * probe.diameter ** 2) / 4) * Math.min(probe.height, thick) }] }; - if (!trial && (step.command === 'solid.hole' || step.command === 'solid.cylinder')) built += 1; - if (step.command === 'edit.undo') built -= 1; - // A blind hole of 4 mm takes what plastic there is, the plate's thickness at most. - if (step.command === 'inspect.measure') return { bodies: [{ name: 'Plate', volume_mm3: 4800 - built * 4 * Math.PI * Math.min(6.7, thick) }, ...(split && built > 0 ? [{ name: 'Plate (1)', volume_mm3: 100 }] : [])] }; - if (step.command === 'document.inspect') return { undo: 1 + built }; + if (trial && step.command === 'solid.cylinder') { + const [z, way, height] = [(step.params.base as number[])[2] ?? 0, (step.params.axis as number[])[2] ?? 0, num(step.params.height)]; + probe = { diameter: num(step.params.diameter), top: Math.max(z, z + way * height), bottom: Math.min(z, z + way * height) }; + } + if (step.command === 'inspect.interference') { + const within = (top: number, bottom: number) => Math.max(0, Math.min(top, probe.top) - Math.max(bottom, probe.bottom)); + const cut = holes.reduce((sum, hole) => sum + area(hole.diameter) * within(thick, Math.max(0, thick - hole.depth)), 0); + return { interferences: [{ a: 'Plate', b: 'PartBuddy probe 0', volume_mm3: area(probe.diameter) * within(thick, 0) - cut }] }; + } + if (!trial && (step.command === 'solid.hole' || step.command === 'solid.cylinder')) holes.push({ diameter: num(step.params.diameter ?? '3.4'), depth: num(step.params.depth ?? step.params.height ?? '1000') }); + if (step.command === 'edit.undo') holes.pop(); + if (step.command === 'inspect.measure') return measured(); + if (step.command === 'document.inspect') return { undo: 1 + holes.length }; return {}; }); // The trial ends on a step that must fail; SolidCraft answers it as an error that carries the results before it. return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify(trial ? { results: results.slice(0, -1), failed: { command: 'sketch.inspect' } } : { results }) }], ...(trial ? { isError: true } : {}) }); }, }; - return { tools: withBlockTool(upstream), upstream, batches }; + return { tools: withBlockTool(upstream), upstream, batches, holes }; } const text = async (answer: Promise) => String((await answer).content[0]?.text); - it('names the plastic a keyhole and a magnet pocket need: as deep as the cut with what must stay behind it', () => { + it('names the plastic each block needs, measured from under the face so a curved face is not read as a gap', () => { const key: KeyholeSpec = { body: 'Plate', point: [20, 30, 0], normal: [0, 0, -1], up: [0, 1, 0], screw: 4, more: [], name: 'Keyhole' }; - // Behind where the screw rests and behind the entry 8 mm below it. + // Behind the lip (2 mm) down to 6.7 mm, where the screw rests and at the entry 8 mm below it. expect(keyholeStock(key)).toEqual([ - { at: [20, 30, 0], base: [20, 30, 0], axis: [0, 0, 1], diameter: 9, depth: 6.7 }, - { at: [20, 30, 0], base: [20, 22, 0], axis: [0, 0, 1], diameter: 9, depth: 6.7 }, + { at: [20, 30, 0], base: [20, 30, 2], axis: [0, 0, 1], diameter: 9, depth: 4.7, from: 2 }, + { at: [20, 22, 0], base: [20, 22, 2], axis: [0, 0, 1], diameter: 9, depth: 4.7, from: 2 }, ]); - // A 6 × 2 mm magnet: 6.3 + 2 × 1.2 wide, 2.2 + 0.6 deep. + // A 6 × 2 mm magnet: 6.3 + 2 × 1.2 wide, from half its 2.2 mm pocket down to 0.6 mm under it. expect(magnetStock({ body: 'Plate', point: [10, 10, 3], normal: [0, 0, 2], more: [[30, 10, 3]], diameter: 6, height: 2, hold: 'glue', name: 'Magnet' })).toEqual([ - { at: [10, 10, 3], base: [10, 10, 3], axis: [0, 0, -1], diameter: 8.7, depth: 2.8 }, - { at: [30, 10, 3], base: [30, 10, 3], axis: [0, 0, -1], diameter: 8.7, depth: 2.8 }, + { at: [10, 10, 3], base: [10, 10, 1.9], axis: [0, 0, -1], diameter: 8.7, depth: 1.7, from: 1.1 }, + { at: [30, 10, 3], base: [30, 10, 1.9], axis: [0, 0, -1], diameter: 8.7, depth: 1.7, from: 1.1 }, ]); + // The seat of an M3 insert: 4 + 2 × 2 wide, from half its 6.7 mm down to 0.6 mm under it, less the seat itself. + const [seat] = seatStock({ block: 'insertSeat', body: 'Plate', point: [10, 10, 10], size: 'M3', diameter: 4, depth: 6.7 }, [0, 0, -1]); + expect(seat).toMatchObject({ at: [10, 10, 10], base: [10, 10, 6.65], diameter: 8, depth: 3.95, from: 3.35 }); + expect(seat?.hole).toBeCloseTo(Math.PI * 4 * 3.35, 6); + // A 4 mm wood screw: 8.8 + 2 × 4 wide, from half the 2.05 mm countersink down to 0.6 mm of clearance hole under it. + const [screw] = woodScrewStock({ body: 'Plate', point: [10, 10, 10], more: [], screw: 4, name: 'Wall screw' }, [0, 0, -1]); + expect(screw).toMatchObject({ base: [10, 10, 8.975], diameter: 16.8, depth: 1.625, from: 1.025 }); + }); + + it('reads the way a hole went in from what it took out of the body', () => { + const before = { bodies: [{ name: 'Plate', volume_mm3: 16000, center_of_mass: [20, 20, 5] }] }; + // A hole of 100 mm³ whose middle lies at [10, 10, 7], drilled from [10, 10, 10]. + const after = { bodies: [{ name: 'Plate', volume_mm3: 15900, center_of_mass: [(16000 * 20 - 100 * 10) / 15900, (16000 * 20 - 100 * 10) / 15900, (16000 * 5 - 100 * 7) / 15900] }] }; + expect(cutAxis('Plate', before, after, [[10, 10, 10]])).toEqual([0, 0, -1]); + expect(cutAxis('Plate', before, before, [[10, 10, 10]])).toBeNull(); + expect(cutAxis('Ghost', before, after, [[10, 10, 10]])).toBeNull(); }); it('builds no keyhole in a 3 mm plate and no magnet pocket through it, and says what it measured', async () => { const { tools, batches } = plate(3); - expect(await text(tools.callTool('partbuddy_block', { block: 'keyhole', body: 'Plate', point: [20, 30, 0], normal: [0, 0, -1], up: [0, 1, 0] }))).toBe( - 'Not built: at [20, 30, 0] Plate has 3 mm of plastic where 6.7 mm are needed (the mean over a width of 9 mm; the far side, an edge or another cut takes the rest); a keyhole needs the part 6.7 mm thick behind its entry and its slot. Thicken the part there, move the keyhole away from edges and other cuts, or use woodScrew.', + expect(await text(tools.callTool('partbuddy_block', { block: 'keyhole', body: 'Plate', point: [20, 30, 3], normal: [0, 0, 1], up: [0, 1, 0] }))).toBe( + 'Not built: at [20, 30, 3] Plate has 3 mm of plastic where 6.7 mm are needed (the mean over a width of 9 mm; the far side, an edge or another cut takes the rest); a keyhole needs the part 6.7 mm thick behind its entry and its slot. Thicken the part there, move the keyhole away from edges and other cuts, or use woodScrew.', ); expect(await text(tools.callTool('partbuddy_block', { block: 'magnet', body: 'Plate', point: [20, 10, 3], normal: [0, 0, 1], height: 3 }))).toContain( 'Not built: at [20, 10, 3] Plate has 3 mm of plastic where 3.8 mm are needed (the mean over a width of 8.7 mm', @@ -321,6 +354,22 @@ describe('a block is built only where the part has the plastic for it (audit R54 expect(batches.join(' ')).not.toContain('solid.extrude'); }); + it('says where the plastic is missing: at the entry of a keyhole, not where the screw rests', async () => { + let probes = 0; + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve([]), + callTool: (_name, args) => { + const commands = args.commands as { command: string }[]; + // The first probe (where the screw rests) is all plastic, the second (the entry) is half in the air. + const results = commands.map((step) => (step.command === 'inspect.interference' ? { interferences: [{ volume_mm3: ((Math.PI * 81) / 4) * 4.7 * (probes++ === 0 ? 1 : 0.5) }] } : {})); + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }], isError: true }); + }, + }; + const stock = keyholeStock({ body: 'Plate', point: [20, 5, 0], normal: [0, 0, -1], up: [0, 1, 0], screw: 4, more: [], name: 'Keyhole' }); + expect(await shortOf(upstream, 'Plate', stock, undefined)).toContain('at [20, -3, 0] Plate has 4.35 mm of plastic where 6.7 mm are needed'); + }); + it('builds them where the plastic is, and says that it measured', async () => { const { tools } = plate(10); expect(await text(tools.callTool('partbuddy_block', { block: 'magnet', body: 'Plate', point: [20, 10, 10], normal: [0, 0, 1] }))).toMatch(/^Built 1 pocket .* PartBuddy measured that plastic before building: it is there\.$/); @@ -328,19 +377,33 @@ describe('a block is built only where the part has the plastic for it (audit R54 it('says so when the plastic could not be measured, instead of passing it over', async () => { const silent: UpstreamTools = { instructions: () => undefined, listTools: () => Promise.resolve([]), callTool: () => Promise.resolve({ content: [{ type: 'text', text: 'no' }], isError: true }) }; - expect(await shortOf(silent, 'Plate', [{ at: [0, 0, 0], base: [0, 0, 0], axis: [0, 0, 1], diameter: 9, depth: 6.7 }], undefined)).toBeUndefined(); + expect(await shortOf(silent, 'Plate', [{ at: [0, 0, 0], base: [0, 0, 0], axis: [0, 0, 1], diameter: 9, depth: 6.7, from: 0 }], undefined)).toBeUndefined(); }); - it('takes back the seat of an insert that comes out of the far side', async () => { + it('takes back the seat of an insert that comes out of the far side, and one that leaves no floor', async () => { + const need = 'the seat of an M3 insert needs 2 mm of plastic round it and 0.6 mm under it. Thicken the part there, move the seat away from edges and other cuts, or use a boss.'; const thin = plate(3); - expect(await text(thin.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [10, 10, 3] }))).toBe( - 'Not built: the seat would cut 37.7 mm³ of plastic where a blind hole 6.7 mm deep takes 84.2 mm³: it comes out of the far side or of an edge (3 mm of plastic at the point). Thicken the part there, or use a boss', + expect(await text(thin.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [20, 20, 3] }))).toBe( + `Not built: at [20, 20, 3] Plate has less than 3.35 mm of plastic where 7.3 mm are needed (the mean over a width of 8 mm; the far side, an edge or another cut takes the rest); ${need}`, ); expect(thin.batches.at(-1)).toBe('edit.undo'); + expect(thin.holes).toEqual([]); + // A plate just as thick as the seat is deep: the hole is whole, and has no floor (review of PR 357). + const flush = plate(6.7); + expect(await text(flush.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [20, 20, 6.7] }))).toContain('Not built: at [20, 20, 6.7] Plate has 6.54 mm of plastic where 7.3 mm are needed'); + expect(flush.holes).toEqual([]); const thick = plate(10); - expect(await text(thick.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [10, 10, 10] }))).toBe( - 'Built insertSeat on Plate at [10, 10, 10]: size M3, diameter 4, depth 6.7. Measured: the seat is a blind hole cut out of solid plastic. Not checked: the plastic left round and under it.', + expect(await text(thick.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [20, 20, 10] }))).toBe( + 'Built insertSeat on Plate at [20, 20, 10]: size M3, diameter 4, depth 6.7. Each seat needs 2 mm of plastic round it and 0.6 mm under it. PartBuddy measured that plastic: it is there.', + ); + }); + + it('takes back a countersunk hole in a plate thinner than the countersink with its hole under it', async () => { + const thin = plate(2); + expect(await text(thin.tools.callTool('partbuddy_block', { block: 'woodScrew', body: 'Plate', point: [20, 20, 2] }))).toContain( + 'a countersunk hole for a 4 mm wood screw needs 4 mm of plastic round the countersink and a plate at least 2.65 mm thick. Thicken the plate, or move the hole away from edges and other cuts.', ); + expect(thin.holes).toEqual([]); }); it('takes back a block that cuts the body into pieces', async () => { From 6b33dba7d8900e612bf90891e43c36c40a910a65 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:34:57 +0100 Subject: [PATCH 16/24] fix(geometry): a bridge is found from its anchors, whichever way the part lies, in one pass Second review of the print check. What counts as a small overhang is reworked: The bridge was tried in twelve fixed directions. An 8 mm tunnel turned by 1 degree was reported whole (240 mm2), a lying hole of 8 mm was free at 0 and 90 degrees only, and a roof meshed as a grid was reported at 15 degrees. The directions now come from the anchors of the patch, and one is chosen for the patch as a slicer lays a bridge. Points just inside the corners are tested, not points on the rim. A hole in a bridge reported the whole ceiling (231 mm2 of 30 mm, 791 mm2 of 100 mm). Only what does not hold is reported, with the arm back to its anchor: the two stubs beside the hole, 15 mm2. By the same rule the roof of the blind T-slot reports its two arms (162 mm2, not the 9 mm bridge behind the slot) and the ring under a counterbore its two stubs (20 mm2). Whether material stands was searched anew from every rim: 14 s on 24000 triangles of a stepped funnel, 56 s on 48000. One flood from the bed finds it, upwards only and across overhang no further than a small one reaches; 100000 triangles take under a second. A face that looks up stands when the underside straight below it does, so a lip with a hook, a step or a bead that looks up no longer carries a shelf. A hole of 9 mm with 20 segments stayed out: the rim of its roof lies on a mesh edge. The distance now runs to where the curve passes the limit angle. A profile without an overhang angle is checked at 45 degrees. Co-Authored-By: Claude Fable 5.2 --- packages/geometry/src/bridges.test.ts | 146 ++++- packages/geometry/src/overhang.ts | 582 ++++++++++++++------ packages/geometry/src/overhangSpeed.test.ts | 88 +++ 3 files changed, 635 insertions(+), 181 deletions(-) create mode 100644 packages/geometry/src/overhangSpeed.test.ts diff --git a/packages/geometry/src/bridges.test.ts b/packages/geometry/src/bridges.test.ts index 8016fbb5..179fc518 100644 --- a/packages/geometry/src/bridges.test.ts +++ b/packages/geometry/src/bridges.test.ts @@ -1,50 +1,100 @@ import { describe, expect, it } from 'vitest'; -import { BRIDGE_SPAN, LYING_HOLE } from './overhang'; +import { BRIDGE_SPAN, LYING_HOLE, Overhang, overhangLimits, overhangOf, smallOverhangReach } from './overhang'; import { checkPrint, type PrintCheckBody, type PrintCheckProfile } from './printCheck'; import { gridBody, lyingTube, prismBody } from './testShapes'; const PLA: PrintCheckProfile = { bed: [256, 256, 256], nozzle: 0.4, layer: 0.2, overhang: 45 }; -describe('bridges, arms and what they hang from, on closed bodies', () => { - /** The area (mm²) reported as overhang, and the faces. */ - const hanging = (body: PrintCheckBody) => { - const finding = checkPrint([body], PLA).findings.find((f) => f.kind === 'overhang'); - return finding?.kind === 'overhang' ? { area: finding.area, faces: finding.places.flatMap((place) => place.faces) } : { area: 0, faces: [] }; - }; +/** The area (mm²) reported as overhang, and the faces. */ +const hanging = (body: PrintCheckBody) => { + const finding = checkPrint([body], PLA).findings.find((f) => f.kind === 'overhang'); + return finding?.kind === 'overhang' ? { area: finding.area, faces: finding.places.flatMap((place) => place.faces) } : { area: 0, faces: [] }; +}; + +/** The area (mm²) that needs support with the body turned by `degrees` about Z on the bed. */ +function turned(body: PrintCheckBody, degrees = 0): number { + const geometry = body.model.geometry; + const source = geometry.getAttribute('position').array; + const cos = Math.cos((degrees * Math.PI) / 180); + const sin = Math.sin((degrees * Math.PI) / 180); + const positions = new Float32Array(source.length); + let bed = Infinity; + for (let i = 0; i < source.length; i += 3) { + positions[i] = cos * (source[i] ?? 0) - sin * (source[i + 1] ?? 0); + positions[i + 1] = sin * (source[i] ?? 0) + cos * (source[i + 1] ?? 0); + positions[i + 2] = source[i + 2] ?? 0; + bed = Math.min(bed, positions[i + 2] ?? 0); + } + const mesh = { positions, normals: new Float32Array(source.length), index: geometry.index?.array as Uint32Array, vertexFaces: body.model.vertexFaces, faceCount: body.model.table.faces.length }; + return overhangOf(mesh, bed, overhangLimits(45), smallOverhangReach(0.4)).area[Overhang.Support]; +} +// Two walls 2 mm thick, `span` apart, `length` deep and 8 high, under a roof 2 mm thick. +const portal = (span: number, length = 30) => gridBody('Portal', [0, 2, 2 + span, 4 + span], [0, length], [0, 8, 10], (i, _j, k) => k === 1 || i !== 1); +// The same 8 mm tunnel with its roof meshed as a grid of nine cells. +const meshed = gridBody('Meshed', [0, 2, 4.5, 7.5, 10, 12], [0, 13.5, 16.5, 30], [0, 8, 10], (i, _j, k) => k === 1 || i === 0 || i === 4); +// A pocket closed at both ends. +const pocket = (span: number) => gridBody('Pocket', [0, 2, 2 + span, 4 + span], [0, 2, 32, 34], [0, 8, 10], (i, j, k) => k === 1 || i !== 1 || j !== 1); +const arm = gridBody('Arm', [0, 2, 42], [0, 30], [0, 8, 10], (i, _j, k) => k === 1 || i === 0); +// The roof of a blind T-slot: 9 mm between the walls, a slot 3 mm wide down the middle, joined at the back. +const slotted = (slot: boolean) => gridBody('Slot', [0, 2, 5, 8, 11, 13], [0, 27, 30, 32], [0, 8, 10], (i, j, k) => (k === 1 ? !(slot && i === 2 && j === 0) : i === 0 || i === 4 || j === 2)); +// The ring a counterbore leaves under a through hole: 9 mm wide with a hole of 4 mm. +const ring = (hole: boolean) => gridBody('Bore', [0, 2, 4.5, 8.5, 11, 13], [0, 2, 4.5, 8.5, 11, 13], [0, 8, 10], (i, j, k) => (k === 1 ? !(hole && i === 2 && j === 2) : i === 0 || i === 4 || j === 0 || j === 4)); +// A bridge of 8 mm with a hole 3 × 3 mm through its middle. +const holed = (length: number) => gridBody('Holed', [0, 2, 4.5, 7.5, 10, 12], [0, length / 2 - 1.5, length / 2 + 1.5, length], [0, 8, 10], (i, j, k) => (k === 1 ? !(i === 2 && j === 1) : i === 0 || i === 4)); +// A shelf 8 mm out of a wall, with something hanging from its tip. +const shelf = (lip: [number, number][]) => prismBody('Shelf', [[0, 0], [5, 0], [5, 16], [13, 16], ...lip, [15, 20], [0, 20]], 30); +const lips: Record = { + 'a drip edge': [[13, 14], [15, 16]], + 'a hook curled back to the wall': [[13, 13], [11, 13], [11, 12.5], [13, 10.5], [15, 12.5]], + 'a lip with a step of 0.5 mm that looks up': [[13, 13], [12.5, 13], [12.5, 12.5], [13.75, 11.25], [15, 12.5]], + 'a bead whose back looks up': [[12, 14], [13.5, 12.5], [15, 14]], +}; +// A tunnel under a round arch of diameter `d` on legs 4 mm high. +const arch = (d: number, segments = 48) => + prismBody('Arch', [[0, 0], [4, 0], ...Array.from({ length: segments + 1 }, (_, i): [number, number] => [4 + d / 2 - (d / 2) * Math.cos((i * Math.PI) / segments), 4 + (d / 2) * Math.sin((i * Math.PI) / segments)]), [4 + d, 0], [8 + d, 0], [8 + d, 10 + d / 2], [0, 10 + d / 2]], 30); +// A box whose floor is reached from nowhere but through its material: a cavity closed all round, 8 mm wide. +const sealed = gridBody('Sealed', [0, 2, 10, 12], [0, 2, 32, 34], [0, 2, 8, 10], (i, j, k) => k !== 1 || i !== 1 || j !== 1); +const tray = gridBody('Tray', [0, 2, 10, 12], [0, 30], [0, 2, 8, 10], (i, _j, k) => k !== 1 || i !== 1); + +describe('bridges, arms and what they hang from, on closed bodies', () => { it('leaves a bridge of up to 10 mm between two walls out, and keeps a wider one, a ceiling and an arm', () => { - // Two walls 2 mm thick, `span` apart, 30 deep and 8 high, under a roof 2 mm thick. - const portal = (span: number) => gridBody('Portal', [0, 2, 2 + span, 4 + span], [0, 30], [0, 8, 10], (i, _j, k) => k === 1 || i !== 1); expect(BRIDGE_SPAN).toBe(10); expect([3, 5.97, 10].map((span) => hanging(portal(span)).area)).toEqual([0, 0, 0]); + expect(hanging(portal(10.001)).area).toBe(300); // Audit MG-01, N-mechanik-1: every corner of these lies on a wall, so they counted as small // (the ceilings of a guide's groove 13.9, 27.9 and 101.9 mm wide). expect([13.9, 27.9, 101.9].map((span) => hanging(portal(span)).area)).toEqual([417, 837, 3057]); // An arm held on one side only is no bridge. - expect(hanging(gridBody('Arm', [0, 2, 42], [0, 30], [0, 8, 10], (i, _j, k) => k === 1 || i === 0)).area).toBe(1200); + expect(hanging(arm).area).toBe(1200); }); - it('takes no bridge across a slot or a hole in the ceiling: what ends at a free edge is an arm', () => { - // The roof of a blind T-slot: 9 mm between the walls, a slot 3 mm wide down the middle, joined at the back. - // Two arms 3 mm out; a line across them met a wall on both sides and read as a 9 mm bridge. - const slotted = (slot: boolean) => gridBody('Slot', [0, 2, 5, 8, 11, 13], [0, 27, 30, 32], [0, 8, 10], (i, j, k) => (k === 1 ? !(slot && i === 2 && j === 0) : i === 0 || i === 4 || j === 2)); - expect(hanging(slotted(true))).toEqual({ area: 189, faces: ['F1:-z@8'] }); + it('takes no bridge across a slot or a hole in the ceiling, and reports the arms, not the bridge beside them', () => { + // Two arms 3 mm out and 27 long; a line across them met a wall on both sides and read as a 9 mm + // bridge. The 9 × 3 mm behind the slot is one, and stays out. + expect(hanging(slotted(true))).toEqual({ area: 162, faces: ['F1:-z@8'] }); expect(hanging(slotted(false)).area).toBe(0); - // The ring a counterbore leaves under a through hole: 9 mm wide with a hole of 4 mm. - const ring = (hole: boolean) => gridBody('Bore', [0, 2, 4.5, 8.5, 11, 13], [0, 2, 4.5, 8.5, 11, 13], [0, 8, 10], (i, j, k) => (k === 1 ? !(hole && i === 2 && j === 2) : i === 0 || i === 4 || j === 0 || j === 4)); - expect(hanging(ring(true)).area).toBe(65); + // Laid across in one direction, two sides of the ring are bridges beside the hole and two are stubs 2.5 mm out. + expect(hanging(ring(true)).area).toBe(20); expect(hanging(ring(false)).area).toBe(0); + // A hole in a bridge leaves the two stubs beside it (2.5 × 3 mm each), however long the bridge: + // the whole ceiling was reported, 231 mm² of 30 mm and 791 mm² of 100 mm. + expect(hanging(holed(30)).area).toBe(15); + expect(hanging(holed(100)).area).toBe(15); }); it('hangs nothing from material that itself ends in the air', () => { - // A shelf 8 mm out of a wall with a drip edge at its tip: the lip going down from the far edge - // counted as a second wall, and the shelf as bridged. - expect(hanging(prismBody('Shelf', [[0, 0], [5, 0], [5, 16], [13, 16], [13, 14], [15, 16], [15, 20], [0, 20]], 30))).toEqual({ area: 240, faces: ['F1:s2'] }); + // The lip going down from the far edge counted as a second wall, and the shelf as bridged; with a + // face that looks up on the lip, the lip counted as standing. + for (const [name, lip] of Object.entries(lips)) expect(hanging(shelf(lip)), name).toEqual({ area: 240, faces: ['F1:s2'] }); expect(hanging(prismBody('Shelf', [[0, 0], [5, 0], [5, 16], [15, 16], [15, 20], [0, 20]], 30)).area).toBe(300); - // A wall that stands on the floor of a box holds a bridge as one on the bed does. - const tray = gridBody('Tray', [0, 2, 10, 12], [0, 30], [0, 2, 8, 10], (i, _j, k) => k !== 1 || i !== 1); + // A wall that stands on the floor of a box holds a bridge as one on the bed does, also where the + // floor is reached only through the material under it. expect(hanging(tray).area).toBe(0); + expect(hanging(sealed).area).toBe(0); + // A second wall down to the bed makes the shelf a bridge. + expect(hanging(prismBody('Portal', [[0, 0], [5, 0], [5, 16], [13, 16], [13, 0], [15, 0], [15, 20], [0, 20]], 30)).area).toBe(0); }); it('lets a hole lying across the layers print round up to 8 mm, as the assistant is told', () => { @@ -52,5 +102,55 @@ describe('bridges, arms and what they hang from, on closed bodies', () => { expect(hanging(lyingTube('Tube', 10, 4, 10, 96)).faces).not.toContain('F1:hole'); expect(hanging(lyingTube('Tube', 10, 4.5, 10, 96)).faces).toContain('F1:hole'); expect(hanging(lyingTube('Tube', 10, 1.5, 10, 96)).faces).not.toContain('F1:hole'); + expect(turned(arch(8))).toBe(0); + expect(turned(arch(9))).toBeGreaterThan(200); + }); + + it('reads a hole the same however coarsely it is meshed', () => { + // With 20 segments a hole of 9 mm stayed out: its rim lay a facet short of where the wall passes the limit. + for (const segments of [16, 20, 24, 36, 48, 96]) { + const outside = turned(lyingTube('Tube', 10, 1.5, 10, segments)); + expect(turned(lyingTube('Tube', 10, 4, 10, segments)), `Ø 8, ${String(segments)} segments`).toBeCloseTo(outside, 3); + expect(turned(lyingTube('Tube', 10, 4.5, 10, segments)), `Ø 9, ${String(segments)} segments`).toBeGreaterThan(outside + 40); + } + for (const segments of [12, 16, 20, 24, 36]) { + expect(turned(arch(8, segments)), `arch 8, ${String(segments)} segments`).toBe(0); + // Coarsely meshed, the flat facets at the top of the arch pass as a ceiling; the rest is reported. + expect(turned(arch(9, segments)), `arch 9, ${String(segments)} segments`).toBeGreaterThan(100); + } + }); + + it('gives the same answer however the part is turned on the bed', () => { + // With twelve fixed directions an 8 mm tunnel turned by 1° was reported whole (240 mm²), a lying + // hole of 8 mm was free at 0° and 90° only, and a roof meshed as a grid was reported at 15°. + const cases: [string, PrintCheckBody, number][] = [ + ['bridge 3', portal(3), 0], + ['bridge 5', portal(5), 0], + ['bridge 8', portal(8), 0], + ['bridge 10', portal(10), 0], + ['bridge 10.001', portal(10.001), 300.03], + ['bridge 13.9', portal(13.9), 417], + ['bridge 8, roof meshed', meshed, 0], + ['pocket 8', pocket(8), 0], + ['pocket 9.95', pocket(9.95), 0], + ['arm 40', arm, 1200], + ['T-slot', slotted(true), 162], + ['counterbore', ring(true), 20], + ['bridge with a hole', holed(30), 15], + ['shelf with a drip edge', shelf(lips['a drip edge'] ?? []), 240], + ['shelf with a hook', shelf(lips['a hook curled back to the wall'] ?? []), 240], + ['sealed box', sealed, 0], + ['hole Ø 7', lyingTube('Tube', 10, 3.5, 10, 96), turned(lyingTube('Tube', 10, 1.5, 10, 96))], + ['hole Ø 8', lyingTube('Tube', 10, 4, 10, 96), turned(lyingTube('Tube', 10, 1.5, 10, 96))], + ['hole Ø 9', lyingTube('Tube', 10, 4.5, 10, 96), turned(lyingTube('Tube', 10, 4.5, 10, 96))], + ['arch 8', arch(8), 0], + ['arch 9', arch(9), turned(arch(9))], + ]; + for (const [name, body, area] of cases) { + for (const degrees of [0, 1, 7.5, 15, 30, 45, 60, 75, 90]) { + const found = turned(body, degrees); + expect(Math.abs(found - area), `${name} turned ${String(degrees)}°: ${String(found)} for ${String(area)}`).toBeLessThanOrEqual(Math.max(0.01, area * 0.01)); + } + } }); }); diff --git a/packages/geometry/src/overhang.ts b/packages/geometry/src/overhang.ts index f1352d16..8af245f3 100644 --- a/packages/geometry/src/overhang.ts +++ b/packages/geometry/src/overhang.ts @@ -20,7 +20,9 @@ export const OVERHANG_LIMITS: OverhangLimits = { borderline: 35, support: 45 }; /** The limits for a material (M7): support beyond its overhang angle, borderline 10° before. */ export function overhangLimits(support: number): OverhangLimits { - return { borderline: Math.max(0, support - 10), support }; + // A profile without an angle is checked at the usual one, not with a limit nothing passes. + const limit = Number.isFinite(support) ? support : OVERHANG_LIMITS.support; + return { borderline: Math.max(0, limit - 10), support: limit }; } /** @@ -113,189 +115,451 @@ export const LYING_HOLE = 8; const LEVEL = 0.99; /** A face that looks up at least this much (z of its unit normal) has material under it. */ const UP = 0.1; -const CORNERS: [number, number, number][] = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; +/** Just inside each corner of a triangle: a point on the rim itself belongs to both sides of it. */ +const CORNERS: [number, number, number][] = [[0.998, 0.001, 0.001], [0.001, 0.998, 0.001], [0.001, 0.001, 0.998]]; +/** Directions a patch is tried to be bridged in, at most: those most of its anchors lie across. */ +const DIRECTIONS = 6; +/** Two faces that turn less than this (rad) into each other are one curved surface, not an edge. */ +const SMOOTH = Math.PI / 6; + +/** A cell of a grid and the eight around it, the cell itself first. */ +const AROUND: [number, number][] = [[0, 0], [-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [-1, 1], [1, -1], [1, 1]]; + +/** Where a patch of overhang ends: an edge in plan, whether the patch hangs from it, and how far the overhang really runs past it. */ +interface Rim { + ax: number; + ay: number; + bx: number; + by: number; + anchor: boolean; + ease: number; +} /** - * Overhang triangles that are small: each connected patch whose every point lies within `reach` - * (mm, across the bed) of an edge where the material goes on below, the way a ledge or a thread - * flank hangs from its wall, or between two such edges that are the patch's nearest rims on both - * sides and no more than a span apart: `BRIDGE_SPAN` under a flat ceiling, `arch` under the roof of - * a lying hole. A patch with no such edge starts in the air, and a patch reaching further (a - * ceiling, a wide bridge, a cantilevered arm, a lip beside a slot) needs support; both stay. - * shortcut: point-to-edge distances are compared pairwise per patch; fine for the parts PartBuddy - * builds, upgrade to a spatial grid if a check of a large mesh gets slow. + * Overhang triangles that are small, so that they need no support. + * + * A patch (overhang triangles joined by edges) hangs from its anchors: the rims where the surface + * goes on downwards into material that stands (`standing`). A point of it holds when it lies within + * `reach` (mm, across the bed) of an anchor, the way a ledge or a thread flank hangs from its wall, + * or when it is bridged: across the patch, in the one direction the slicer would lay the bridge, + * the nearest rim on both sides is an anchor and the two are no more than a span apart + * (`BRIDGE_SPAN` under a flat ceiling, `arch` under the roof of a lying hole). The direction is taken + * from the anchors themselves, so the answer does not turn with the part on the bed. + * + * What does not hold is reported, with the arm it hangs on back to its anchor: a ceiling, a wide + * bridge, an arm, the lips beside a slot or a hole. The rest of the patch stays out, so a hole in a + * long bridge reports the stubs beside it, not the bridge. A patch with no anchor starts in the air. */ -function smallPatches(positions: ArrayLike, index: ArrayLike, support: readonly number[], reach: number, arch: number, bedZ: number): Set { +function smallPatches(positions: ArrayLike, index: ArrayLike, down: ArrayLike, isSupport: (t: number) => boolean, reach: number, arch: number, sinSupport: number, bedZ: number): Set { const small = new Set(); - if (support.length === 0) return small; - const key = (v: number) => `${String(Math.round((positions[v * 3] ?? 0) / WELD))},${String(Math.round((positions[v * 3 + 1] ?? 0) / WELD))},${String(Math.round((positions[v * 3 + 2] ?? 0) / WELD))}`; - const keys = new Map(); - const keyOf = (v: number) => { - let k = keys.get(v); - if (k === undefined) { - k = key(v); - keys.set(v, k); + const count = index.length / 3; + let any = false; + for (let t = 0; t < count && !any; t += 1) any = isSupport(t); + if (!any) return small; + + // Render buffers repeat corners per face: weld them, so edges are shared between faces. + const welded = new Map(); + const wx: number[] = []; + const wy: number[] = []; + const wz: number[] = []; + const corner = new Int32Array(index.length); + for (let i = 0; i < index.length; i += 1) { + const v = index[i] ?? 0; + const [px, py, pz] = [positions[v * 3] ?? 0, positions[v * 3 + 1] ?? 0, positions[v * 3 + 2] ?? 0]; + const key = `${String(Math.round(px / WELD))},${String(Math.round(py / WELD))},${String(Math.round(pz / WELD))}`; + let w = welded.get(key); + if (w === undefined) { + w = wx.length; + welded.set(key, w); + wx.push(px); + wy.push(py); + wz.push(pz); } - return k; - }; - const edgeKey = (a: number, b: number) => { - const ka = keyOf(a); - const kb = keyOf(b); - return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; - }; - const x = (v: number) => positions[v * 3] ?? 0; - const y = (v: number) => positions[v * 3 + 1] ?? 0; - const z = (v: number) => positions[v * 3 + 2] ?? 0; - /** The normal of a triangle, not of unit length. */ - const normalOf = (t: number): [number, number, number] => { - const [a, b, c] = [index[t * 3] ?? 0, index[t * 3 + 1] ?? 0, index[t * 3 + 2] ?? 0]; - const [ux, uy, uz, vx, vy, vz] = [x(b) - x(a), y(b) - y(a), z(b) - z(a), x(c) - x(a), y(c) - y(a), z(c) - z(a)]; - return [uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx]; - }; - // Every edge of the body with the triangles on it and their third corner; per vertex the vertices - // one edge away, and whether a face that looks up meets it. - const edges = new Map(); - const links = new Map(); - const looksUp = new Set(); - for (let t = 0; t < index.length / 3; t += 1) { - const [nx, ny, nz] = normalOf(t); - const up = nz > UP * Math.hypot(nx, ny, nz); + corner[i] = w; + } + const vertices = wx.length; + const x = (w: number) => wx[w] ?? 0; + const y = (w: number) => wy[w] ?? 0; + const z = (w: number) => wz[w] ?? 0; + const at = (t: number, k: number) => corner[t * 3 + k] ?? 0; + + // Every edge with the triangles on it and their third corner. + const edgeKey = (a: number, b: number) => (a < b ? a * vertices + b : b * vertices + a); + const edges = new Map(); + for (let t = 0; t < count; t += 1) { for (let k = 0; k < 3; k += 1) { - const a = index[t * 3 + k] ?? 0; - const b = index[t * 3 + ((k + 1) % 3)] ?? 0; - const third = index[t * 3 + ((k + 2) % 3)] ?? 0; + const a = at(t, k); + const b = at(t, (k + 1) % 3); + if (a === b) continue; const id = edgeKey(a, b); const list = edges.get(id); - if (list) list.push({ triangle: t, third }); - else edges.set(id, [{ triangle: t, third }]); - const near = links.get(keyOf(a)); - if (near) near.push(b, third); - else links.set(keyOf(a), [b, third]); - if (up) looksUp.add(keyOf(a)); + if (list) list.push(t, at(t, (k + 2) % 3)); + else edges.set(id, [t, at(t, (k + 2) % 3)]); } } - // Whether the material at a vertex stands on something: going down along the mesh, never up, it - // comes to the bed or to a face that looks up (a floor, the flank below). The tip of a drip edge - // under a shelf ends in the air instead. - // shortcut: a hook whose own back looks up counts as standing; follow the material below the - // face that looks up if such a shape must be told apart. - const stands = new Map(); - const grounded = (start: number): boolean => { - const known = stands.get(keyOf(start)); - if (known !== undefined) return known; - const visited = new Set([keyOf(start)]); - const stack = [start]; - let found = false; - while (stack.length > 0 && !found) { - const v = stack.pop() ?? 0; - if (z(v) <= bedZ + BED_TOLERANCE || looksUp.has(keyOf(v))) found = true; - else - for (const next of links.get(keyOf(v)) ?? []) { - if (z(next) > z(v) + WELD || visited.has(keyOf(next))) continue; - visited.add(keyOf(next)); - stack.push(next); - } - } - stands.set(keyOf(start), found); - return found; - }; - const isSupport = new Set(support); - const seen = new Set(); - for (const start of support) { - if (seen.has(start)) continue; - // One patch: support triangles joined by edges. + + const standing = standingVertices({ count, vertices, x, y, z, at, down, isSupport, edges, reach, bedZ }); + + const seen = new Uint8Array(count); + for (let start = 0; start < count; start += 1) { + if (seen[start] || !isSupport(start)) continue; + // One patch: support triangles joined by edges, and the rims where it ends. const patch: number[] = []; - const anchors: [number, number][] = []; - // Every edge where the patch ends, and whether it is an anchor. - const rim: [number, number, boolean][] = []; + const rims: Rim[] = []; const queue = [start]; - seen.add(start); + seen[start] = 1; while (queue.length > 0) { const t = queue.pop() ?? 0; patch.push(t); for (let k = 0; k < 3; k += 1) { - const a = index[t * 3 + k] ?? 0; - const b = index[t * 3 + ((k + 1) % 3)] ?? 0; - for (const other of edges.get(edgeKey(a, b)) ?? []) { - if (other.triangle === t) continue; - if (isSupport.has(other.triangle)) { - if (!seen.has(other.triangle)) { - seen.add(other.triangle); - queue.push(other.triangle); + const a = at(t, k); + const b = at(t, (k + 1) % 3); + const list = edges.get(edgeKey(a, b)) ?? []; + for (let i = 0; i < list.length; i += 2) { + const other = list[i] ?? 0; + const third = list[i + 1] ?? 0; + if (other === t) continue; + if (isSupport(other)) { + if (!seen[other]) { + seen[other] = 1; + queue.push(other); } - } else { - // The neighbour goes on downwards from this edge to where it stands, or the edge lies - // on the bed: the patch hangs from material here. - const anchor = (z(other.third) < Math.min(z(a), z(b)) - WELD && grounded(other.third)) || Math.max(z(a), z(b)) <= bedZ + BED_TOLERANCE; - if (anchor) anchors.push([a, b]); - rim.push([a, b, anchor]); + continue; } + // The neighbour goes on downwards from this edge to material that stands, or the edge + // lies on the bed: the patch hangs from material here. + const anchor = (z(third) < Math.min(z(a), z(b)) - WELD && standing(third)) || Math.max(z(a), z(b)) <= bedZ + BED_TOLERANCE; + rims.push({ ax: x(a), ay: y(a), bx: x(b), by: y(b), anchor, ease: anchor ? easeOf(x(at(t, (k + 2) % 3)), y(at(t, (k + 2) % 3)), x(a), y(a), x(b), y(b), down[t] ?? 0, down[other] ?? 0, sinSupport) : 0 }); } } } - if (anchors.length === 0) continue; - const near = (px: number, py: number) => { - for (const [a, b] of anchors) { - const ax = positions[a * 3] ?? 0; - const ay = positions[a * 3 + 1] ?? 0; - const dx = (positions[b * 3] ?? 0) - ax; - const dy = (positions[b * 3 + 1] ?? 0) - ay; - const length = dx * dx + dy * dy; - const s = length === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / length)); - if (Math.hypot(px - ax - s * dx, py - ay - s * dy) <= reach) return true; + if (!rims.some((rim) => rim.anchor)) continue; + for (const t of heldTriangles(patch, rims, { x, y, at, down, edges, edgeKey, reach, arch })) small.add(t); + } + return small; +} + +/** + * How far (mm) past a rim the overhang really runs on a curved surface. The mesh puts the rim on an + * edge between two facets, the limit angle lies somewhere on the curve between their middles: a + * hole of 20 segments would read up to a facet narrower than the same hole of 96. + */ +function easeOf(tx: number, ty: number, ax: number, ay: number, bx: number, by: number, downIn: number, downOut: number, sinSupport: number): number { + const angleIn = Math.asin(Math.max(-1, Math.min(1, downIn))); + const angleOut = Math.asin(Math.max(-1, Math.min(1, downOut))); + const turn = angleIn - angleOut; + if (!(turn > 1e-6) || turn > SMOOTH) return 0; + const length = Math.hypot(bx - ax, by - ay); + if (length === 0) return 0; + // The facet's width in plan: from its far corner to the rim. + const width = Math.abs((bx - ax) * (ty - ay) - (by - ay) * (tx - ax)) / length; + const ease = (width * ((angleIn + angleOut) / 2 - Math.asin(sinSupport)) * sinSupport) / (turn * Math.max(downIn, 1e-6)); + return Math.max(-width, Math.min(width, ease)); +} + +interface Surface { + count: number; + vertices: number; + x: (w: number) => number; + y: (w: number) => number; + z: (w: number) => number; + at: (t: number, k: number) => number; + down: ArrayLike; + isSupport: (t: number) => boolean; + edges: Map; + reach: number; + bedZ: number; +} + +/** + * Which vertices stand on something, found in one flood from the bed: upwards along the surface, + * never down, and across overhang only as far as a small overhang reaches (a thread flank carries + * the next turn, a shelf carries neither a lip at its tip nor a wall on it). A face that looks up stands when the + * underside of the material straight below it does: the floor of a box, the back of a hook not. + */ +function standingVertices({ count, vertices, x, y, z, at, down, isSupport, edges, reach, bedZ }: Surface): (w: number) => boolean { + // Per vertex: the vertices one edge away, and what the way there adds: the run in plan over overhang. + const next: number[][] = Array.from({ length: vertices }, () => []); + for (const [id, list] of edges) { + const a = Math.floor(id / vertices); + const b = id % vertices; + let hanging = false; + for (let i = 0; i < list.length; i += 2) hanging ||= isSupport(list[i] ?? 0); + // Over overhang the way adds its run in plan; on sound surface it adds nothing (−1). + const run = hanging ? Math.hypot(x(b) - x(a), y(b) - y(a)) : -1; + next[a]?.push(b, run); + next[b]?.push(a, run); + } + + // Undersides in a grid in plan, to find the one straight below a face that looks up. + const undersides: number[] = []; + let [minX, minY, maxX, maxY] = [Infinity, Infinity, -Infinity, -Infinity]; + for (let t = 0; t < count; t += 1) { + if (!((down[t] ?? 0) > 0)) continue; + undersides.push(t); + for (let k = 0; k < 3; k += 1) { + minX = Math.min(minX, x(at(t, k))); + maxX = Math.max(maxX, x(at(t, k))); + minY = Math.min(minY, y(at(t, k))); + maxY = Math.max(maxY, y(at(t, k))); + } + } + const cell = Math.max(1e-3, Math.sqrt(((maxX - minX) * (maxY - minY)) / Math.max(1, undersides.length)) * 2); + const columns = Math.max(1, Math.ceil((maxX - minX) / cell)); + const grid = new Map(); + for (const t of undersides) { + const xs = [x(at(t, 0)), x(at(t, 1)), x(at(t, 2))]; + const ys = [y(at(t, 0)), y(at(t, 1)), y(at(t, 2))]; + for (let i = Math.floor((Math.min(...xs) - minX) / cell); i <= Math.floor((Math.max(...xs) - minX) / cell); i += 1) { + for (let j = Math.floor((Math.min(...ys) - minY) / cell); j <= Math.floor((Math.max(...ys) - minY) / cell); j += 1) { + const list = grid.get(j * (columns + 1) + i); + if (list) list.push(t); + else grid.set(j * (columns + 1) + i, [t]); } - return false; - }; - // A point is bridged where a straight line through it leaves the patch over an anchor on both - // sides, within the span. A free edge or a hole on the way makes that side an arm, not a bridge. - const bridged = (px: number, py: number, span: number) => { - for (let turn = 0; turn < 12; turn += 1) { - const dx = Math.cos((turn * Math.PI) / 12); - const dy = Math.sin((turn * Math.PI) / 12); - // Distance to the nearest rim ahead and behind, and whether it is free. - const ends = [ - { at: Infinity, free: false }, - { at: Infinity, free: false }, - ]; - for (const [a, b, anchor] of rim) { - const ax = x(a) - px; - const ay = y(a) - py; - const ex = x(b) - x(a); - const ey = y(b) - y(a); - const cross = dx * ey - dy * ex; - if (Math.abs(cross) < 1e-12) continue; - const s = (ax * dy - ay * dx) / cross; - if (s < -1e-6 || s > 1 + 1e-6) continue; - const along = (ax * ey - ay * ex) / cross; - const end = ends[along >= 0 ? 0 : 1]; - if (!end) continue; - const at = Math.abs(along); - // Two rims met at one point: a free one among them decides. - if (Math.abs(at - end.at) <= WELD) end.free ||= !anchor; - else if (at < end.at) { - end.at = at; - end.free = !anchor; - } + } + } + // Turns of a thread lie over each other in plan: each cell from the top down, so a point finds + // the underside below it without passing every turn. + const lowest = (t: number) => Math.min(z(at(t, 0)), z(at(t, 1)), z(at(t, 2))); + let tallest = 0; + for (const t of undersides) tallest = Math.max(tallest, Math.max(z(at(t, 0)), z(at(t, 1)), z(at(t, 2))) - lowest(t)); + for (const list of grid.values()) list.sort((a, b) => lowest(b) - lowest(a)); + /** The underside straight below a point, the highest one, or −1. */ + const below = (px: number, py: number, pz: number): number => { + let found = -1; + let height = -Infinity; + const list = grid.get(Math.floor((py - minY) / cell) * (columns + 1) + Math.floor((px - minX) / cell)) ?? []; + // The first that does not begin above the point. + let from = 0; + for (let to = list.length; from < to; ) { + const middle = (from + to) >> 1; + if (lowest(list[middle] ?? 0) > pz) from = middle + 1; + else to = middle; + } + for (let i = from; i < list.length; i += 1) { + const t = list[i] ?? 0; + const [az, bz, cz] = [z(at(t, 0)), z(at(t, 1)), z(at(t, 2))]; + // Nothing further down reaches up to the best so far. + if (Math.min(az, bz, cz) + tallest < height) break; + const [ax, ay, bx, by, cx, cy] = [x(at(t, 0)), y(at(t, 0)), x(at(t, 1)), y(at(t, 1)), x(at(t, 2)), y(at(t, 2))]; + const area = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); + if (area === 0) continue; + const u = ((px - ax) * (cy - ay) - (py - ay) * (cx - ax)) / area; + const v = ((bx - ax) * (py - ay) - (by - ay) * (px - ax)) / area; + if (u < 0 || v < 0 || u + v > 1) continue; + const pointZ = az + u * (bz - az) + v * (cz - az); + if (pointZ < pz - WELD && pointZ > height) { + height = pointZ; + found = t; + } + } + return found; + }; + // Per underside the faces that look up above it, and per vertex the undersides that wait for it. + const above = new Map(); + const waiting = new Map(); + for (let t = 0; t < count; t += 1) { + if (!((down[t] ?? 0) < -UP)) continue; + const under = below((x(at(t, 0)) + x(at(t, 1)) + x(at(t, 2))) / 3, (y(at(t, 0)) + y(at(t, 1)) + y(at(t, 2))) / 3, (z(at(t, 0)) + z(at(t, 1)) + z(at(t, 2))) / 3); + if (under < 0) continue; + const list = above.get(under); + if (list) list.push(t); + else { + above.set(under, [t]); + for (let k = 0; k < 3; k += 1) { + const watchers = waiting.get(at(under, k)); + if (watchers) watchers.push(under); + else waiting.set(at(under, k), [under]); + } + } + } + + // The flood. `run` is how far over overhang the way from standing material to a vertex leads. + const run = new Float64Array(vertices).fill(Infinity); + const queue: number[] = []; + const reached = (w: number, value: number) => { + if (value > reach + WELD || value >= (run[w] ?? Infinity) - 1e-9) return; + run[w] = value; + queue.push(w); + }; + for (let w = 0; w < vertices; w += 1) if (z(w) <= bedZ + BED_TOLERANCE) reached(w, 0); + for (let head = 0; head < queue.length; head += 1) { + const w = queue[head] ?? 0; + const list = next[w] ?? []; + for (let i = 0; i < list.length; i += 2) { + const n = list[i] ?? 0; + if (z(n) < z(w) - WELD) continue; + const over = list[i + 1] ?? 0; + // A small overhang that holds carries what rises from it: a step on a step on a step. + reached(n, over >= 0 ? (run[w] ?? 0) + over : z(n) > z(w) + WELD ? 0 : (run[w] ?? 0)); + } + for (const under of waiting.get(w) ?? []) { + if (!Number.isFinite(run[at(under, 0)]) || !Number.isFinite(run[at(under, 1)]) || !Number.isFinite(run[at(under, 2)])) continue; + for (const t of above.get(under) ?? []) for (let k = 0; k < 3; k += 1) reached(at(t, k), 0); + above.delete(under); + } + } + return (w) => Number.isFinite(run[w]); +} + +interface PatchSurface { + x: (w: number) => number; + y: (w: number) => number; + at: (t: number, k: number) => number; + down: ArrayLike; + edges: Map; + edgeKey: (a: number, b: number) => number; + reach: number; + arch: number; +} + +/** The triangles of one patch that hold: near an anchor, or bridged in the patch's one direction. */ +function heldTriangles(patch: readonly number[], rims: readonly Rim[], { x, y, at, down, edges, edgeKey, reach, arch }: PatchSurface): number[] { + // The rims in grids in plan: what a point can hang from lies in the cells around it. The anchors + // in cells as wide as a small overhang reaches, all rims in cells a bridge span wide. + const gridOf = (cell: number, wanted: (rim: Rim) => boolean) => { + const cells = new Map(); + const cellOf = (value: number) => Math.floor(value / cell) + 32768; + for (const rim of rims) { + if (!wanted(rim)) continue; + for (let i = cellOf(Math.min(rim.ax, rim.bx)); i <= cellOf(Math.max(rim.ax, rim.bx)); i += 1) { + for (let j = cellOf(Math.min(rim.ay, rim.by)); j <= cellOf(Math.max(rim.ay, rim.by)); j += 1) { + const list = cells.get(i * 65536 + j); + if (list) list.push(rim); + else cells.set(i * 65536 + j, [rim]); } - if (ends.every((end) => !end.free) && (ends[0]?.at ?? Infinity) + (ends[1]?.at ?? Infinity) <= span + WELD) return true; + } + } + /** The rims in the cells around a point, until `found` says it has what it looked for. */ + return (px: number, py: number, found: (rim: Rim) => boolean): boolean => { + // The point's own cell first: what is nearest is most likely there. + for (const [di, dj] of AROUND) { + for (const rim of cells.get((cellOf(px) + di) * 65536 + cellOf(py) + dj) ?? []) if (found(rim)) return true; } return false; }; - // Every point of the patch, not only its corners: a ceiling has all its corners on its walls. - const holds = patch.every((t) => { - const [a, b, c] = [index[t * 3] ?? 0, index[t * 3 + 1] ?? 0, index[t * 3 + 2] ?? 0]; - const [nx, ny, nz] = normalOf(t); - const span = -nz > LEVEL * Math.hypot(nx, ny, nz) ? BRIDGE_SPAN : arch; - const points = [...spread(Math.ceil(Math.abs(nz) / 2 / (reach / 2) ** 2)), ...CORNERS]; - return points.every(([wa, wb, wc]) => { - const px = wa * x(a) + wb * x(b) + wc * x(c); - const py = wa * y(a) + wb * y(b) + wc * y(c); - return near(px, py) || bridged(px, py, span); + }; + const anchorsAround = gridOf(reach, (rim) => rim.anchor); + const rimsAround = gridOf(BRIDGE_SPAN, () => true); + const near = (px: number, py: number) => + anchorsAround(px, py, (rim) => { + const dx = rim.bx - rim.ax; + const dy = rim.by - rim.ay; + const length = dx * dx + dy * dy; + const s = length === 0 ? 0 : Math.max(0, Math.min(1, ((px - rim.ax) * dx + (py - rim.ay) * dy) / length)); + return Math.hypot(px - rim.ax - s * dx, py - rim.ay - s * dy) <= reach; + }); + // Along a line through the point the patch ends at the nearest rim ahead and behind. A bridge has + // an anchor at both; a free edge or a hole on the way makes that side an arm. + const bridged = (px: number, py: number, dx: number, dy: number, span: number) => { + const ends = [ + { at: Infinity, free: false, ease: 0 }, + { at: Infinity, free: false, ease: 0 }, + ]; + rimsAround(px, py, (rim) => { + const ex = rim.bx - rim.ax; + const ey = rim.by - rim.ay; + const cross = dx * ey - dy * ex; + if (Math.abs(cross) < 1e-9 * Math.hypot(ex, ey)) return false; + const s = ((rim.ax - px) * dy - (rim.ay - py) * dx) / cross; + if (s < -1e-6 || s > 1 + 1e-6) return false; + const along = ((rim.ax - px) * ey - (rim.ay - py) * ex) / cross; + const end = ends[along >= 0 ? 0 : 1]; + if (!end) return false; + const distance = Math.abs(along); + // Two rims met at one point: a free one among them decides. + if (Math.abs(distance - end.at) <= WELD) end.free ||= !rim.anchor; + else if (distance < end.at) { + end.at = distance; + end.free = !rim.anchor; + end.ease = rim.ease; + } + return false; + }); + const [ahead, behind] = ends; + return ahead !== undefined && behind !== undefined && !ahead.free && !behind.free && ahead.at + ahead.ease + behind.at + behind.ease <= span + WELD; + }; + + // The directions most of the anchors lie across, by their length: a bridge spans from anchor to anchor. + const bins = new Map(); + for (const rim of rims) { + if (!rim.anchor) continue; + const length = Math.hypot(rim.bx - rim.ax, rim.by - rim.ay); + if (length === 0) continue; + const angle = Math.atan2(rim.by - rim.ay, rim.bx - rim.ax); + const turned = ((angle % Math.PI) + Math.PI) % Math.PI; + const bin = bins.get(Math.floor((turned * 180) / Math.PI)) ?? [0, 0, 0]; + bins.set(Math.floor((turned * 180) / Math.PI), [bin[0] + length, bin[1] + length * Math.cos(2 * angle), bin[2] + length * Math.sin(2 * angle)]); + } + const directions = [...bins.values()] + .sort((a, b) => b[0] - a[0]) + .slice(0, DIRECTIONS) + .map(([, cos, sin]): [number, number] => { + const along = Math.atan2(sin, cos) / 2; + return [-Math.sin(along), Math.cos(along)]; + }); + + // A patch that is flat over a third of its width or more is a ceiling with rounded or chamfered + // sides; one that is curved nearly all the way is the roof of a hole, and the few flat facets at + // its top (a fifth of a round roof) are part of the arch. + const planOf = (t: number) => Math.abs((x(at(t, 1)) - x(at(t, 0))) * (y(at(t, 2)) - y(at(t, 0))) - (y(at(t, 1)) - y(at(t, 0))) * (x(at(t, 2)) - x(at(t, 0)))) / 2; + const ceiling = patch.reduce((sum, t) => sum + ((down[t] ?? 0) > LEVEL ? 2 * planOf(t) : -planOf(t)), 0) >= 0; + /** The points of a triangle in plan, and the span a bridge under it may have. */ + const sampled = (t: number) => { + const [a, b, c] = [at(t, 0), at(t, 1), at(t, 2)]; + const plan = planOf(t); + const points = [...spread(Math.ceil(plan / (reach / 2) ** 2)), ...CORNERS].map(([wa, wb, wc]): [number, number] => [wa * x(a) + wb * x(b) + wc * x(c), wa * y(a) + wb * y(b) + wc * y(c)]); + return { plan, points, span: ceiling && (down[t] ?? 0) > LEVEL ? BRIDGE_SPAN : arch }; + }; + // Per triangle: whether all of it lies near an anchor, and in which directions it does not hold. + const nearAll = new Map(); + const fails = new Map(); + const lost = directions.map(() => 0); + let lostNear = 0; + for (const t of patch) { + const { plan, points, span } = sampled(t); + let allNear = true; + const failing = directions.map(() => false); + for (const [px, py] of points) { + if (near(px, py)) continue; + allNear = false; + directions.forEach(([dx, dy], d) => { + if (!failing[d] && !bridged(px, py, dx, dy, span)) failing[d] = true; }); + } + nearAll.set(t, allNear); + fails.set(t, failing); + failing.forEach((failed, d) => { + if (failed) lost[d] = (lost[d] ?? 0) + plan; }); - if (holds) for (const t of patch) small.add(t); + if (!allNear) lostNear += plan; } - return small; + // The one direction that leaves the least hanging; a patch with no direction holds only near its anchors. + const best = lost.indexOf(Math.min(...lost)); + const direction = directions[best]; + const failed = (t: number) => (direction ? (fails.get(t)?.[best] ?? false) : !(nearAll.get(t) ?? false)); + if (direction ? (lost[best] ?? 0) === 0 : lostNear === 0) return [...patch]; + + // What fails, and the arm it hangs on: the triangles next to it that lie near an anchor but are + // no part of a bridge themselves. + const inPatch = new Set(patch); + const hanging = new Set(patch.filter(failed)); + const stack = [...hanging]; + while (stack.length > 0) { + const t = stack.pop() ?? 0; + for (let k = 0; k < 3; k += 1) { + const list = edges.get(edgeKey(at(t, k), at(t, (k + 1) % 3))) ?? []; + for (let i = 0; i < list.length; i += 2) { + const other = list[i] ?? 0; + if (!inPatch.has(other) || hanging.has(other) || !(nearAll.get(other) ?? false)) continue; + const { points, span } = sampled(other); + if (direction && points.every(([px, py]) => bridged(px, py, direction[0], direction[1], span))) continue; + hanging.add(other); + stack.push(other); + } + } + } + return patch.filter((t) => !hanging.has(t)); } /** @@ -322,6 +586,8 @@ export function overhangOf(mesh: OverhangInput, bedZ: number, limits: OverhangLi const area: [number, number, number, number] = [0, 0, 0, 0]; const supportByFace = new Float64Array(faceCount); const triangles: { kind: Overhang; face: number; area: number }[] = []; + // How much each triangle points down (−z of its unit normal). + const down = new Float64Array(index.length / 3); for (let i = 0; i < index.length; i += 3) { const a = (index[i] ?? 0) * 3; const b = (index[i + 1] ?? 0) * 3; @@ -337,12 +603,12 @@ export function overhangOf(mesh: OverhangInput, bedZ: number, limits: OverhangLi const nz = ux * vy - uy * vx; const length = Math.hypot(nx, ny, nz); const face = vertexFaces[index[i] ?? 0] ?? 0; + down[i / 3] = length === 0 ? 0 : -nz / length; triangles.push({ kind: length === 0 ? Overhang.Fine : onBed(face) ? Overhang.Bed : classOf(-nz / length), face, area: length / 2 }); } - const support = reach > 0 ? triangles.flatMap((triangle, t) => (triangle.kind === Overhang.Support ? [t] : [])) : []; // The roof of a lying hole overhangs between the two lines where its wall passes the limit. - const arch = LYING_HOLE * Math.sin(((limits.support + ANGLE_TOLERANCE) * Math.PI) / 180); - const small = smallPatches(positions, index, support, reach, arch, bedZ); + const sinSupport = Math.sin(((limits.support + ANGLE_TOLERANCE) * Math.PI) / 180); + const small = reach > 0 ? smallPatches(positions, index, down, (t) => triangles[t]?.kind === Overhang.Support, reach, LYING_HOLE * sinSupport, sinSupport, bedZ) : new Set(); triangles.forEach(({ kind, face, area: size }, t) => { const counted = small.has(t) ? Overhang.Borderline : kind; area[counted] += size; diff --git a/packages/geometry/src/overhangSpeed.test.ts b/packages/geometry/src/overhangSpeed.test.ts new file mode 100644 index 00000000..f1ac3a00 --- /dev/null +++ b/packages/geometry/src/overhangSpeed.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { Overhang, overhangLimits, overhangOf, smallOverhangReach, type OverhangInput } from './overhang'; + +type Ring = [number, number, number][]; + +/** A surface through rings of points, each joined to the next; `wrap` closes every ring. Triangles face away from the Z axis, flat rings the way `facing` says. */ +function surface(rings: Ring[], facing: number[], wrap: boolean): OverhangInput { + const positions = rings.flat(2); + const per = rings[0]?.length ?? 0; + const index: number[] = []; + const point = (v: number) => [positions[v * 3] ?? 0, positions[v * 3 + 1] ?? 0, positions[v * 3 + 2] ?? 0] as const; + for (let r = 0; r + 1 < rings.length; r += 1) { + for (let i = 0; i < (wrap ? per : per - 1); i += 1) { + const j = (i + 1) % per; + const quad = [r * per + i, r * per + j, (r + 1) * per + j, (r + 1) * per + i] as const; + for (const [a, b, c] of [[quad[0], quad[1], quad[2]], [quad[0], quad[2], quad[3]]] as const) { + const [pa, pb, pc] = [point(a), point(b), point(c)]; + const u = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]] as const; + const w = [pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2]] as const; + const normal = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]] as const; + const radial = normal[0] * (pa[0] + pb[0] + pc[0]) + normal[1] * (pa[1] + pb[1] + pc[1]); + const flip = Math.abs(radial) > 1e-9 ? radial < 0 : normal[2] * (facing[r] ?? 0) < 0; + index.push(a, flip ? c : b, flip ? b : c); + } + } + } + return { positions: Float32Array.from(positions), normals: new Float32Array(positions.length), index: Uint32Array.from(index), vertexFaces: new Uint32Array(positions.length / 3), faceCount: 1 }; +} + +const circle = (radius: number, z: number, n: number): Ring => Array.from({ length: n }, (_, i) => [radius * Math.cos((i * 2 * Math.PI) / n), radius * Math.sin((i * 2 * Math.PI) / n), z]); + +/** An outside thread on a core that stands on the bed: root 4 mm, crest 4.6 mm, pitch 1 mm, the under flank 63° from the vertical. */ +function thread(turns: number, n: number): OverhangInput { + const helix = (radius: number, lift: number, z?: number): Ring => Array.from({ length: turns * n + 1 }, (_, i) => [radius * Math.cos((i * 2 * Math.PI) / n), radius * Math.sin((i * 2 * Math.PI) / n), z ?? i / n + lift]); + return surface([helix(4, 0, -1), helix(4, 0), helix(4.6, 0.3), helix(4, 1)], [0, 0, 0], false); +} + +/** A funnel that steps outwards going up: risers 1 mm high, ledges 1 mm out facing down, no face that looks up. */ +function funnel(steps: number, n: number): OverhangInput { + const rings = [circle(5, 0, n)]; + const facing: number[] = []; + for (let k = 0; k < steps; k += 1) { + rings.push(circle(5 + k, k + 1, n), circle(6 + k, k + 1, n)); + facing.push(0, -1); + } + return surface(rings, facing, true); +} + +/** A stem meshed in many rings under a ledge 1.5 mm out. */ +function stem(rings: number, n: number, from: number): OverhangInput { + const all = Array.from({ length: rings + 1 }, (_, k) => circle(5, from + k * 0.2, n)); + return surface([...all, circle(6.5, from + rings * 0.2, n)], [...Array.from({ length: rings }, () => 0), -1], true); +} + +function timed(mesh: OverhangInput, bed: number) { + const start = performance.now(); + const map = overhangOf(mesh, bed, overhangLimits(45), smallOverhangReach(0.4)); + return { ms: performance.now() - start, triangles: mesh.index.length / 3, support: Math.round(map.area[Overhang.Support]), small: Math.round(map.area[Overhang.Borderline]) }; +} + +describe('what stands and what hangs on large meshes, in one pass', () => { + // Searching anew from every rim took 14 s on 24 000 triangles of the funnel and 56 s on 48 000. + // The limits are wide for a busy CI runner; measured locally all stay under 1 s. + it('finds every step of a stepped funnel small: each ledge stands on the riser below', () => { + expect(timed(funnel(60, 100), 0)).toMatchObject({ triangles: 24_000, support: 0, small: 13_186 }); + const large = timed(funnel(250, 100), 0); + expect(large).toMatchObject({ triangles: 100_000, support: 0 }); + expect(large.ms).toBeLessThan(2000); + }); + + it('finds the flanks of a long thread small, turn over turn', () => { + const bolt = timed(thread(260, 96), -1); + expect(bolt.triangles).toBe(149_760); + expect(bolt.support).toBe(0); + expect(bolt.small).toBeGreaterThan(4700); + expect(bolt.ms).toBeLessThan(3000); + }); + + it('holds a ledge on a finely meshed stem that stands on the bed, and reports it on one that floats', () => { + const standing = timed(stem(250, 200, 0), 0); + expect(standing).toMatchObject({ triangles: 100_400, support: 0, small: 54 }); + expect(standing.ms).toBeLessThan(2000); + const floating = timed(stem(250, 200, 1), 0); + expect(floating).toMatchObject({ support: 54, small: 0 }); + expect(floating.ms).toBeLessThan(2000); + }); +}); From 89274a368134d54289c033e53af1b9288ce4d5e4 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:35:03 +0100 Subject: [PATCH 17/24] fix(desktop): the report of the print check fits its contract without an angle or with many bodies Second review of the print check: the overhang finding carried the profile's angle as it came, so one that is not a number failed the report's schema; it carries the limit that was checked. The count of bodies with little on the bed was capped at 500 in the schema, where a larger design would have lost the whole report. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/shared/contract.ts | 2 +- apps/desktop/test/printCheckNote.test.ts | 2 ++ packages/geometry/src/printCheck.test.ts | 8 ++++++++ packages/geometry/src/printCheck.ts | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index 8e970a86..6cf207d0 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -1146,7 +1146,7 @@ export const printFindingSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('size'), severity: findingSeveritySchema, body: bodyName, size: z.tuple([mmValue, mmValue, mmValue]), bed: z.tuple([mmValue, mmValue, mmValue]), lying: z.boolean() }).strict(), z.object({ kind: z.literal('thinWall'), severity: findingSeveritySchema, floor: z.boolean(), layers: finite.positive().max(1000).optional(), thickness: mmValue, minimum: mmValue, area: finite.nonnegative(), places: findingPlacesSchema }).strict(), z.object({ kind: z.literal('overhang'), severity: findingSeveritySchema, area: finite.nonnegative(), limit: range(0, 90), places: findingPlacesSchema }).strict(), - z.object({ kind: z.literal('contact'), severity: findingSeveritySchema, area: finite.nonnegative(), bodies: z.number().int().positive().max(500), places: findingPlacesSchema }).strict(), + z.object({ kind: z.literal('contact'), severity: findingSeveritySchema, area: finite.nonnegative(), bodies: z.number().int().positive().max(100_000), places: findingPlacesSchema }).strict(), z.object({ kind: z.literal('feature'), severity: findingSeveritySchema, feature: z.string().max(200), message: z.string().max(500) }).strict(), z.object({ kind: z.literal('separate'), severity: findingSeveritySchema, bodies: z.array(z.string().max(200)).min(2).max(20) }).strict(), z.object({ kind: z.literal('open'), severity: findingSeveritySchema, bodies: z.array(z.string().max(200)).min(1).max(20) }).strict(), diff --git a/apps/desktop/test/printCheckNote.test.ts b/apps/desktop/test/printCheckNote.test.ts index 38cf40cd..68032295 100644 --- a/apps/desktop/test/printCheckNote.test.ts +++ b/apps/desktop/test/printCheckNote.test.ts @@ -65,6 +65,8 @@ describe('the ready-to-print check as the assistant reads it (M7)', () => { const note = printCheckNote(report); expect(note).toContain('50 bodies have little on the bed, the least 16 mm²'); expect(note).toContain('"F1:-z" of body "Pin3"; and 46 more bodies)'); + // A design of more bodies than a report names still fits the contract. + expect(printCheckReportSchema.safeParse(reportOf({ status: 'warn', findings: [{ kind: 'contact', severity: 'warn', area: 16, bodies: 600, places, box: null }] }, 0.4, 1)).success).toBe(true); // Fifty findings: the report keeps 40 and counts the rest, the note lists 12 and counts the rest. const many = reportOf({ status: 'block', findings: Array.from({ length: 50 }, (_, i) => ({ kind: 'size' as const, severity: 'block' as const, body: 'Rod' + String(i), size: [300, 300, 300] as [number, number, number], bed: [256, 256, 256] as [number, number, number], lying: false })) }, 0.4, 1); expect(many).toMatchObject({ omitted: 10 }); diff --git a/packages/geometry/src/printCheck.test.ts b/packages/geometry/src/printCheck.test.ts index 576de243..61649e5c 100644 --- a/packages/geometry/src/printCheck.test.ts +++ b/packages/geometry/src/printCheck.test.ts @@ -229,6 +229,14 @@ describe('the ready-to-print check (M7)', () => { expect(drafted.findings.map((f) => f.kind)).toContain('thinWall'); }); + it('checks overhangs at 45° where the profile names no angle', () => { + // With the angle undefined nothing passed the limit: a ceiling 14 mm wide read ready. + const table = boxesBody('Table', [[[9, 9, 0], [11, 11, 10]], [[0, 0, 10], [20, 20, 12]]]); + for (const overhang of [undefined as unknown as number, Number.NaN]) { + expect(checkPrint([table], { ...PLA, overhang }).findings.find((f) => f.kind === 'overhang')).toMatchObject({ area: 400, limit: 45 }); + } + }); + it('follows the overhang angle of the material', () => { const tube = lyingTube('Tube', 6, 2.25, 10); const faces = (limit: number) => { diff --git a/packages/geometry/src/printCheck.ts b/packages/geometry/src/printCheck.ts index 19707ee3..dcfa4ac1 100644 --- a/packages/geometry/src/printCheck.ts +++ b/packages/geometry/src/printCheck.ts @@ -521,7 +521,7 @@ export function checkPrint(bodies: readonly PrintCheckBody[], profile: PrintChec }); const steep = locate(parts, overhang, MIN_AREA); if (steep.area >= MIN_AREA) { - findings.push({ kind: 'overhang', severity: 'warn', area: round(steep.area, 0), limit: profile.overhang, places: steep.places, box: steep.box }); + findings.push({ kind: 'overhang', severity: 'warn', area: round(steep.area, 0), limit: limits.support, places: steep.places, box: steep.box }); } // One finding for all of them: fifty pins are one thing to say, and none drops off the end of a list. if (loose.length > 0) { From d19ec0af6feb1dcb8ae0cf0929cce0ba7b1bb47e Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:18:00 +0100 Subject: [PATCH 18/24] fix(desktop): a block is taken back by what the design measures, each hole along its own way Second review of PR 357. SolidCraft caps the depth of its undo history at 200, so a block is undone step by step until bodies, volumes and feature count are as before, never further than it sent, and says so when that fails. Each hole of a block is cut and measured by itself, so holes that take unequal amounts no longer share one skewed axis. The plastic is measured from 0.5 mm under the face on, a seat must also cut its whole cylinder (its rim at a chamfer does not), the wood screw's stock is the screw's diameter round its hole, and the answers say what was measured. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/e2e/solidcraft.contract.spec.ts | 4 +- apps/desktop/src/main/agents/blockTool.ts | 157 +++++++++---- apps/desktop/src/shared/magnet.ts | 8 +- apps/desktop/src/shared/wallMount.ts | 29 +-- apps/desktop/test/blockTool.test.ts | 218 ++++++++++++++----- 5 files changed, 305 insertions(+), 111 deletions(-) diff --git a/apps/desktop/e2e/solidcraft.contract.spec.ts b/apps/desktop/e2e/solidcraft.contract.spec.ts index 9dd04c27..e268300b 100644 --- a/apps/desktop/e2e/solidcraft.contract.spec.ts +++ b/apps/desktop/e2e/solidcraft.contract.spec.ts @@ -3396,14 +3396,14 @@ test('the assistant hangs a part on the wall with its blocks: keyhole slots on t // Each block says that the plastic it needs was measured at the real engine (audit R54): the 8 mm plate has it. await expect( page.getByText( - /Built 2 keyhole slots for 4 mm wood screws in Plate: entry 9 mm for the head, a slot 4\.5 mm wide running 8 mm up .* PartBuddy measured that plastic before building: it is there\..* Built 2 countersunk holes for 4 mm wood screws in Plate: 4\.7 mm through, countersink 8\.8 mm at 90°.* PartBuddy measured that plastic: it is there\./, + /Built 2 keyhole slots for 4 mm wood screws in Plate: entry 9 mm for the head, a slot 4\.5 mm wide running 8 mm up .* PartBuddy measured that plastic before building, from 0\.5 mm under the face on: it is there\..* Built 2 countersunk holes for 4 mm wood screws in Plate: 4\.7 mm through, countersink 8\.8 mm at 90°.* PartBuddy measured that plastic, from 0\.5 mm under the face on: it is there\./, ), ).toBeVisible({ timeout: 60_000 }); await expect(page.getByTestId('mesh-summary')).toContainText('1 body', { timeout: 30_000 }); // Each block is one line of the work log, and no feature failed. await expect(page.getByText('Keyhole slot for a 4 mm screw, to hang on a wall')).toHaveCount(1); await expect(page.getByText('Countersunk hole for a 4 mm wood screw')).toHaveCount(1); - await expect(page.getByText(/Built 2 pockets for 6 × 2 mm disc magnets in Plate: 6\.3 mm wide and 2\.2 mm deep.* PartBuddy measured that plastic before building: it is there\./)).toBeVisible(); + await expect(page.getByText(/Built 2 pockets for 6 × 2 mm disc magnets in Plate: 6\.3 mm wide and 2\.2 mm deep.* PartBuddy measured that plastic before building, from 0\.5 mm under the face on: it is there\./)).toBeVisible(); await expect(page.getByText('Pocket for a 6 × 2 mm magnet')).toHaveCount(1); await expect(page.getByText(/SolidCraft warns:/)).toHaveCount(0); await expect(page.locator('[data-testid="timeline-feature"][data-state="error"]')).toHaveCount(0); diff --git a/apps/desktop/src/main/agents/blockTool.ts b/apps/desktop/src/main/agents/blockTool.ts index c69ffc26..a4d94468 100644 --- a/apps/desktop/src/main/agents/blockTool.ts +++ b/apps/desktop/src/main/agents/blockTool.ts @@ -147,24 +147,34 @@ export const SEAT_UNDER = 0.6; /** The plastic round an insert: what its maker asks for, and never under 2 mm (as a boss has it). */ const seatWall = (size: BlockSize) => Math.max(2, INSERT[size]?.wall ?? 2); -/** The plastic the seats of a block need, measured after they are cut: from half their depth on, the seat itself taken off. */ -export function seatStock(block: Extract, axis: Vec3): Stock[] { - const from = Math.round(block.depth * 500) / 1000; +/** + * In mm: how far under the point the plastic is measured from. A face may fall away that far within the width of the + * stock, as a curved one does (0.47 mm under a magnet pocket on a rod of 20 mm radius); an edge or a chamfer falls + * further and is read as missing plastic. + */ +export const FACE_DROP = 0.5; + +/** mm³ a hole `width` wide may take less than its cylinder on a face that falls away {@link FACE_DROP} at the rim of its stock (the cap of a ball). */ +const rimDrop = (width: number, stock: number) => (Math.PI * FACE_DROP * width ** 4) / (8 * stock ** 2); + +/** The plastic the seat of an insert needs, measured after it is cut: {@link FACE_DROP} under the face on, the seat itself taken off. */ +export function seatStock(block: Extract, point: Vec3, axis: Vec3): Stock { const round = (value: number) => Math.round(value * 1000) / 1000; - return [block.point, ...(block.more ?? [])].map((point) => ({ + const area = (Math.PI * block.diameter ** 2) / 4; + return { at: point, - base: point.map((value, k) => round(value + (axis[k] ?? 0) * from)) as Vec3, + base: point.map((value, k) => round(value + (axis[k] ?? 0) * FACE_DROP)) as Vec3, axis, diameter: round(block.diameter + 2 * seatWall(block.size)), - depth: round(block.depth - from + SEAT_UNDER), - from, - hole: ((Math.PI * block.diameter ** 2) / 4) * (block.depth - from), - slack: ((Math.PI * block.diameter ** 2) / 4) * CURVED_FACE, - })); + depth: round(block.depth - FACE_DROP + SEAT_UNDER), + from: FACE_DROP, + hole: area * (block.depth - FACE_DROP), + slack: area * CURVED_FACE, + }; } /** A wall mounting the model asked for: its steps and what to say; null when the block is none of them; or what is wrong. */ -export function wallOf(args: Record): { body: string; steps: Step[]; text: string; stock?: Stock[]; cut?: (axis: Vec3) => Stock[]; need: string } | string | null { +export function wallOf(args: Record): { body: string; steps: Step[]; text: string; stock?: Stock[]; cut?: (point: Vec3, axis: Vec3) => Stock; need: string } | string | null { if (args.block !== 'keyhole' && args.block !== 'woodScrew') return null; const body = typeof args.body === 'string' ? args.body.trim() : ''; const point = vec3(args.point); @@ -179,9 +189,9 @@ export function wallOf(args: Record): { body: string; steps: St return { body, steps: woodScrewSteps({ body, point, more: more as Vec3[], screw, name: 'Wall screw' }), - cut: (axis) => woodScrewStock({ body, point, more: more as Vec3[], screw, name: 'Wall screw' }, axis), - need: `a countersunk hole for a ${String(screw)} mm wood screw needs ${String(screw)} mm of plastic round the countersink and a plate at least ${String(Math.round((countersinkDepth(screw) + WOOD_SCREW_UNDER) * 100) / 100)} mm thick. Thicken the plate, or move the hole away from edges and other cuts`, - text: `Built ${String(count)} countersunk hole${count > 1 ? 's' : ''} for ${String(screw)} mm wood screws in ${body}: ${String(size.clearance)} mm through, countersink ${String(size.countersink)} mm at 90°, so the head lies flush. Keep at least ${String(screw)} mm of plastic between a hole and an edge, and the plate thicker than the countersink is deep (${String(Math.round(((size.countersink - size.clearance) / 2) * 10) / 10)} mm).`, + cut: (at, axis) => woodScrewStock(screw, at, axis, FACE_DROP), + need: `a countersunk hole for a ${String(screw)} mm wood screw needs ${String(screw)} mm of plastic round its ${String(size.clearance)} mm hole and a plate at least ${String(Math.round((countersinkDepth(screw) + WOOD_SCREW_UNDER) * 100) / 100)} mm thick. Thicken the plate, or move the hole away from edges and other cuts`, + text: `Built ${String(count)} countersunk hole${count > 1 ? 's' : ''} for ${String(screw)} mm wood screws in ${body}: ${String(size.clearance)} mm through, countersink ${String(size.countersink)} mm at 90°, so the head lies flush. Keep at least ${String(screw)} mm of plastic between the ${String(size.clearance)} mm hole and an edge, and the plate thicker than the countersink is deep (${String(Math.round(((size.countersink - size.clearance) / 2) * 10) / 10)} mm).`, }; } const normal = vec3(args.normal); @@ -194,7 +204,7 @@ export function wallOf(args: Record): { body: string; steps: St return { body, steps: keyholeSteps(spec), - stock: keyholeStock(spec), + stock: keyholeStock(spec, FACE_DROP), need: `a keyhole needs the part ${String(keyholeDepth(screw))} mm thick behind its entry and its slot. Thicken the part there, move the keyhole away from edges and other cuts, or use woodScrew`, text: `Built ${String(count)} keyhole slot${count > 1 ? 's' : ''} for ${String(screw)} mm wood screws in ${body}: entry ${String(size.entry)} mm for the head, a slot ${String(size.slot)} mm wide running ${String(KEYHOLE.travel)} mm up to where the screw rests, ` + @@ -224,7 +234,7 @@ export function magnetOf(args: Record): { body: string; steps: return { body, steps: magnetSteps(spec), - stock: magnetStock(spec), + stock: magnetStock(spec, FACE_DROP), need: `a pocket needs ${String(MAGNET.around)} mm of plastic round it and ${String(MAGNET.under)} mm under it. Thicken the part there, move the pocket away from edges and other cuts, or take a thinner magnet`, text: `Built ${String(count)} pocket${count > 1 ? 's' : ''} for ${String(spec.diameter)} × ${String(spec.height)} mm disc magnets in ${body}: ${String(pocket.diameter)} mm wide and ${String(pocket.depth)} mm deep, so the magnet lies ${String(MAGNET.sunk)} mm under the face, with a chamfered mouth. ` + @@ -287,11 +297,14 @@ function jsonOf(result: ToolResult): unknown { } /** - * The way a block's holes went into the body, from what `inspect.measure` says before and after: the plastic they - * took lies on their axes, so its centre of mass lies under the middle of their points. SolidCraft's answer to - * `solid.hole` names no direction, and these blocks are given none. Null when it cannot be told. + * The way a hole went into the body, from what `inspect.measure` says before and after it: the plastic it took lies + * on its axis, so its centre of mass lies straight under the point. SolidCraft's answer to `solid.hole` names no + * direction, and these blocks are given none. One hole at a time: two holes that take unequal amounts (a stepped + * plate, a pocket under one of them) have no common centre under the middle of their points. Null when it cannot + * be told. Measured 2026-11-12: SolidCraft gives the centre of mass with all the digits of a double, and on a block + * of 200 × 200 × 50 mm the axis of one M3 seat came out within a hundredth of a degree. */ -export function cutAxis(body: string, before: unknown, after: unknown, points: Vec3[]): Vec3 | null { +export function cutAxis(body: string, before: unknown, after: unknown, point: Vec3): Vec3 | null { const of = (measured: unknown) => (measured as { bodies?: { name?: unknown; volume_mm3?: unknown; center_of_mass?: unknown }[] } | undefined)?.bodies?.find((entry) => entry.name === body); const [was, is] = [of(before), of(after)]; const [from, to] = [vec3(was?.center_of_mass), vec3(is?.center_of_mass)]; @@ -299,10 +312,18 @@ export function cutAxis(body: string, before: unknown, after: unknown, points: V if (typeof full !== 'number' || typeof left !== 'number' || !from || !to) return null; const taken = full - left; if (!(taken > 1e-6)) return null; - const way = from.map((value, k) => (value * full - (to[k] ?? 0) * left) / taken - points.reduce((sum, point) => sum + (point[k] ?? 0), 0) / points.length) as Vec3; + const way = from.map((value, k) => (value * full - (to[k] ?? 0) * left) / taken - (point[k] ?? 0)) as Vec3; const length = Math.hypot(...way); - return length > 1e-6 ? (way.map((value) => Math.round((value / length) * 1e6) / 1e6 + 0) as Vec3) : null; + // Nearer to the point than a tenth of a millimetre, the direction is noise. + return length > 0.1 ? (way.map((value) => Math.round((value / length) * 1e6) / 1e6 + 0) as Vec3) : null; +} + +/** How many features and sketches the design has, as `document.inspect` lists them; null when it does not. */ +function featuresOf(inspected: unknown): number | null { + const { timeline, sketches } = (inspected ?? {}) as { timeline?: unknown; sketches?: unknown }; + return Array.isArray(timeline) ? timeline.length + (Array.isArray(sketches) ? sketches.length : 0) : null; } +const undoDepth = (inspected: unknown) => count((inspected as { undo?: unknown } | undefined)?.undo); /** Volume in mm³ of every body by name as `inspect.measure` answers; NaN for a body that is not closed. */ function volumesOf(measured: unknown): Map { @@ -378,6 +399,41 @@ export async function shortOf(upstream: UpstreamTools, body: string, stock: Stoc } } +type Run = (commands: { command: string; params: Record }[]) => Promise; + +/** Undo steps tried beyond those the block sent, before giving up. */ +const UNDO_RESERVE = 3; + +/** + * Takes a block back that was built and must not stay: one Undo at a time, until the design measures as it did + * before (the same bodies with the same volumes, the same number of features, the history no deeper). The depth of + * the undo history alone does not say how many steps: SolidCraft 0.40.1 caps it at 200, so past that a block adds + * steps and the depth stays. Never more steps than the block sent plus a reserve. The top component is made active + * again, which Undo leaves as the block set it. False when the design does not come back to where it was. + */ +async function takeBack(run: Run, before: Map, was: unknown, sent: number): Promise { + const same = (now: Map) => + now.size === before.size && [...before].every(([name, volume]) => now.has(name) && (Number.isNaN(volume) ? Number.isNaN(now.get(name)) : Math.abs((now.get(name) ?? NaN) - volume) <= 1e-6)); + let back = false; + try { + for (let step = 0; step <= sent + UNDO_RESERVE; step += 1) { + const [measured, inspected] = await run([ + { command: 'inspect.measure', params: {} }, + { command: 'document.inspect', params: {} }, + ]); + if (same(volumesOf(measured)) && featuresOf(inspected) === featuresOf(was) && undoDepth(inspected) <= undoDepth(was)) { + back = true; + break; + } + await run([{ command: 'edit.undo', params: {} }]); + } + } catch { + back = false; + } + await run([{ command: 'component.activate', params: { component: 0 } }]).catch(() => undefined); + return back; +} + /** What was built, with the sizes it took from the tables, for the model to tell the user. */ function built(block: FeatureBlock): string { const { block: kind, body, point, ...sizes } = block; @@ -390,7 +446,7 @@ function built(block: FeatureBlock): string { } /** The upstream tools plus `partbuddy_block`. */ -export function withBlockTool(upstream: UpstreamTools, keepFace: (body: string, face: string) => void = () => undefined): UpstreamTools { +export function withBlockTool(upstream: UpstreamTools, keepFace: (body: string, face: string) => void = () => undefined, log: (line: string) => void = () => undefined): UpstreamTools { return { instructions: () => upstream.instructions(), listTools: async () => [...(await upstream.listTools()), blockTool], @@ -418,21 +474,22 @@ export function withBlockTool(upstream: UpstreamTools, keepFace: (body: string, const fresh = wall !== null && 'fresh' in wall; const measure = { command: 'inspect.measure', params: {} }; const inspect = { command: 'document.inspect', params: {} }; - const depth = (inspected: unknown) => count((inspected as { undo?: unknown } | undefined)?.undo); const [measured, inspected] = await run([measure, inspect]); const before = volumesOf(measured); const missing = fresh ? null : misplaced(body, before, before); if (missing?.startsWith('there is no body')) return { content: [{ type: 'text', text: `Not built: ${missing}` }], isError: true }; // A pocket that comes out of the far side or of an edge is not built at all. - const { stock, cut, need } = (wall ?? {}) as { stock?: Stock[]; cut?: (axis: Vec3) => Stock[]; need?: string }; + const { stock, cut, need } = (wall ?? {}) as { stock?: Stock[]; cut?: (point: Vec3, axis: Vec3) => Stock; need?: string }; const short = stock ? await shortOf(upstream, body, stock, callId) : null; if (short) return { content: [{ type: 'text', text: `Not built: ${short}; ${need ?? ''}.` }], isError: true }; - // Holes that are given no direction are measured once they are cut, along the way they went in. - const holes: { points: Vec3[]; stock: (axis: Vec3) => Stock[]; need: string } | null = + // Holes that are given no direction are measured once they are cut, each along the way it went in. + const holes: { points: Vec3[]; stock: (point: Vec3, axis: Vec3) => Stock; whole?: number; drop?: number; need: string } | null = block?.block === 'insertSeat' ? { points: [block.point, ...(block.more ?? [])], - stock: (axis) => seatStock(block, axis), + stock: (point, axis) => seatStock(block, point, axis), + whole: ((Math.PI * block.diameter ** 2) / 4) * block.depth, + drop: rimDrop(block.diameter, block.diameter + 2 * seatWall(block.size)), need: `the seat of an ${block.size} insert needs ${String(seatWall(block.size))} mm of plastic round it and ${String(SEAT_UNDER)} mm under it. Thicken the part there, move the seat away from edges and other cuts, or use a boss`, } : cut && need @@ -440,23 +497,38 @@ export function withBlockTool(upstream: UpstreamTools, keepFace: (body: string, : null; let results: unknown[]; let after: boolean | null = null; + // One hole a step, measured after each: every hole has its own way in, also on a stepped part or on two faces. + const sent = holes ? sequence.flatMap((step) => [step, measure]) : [...sequence, measure]; try { - results = await run([...sequence, measure]); + results = await run(sent); // The point decides what SolidCraft cuts, so the named body is checked before the block stays. const wrong = fresh ? null : misplaced(body, before, volumesOf(results[results.length - 1])); if (wrong) throw new Error(wrong); if (holes) { - const axis = cutAxis(body, measured, results[results.length - 1], holes.points); - const thin = axis ? await shortOf(upstream, body, holes.stock(axis), callId) : undefined; + const stocks: Stock[] = []; + for (const [i, point] of holes.points.entries()) { + const [was, is] = [i === 0 ? measured : results[i * 2 - 1], results[i * 2 + 1]]; + const taken = (volumesOf(was).get(body) ?? NaN) - (volumesOf(is).get(body) ?? NaN); + // A seat is cut out of solid plastic: where its rim breaks out of the face it takes less than its cylinder. + if (holes.whole && taken < holes.whole * STOCK_FULL - (holes.drop ?? 0)) + throw new Error( + `at [${point.join(', ')}] the seat would cut ${String(Math.round(taken * 10) / 10)} mm³ of plastic where its whole cylinder is ${String(Math.round(holes.whole * 10) / 10)} mm³: its rim breaks out of the face, into an edge, a chamfer, the far side or another cut; ${holes.need}.`, + ); + const axis = cutAxis(body, was, is, point); + if (axis) stocks.push(holes.stock(point, axis)); + } + const thin = stocks.length === holes.points.length ? await shortOf(upstream, body, stocks, callId) : undefined; if (thin) throw new Error(`${thin}; ${holes.need}.`); after = thin === null; } } catch (cause) { - // Nothing of a block that failed half way, landed on another body or found too little plastic stays: undone - // step by step, as the work log goes back. (A batch with `rollback` or a checkpoint is no way back in - // SolidCraft 0.40.1: after the one the same modelled thread fails for real, and the other is gone once a batch has run.) - const steps = depth((await run([inspect]))[0]) - depth(inspected); - if (steps > 0) await run(Array.from({ length: steps }, () => ({ command: 'edit.undo', params: {} }))); + // Nothing of a block that failed half way, landed on another body or found too little plastic stays. (A batch with + // `rollback` or a checkpoint is no way back in SolidCraft 0.40.1: after the one the same modelled thread fails for + // real, and the other is gone once a batch has run.) + if (!(await takeBack(run, before, inspected, sequence.length))) { + log(`partbuddy_block ${String(args.block)} on ${body} could not be taken back after: ${(cause as Error).message.slice(0, 200)}`); + throw new Error(`${(cause as Error).message.replace(/\.$/, '')}. What was built of it could NOT be taken back: undo it before you call again`, { cause }); + } throw cause; } // The plate view lays the part on the face SolidCraft names (print-faces.json), as the slicer will. @@ -466,9 +538,18 @@ export function withBlockTool(upstream: UpstreamTools, keepFace: (body: string, const warning = (result as { warning?: unknown } | undefined)?.warning; return typeof warning === 'string' ? [warning.slice(0, 200)] : []; }); - // What was to be measured and was not is said, never passed over. + // What was measured is said as it was measured, and what was not is said too, never passed over. const unmeasured = ' PartBuddy could not measure that plastic this time: check it yourself.'; - const stockSaid = stock ? (short === null ? ' PartBuddy measured that plastic before building: it is there.' : unmeasured) : holes ? (after ? ' PartBuddy measured that plastic: it is there.' : unmeasured) : ''; + const under = `from ${String(FACE_DROP)} mm under the face on`; + const stockSaid = stock + ? short === null + ? ` PartBuddy measured that plastic before building, ${under}: it is there.` + : unmeasured + : holes + ? after + ? ` PartBuddy measured that plastic, ${under}${holes.whole ? ', and that each seat cut its whole cylinder' : ''}: it is there.` + : unmeasured + : ''; const said = (wall ? wall.text : block ? built(block) : '') + (block?.block === 'insertSeat' ? ` Each seat needs ${String(seatWall(block.size))} mm of plastic round it and ${String(SEAT_UNDER)} mm under it.` : ''); return { content: [{ type: 'text', text: `${said}${stockSaid}${warnings.length > 0 ? ` SolidCraft warns: ${warnings.join(' ')}` : ''}` }] }; } catch (cause) { diff --git a/apps/desktop/src/shared/magnet.ts b/apps/desktop/src/shared/magnet.ts index e26c4c8c..fe7740d3 100644 --- a/apps/desktop/src/shared/magnet.ts +++ b/apps/desktop/src/shared/magnet.ts @@ -39,15 +39,13 @@ export function magnetPocket(spec: Pick round(-value / length) + 0) as Vec3; const pocket = magnetPocket(spec); - const from = round(pocket.depth / 2); return [spec.point, ...spec.more].map((point) => ({ at: point, base: point.map((value, k) => round(value + (axis[k] ?? 0) * from)) as Vec3, axis, diameter: round(pocket.diameter + 2 * MAGNET.around), depth: round(pocket.depth - from + MAGNET.under), from })); } diff --git a/apps/desktop/src/shared/wallMount.ts b/apps/desktop/src/shared/wallMount.ts index 3f3c6193..b3c4ad7b 100644 --- a/apps/desktop/src/shared/wallMount.ts +++ b/apps/desktop/src/shared/wallMount.ts @@ -57,13 +57,13 @@ export function keyholeDepth(screw: WoodScrew): number { /** * The plastic each keyhole needs: as thick as {@link keyholeDepth} behind the entry and behind where the screw - * rests, measured from behind the lip on, so a face that is not quite flat is not read as missing plastic. + * rests, as cylinders that start `from` mm under the face. */ -export function keyholeStock(spec: KeyholeSpec): { at: Vec3; base: Vec3; axis: Vec3; diameter: number; depth: number; from: number }[] { +export function keyholeStock(spec: KeyholeSpec, from: number): { at: Vec3; base: Vec3; axis: Vec3; diameter: number; depth: number; from: number }[] { const [n, up] = [unit(spec.normal), unit(spec.up)]; const axis = n.map((value) => round(-value) + 0) as Vec3; - const room = { axis, diameter: WALL[spec.screw].entry, depth: round(keyholeDepth(spec.screw) - KEYHOLE.lip), from: KEYHOLE.lip }; - const behind = (point: Vec3) => ({ at: point, base: point.map((value, k) => round(value + (axis[k] ?? 0) * KEYHOLE.lip)) as Vec3, ...room }); + const room = { axis, diameter: WALL[spec.screw].entry, depth: round(keyholeDepth(spec.screw) - from), from }; + const behind = (point: Vec3) => ({ at: point, base: point.map((value, k) => round(value + (axis[k] ?? 0) * from)) as Vec3, ...room }); return [spec.point, ...spec.more].flatMap((rest) => [behind(rest), behind(rest.map((value, k) => round(value - (up[k] ?? 0) * KEYHOLE.travel)) as Vec3)]); } @@ -76,16 +76,19 @@ export function countersinkDepth(screw: WoodScrew): number { } /** - * The plastic each countersunk hole needs: the screw's diameter of plastic round the countersink, and the plate - * thicker than the countersink is deep. `axis` is the way the holes go into the part; the hole itself is taken off. + * The plastic one countersunk hole needs: the screw's diameter of plastic round its clearance hole (so the + * countersink keeps about 2 mm, and a 15 mm tab takes a 4 mm screw), and the plate thicker than the countersink is + * deep. `axis` is the way the hole went into the part, `from` how far under the face the cylinder starts; the hole + * itself is taken off. */ -export function woodScrewStock(spec: WoodScrewSpec, axis: Vec3): { at: Vec3; base: Vec3; axis: Vec3; diameter: number; depth: number; from: number; hole: number }[] { - const size = WALL[spec.screw]; - const from = round(countersinkDepth(spec.screw) / 2); - const [wide, narrow] = [(size.clearance + size.countersink) / 4, size.clearance / 2]; - // The lower half of the cone, then the clearance hole. - const hole = (Math.PI * from * (wide ** 2 + wide * narrow + narrow ** 2)) / 3 + Math.PI * narrow ** 2 * WOOD_SCREW_UNDER; - return [spec.point, ...spec.more].map((point) => ({ at: point, base: point.map((value, k) => round(value + (axis[k] ?? 0) * from)) as Vec3, axis, diameter: round(size.countersink + 2 * spec.screw), depth: round(from + WOOD_SCREW_UNDER), from, hole })); +export function woodScrewStock(screw: WoodScrew, point: Vec3, axis: Vec3, from: number): { at: Vec3; base: Vec3; axis: Vec3; diameter: number; depth: number; from: number; hole: number; slack: number } { + const size = WALL[screw]; + const cone = Math.max(0, countersinkDepth(screw) - from); + // The 90° cone is as much wider than the clearance hole as it still has to go down. + const [wide, narrow] = [size.clearance / 2 + cone, size.clearance / 2]; + const hole = (Math.PI * cone * (wide ** 2 + wide * narrow + narrow ** 2)) / 3 + Math.PI * narrow ** 2 * WOOD_SCREW_UNDER; + // On a curved face the hole does not end where it ends on a flat one: 0.3 mm of its depth are allowed for that. + return { at: point, base: point.map((value, k) => round(value + (axis[k] ?? 0) * from)) as Vec3, axis, diameter: round(size.clearance + 2 * screw), depth: round(cone + WOOD_SCREW_UNDER), from, hole, slack: Math.PI * narrow ** 2 * 0.3 }; } /** What is wrong with a keyhole before anything is built; null when it can be built. */ diff --git a/apps/desktop/test/blockTool.test.ts b/apps/desktop/test/blockTool.test.ts index a1e4d761..1ae7be4a 100644 --- a/apps/desktop/test/blockTool.test.ts +++ b/apps/desktop/test/blockTool.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { blockOf, cutAxis, knobOf, magnetOf, misplaced, seatStock, shortOf, wallOf, withBlockTool } from '../src/main/agents/blockTool'; +import { FACE_DROP, blockOf, cutAxis, knobOf, magnetOf, misplaced, seatStock, shortOf, wallOf, withBlockTool } from '../src/main/agents/blockTool'; import type { ToolResult, UpstreamTools } from '../src/main/agents/gateProxy'; import { decide } from '../src/main/agents/policy'; import { KNOB, knobProblem, knobSteps, type KnobSpec } from '../src/shared/knob'; @@ -258,26 +258,44 @@ describe('a block that lands on another body is taken back (audit W1)', () => { }; const answer = await withBlockTool(upstream).callTool('partbuddy_block', { block: 'screwHole', body: 'A', point: [30, 10, 20], more: [[40, 10, 20]] }); expect(answer).toMatchObject({ isError: true, content: [{ text: 'Not built: at this point it would change B, not only A; give a point on a face of A alone, away from other bodies' }] }); - expect(batches).toEqual(['inspect.measure document.inspect', 'solid.hole solid.hole inspect.measure', 'document.inspect', 'edit.undo edit.undo']); + // One Undo at a time, measured before each, until the design is as it was (this double takes both holes back with one); then the top component is made active. + expect(batches).toEqual(['inspect.measure document.inspect', 'solid.hole solid.hole inspect.measure', 'inspect.measure document.inspect', 'edit.undo', 'inspect.measure document.inspect', 'component.activate']); expect(String((await withBlockTool(upstream).callTool('partbuddy_block', { block: 'screwHole', body: 'Ghost', point: [30, 10, 20] })).content[0]?.text)).toBe('Not built: there is no body named Ghost; the design has A, B'); }); }); describe('a block is built only where the part has the plastic for it (audit R54)', () => { + interface PlateOptions { + /** The plate is this thick from x = 30 on: a step. */ + thin?: number; + split?: boolean; + /** The share of its cylinder a hole takes: under 1 where its rim breaks out of the face. */ + rim?: number; + /** SolidCraft's cap on the depth of the undo history, and how many steps the design already has. */ + cap?: number; + steps?: number; + /** Undo does nothing: a block cannot be taken back. */ + stuck?: boolean; + } /** - * A plate 40 × 40 mm and `thick` mm thick with its top at z = thick, as SolidCraft 0.40.1 answers: the share of a - * probe cylinder along Z that lies in the plate, holes drilled from the top, each loose piece a body of its own. + * A plate lying on z = 0, `thick` mm thick (`thin` from x = 30 on), as SolidCraft 0.40.1 answers: the share of a probe + * cylinder along Z that lies in it, holes drilled down from their point, each loose piece a body of its own, the undo + * depth capped. A probe that does not run along Z finds no plastic: a wrong axis shows as a refusal. */ - function plate(thick: number, split = false) { + function plate(thick: number, { thin = thick, split = false, rim = 1, cap = 200, steps = 1, stuck = false }: PlateOptions = {}) { const batches: string[] = []; - const holes: { diameter: number; depth: number }[] = []; + const holes: { x: number; y: number; top: number; diameter: number; depth: number }[] = []; const num = (value: unknown) => parseFloat(String(value)); const area = (diameter: number) => (Math.PI * diameter ** 2) / 4; + const topAt = (x: number) => (x < 30 ? thick : thin); + const reach = (hole: { top: number; depth: number }) => Math.min(hole.depth, hole.top); + let active = 0; const measured = () => { - const taken = holes.map((hole) => ({ volume: area(hole.diameter) * Math.min(hole.depth, thick), z: thick - Math.min(hole.depth, thick) / 2 })); - const volume = 1600 * thick - taken.reduce((sum, hole) => sum + hole.volume, 0); - const z = (1600 * thick * (thick / 2) - taken.reduce((sum, hole) => sum + hole.volume * hole.z, 0)) / volume; - return { bodies: [{ name: 'Plate', volume_mm3: volume, center_of_mass: [20, 20, z] }, ...(split && holes.length > 0 ? [{ name: 'Plate (1)', volume_mm3: 100, center_of_mass: [0, 0, 0] }] : [])] }; + const [solid, centre] = [100_000, [50, 20, 3]]; + const taken = holes.map((hole) => ({ volume: area(hole.diameter) * reach(hole) * rim, at: [hole.x, hole.y, hole.top - reach(hole) / 2] })); + const volume = solid - taken.reduce((sum, hole) => sum + hole.volume, 0); + const com = centre.map((value, k) => (solid * value - taken.reduce((sum, hole) => sum + hole.volume * (hole.at[k] ?? 0), 0)) / volume); + return { bodies: [{ name: 'Plate', volume_mm3: volume, center_of_mass: com }, ...(split && holes.length > 0 ? [{ name: 'Plate (1)', volume_mm3: 100, center_of_mass: [0, 0, 0] }] : [])] }; }; const upstream: UpstreamTools = { instructions: () => undefined, @@ -286,59 +304,86 @@ describe('a block is built only where the part has the plastic for it (audit R54 const commands = args.commands as { command: string; params: Record }[]; batches.push(commands.map((step) => step.command).join(' ')); const trial = args.rollback === true; - let probe = { diameter: 0, top: 0, bottom: 0 }; + let probe = { x: 0, y: 0, diameter: 0, top: 0, bottom: 0, alongZ: true }; const results = commands.map((step) => { + if (step.command === 'component.activate') active = step.params.component as number; if (trial && step.command === 'solid.cylinder') { - const [z, way, height] = [(step.params.base as number[])[2] ?? 0, (step.params.axis as number[])[2] ?? 0, num(step.params.height)]; - probe = { diameter: num(step.params.diameter), top: Math.max(z, z + way * height), bottom: Math.min(z, z + way * height) }; + const [base, axis, height] = [step.params.base as number[], step.params.axis as number[], num(step.params.height)]; + const [z, way] = [base[2] ?? 0, axis[2] ?? 0]; + probe = { x: base[0] ?? 0, y: base[1] ?? 0, diameter: num(step.params.diameter), top: Math.max(z, z + way * height), bottom: Math.min(z, z + way * height), alongZ: Math.abs(Math.abs(way) - 1) < 1e-3 }; } if (step.command === 'inspect.interference') { const within = (top: number, bottom: number) => Math.max(0, Math.min(top, probe.top) - Math.max(bottom, probe.bottom)); - const cut = holes.reduce((sum, hole) => sum + area(hole.diameter) * within(thick, Math.max(0, thick - hole.depth)), 0); - return { interferences: [{ a: 'Plate', b: 'PartBuddy probe 0', volume_mm3: area(probe.diameter) * within(thick, 0) - cut }] }; + const cut = holes.filter((hole) => Math.hypot(hole.x - probe.x, hole.y - probe.y) < 1e-6).reduce((sum, hole) => sum + area(hole.diameter) * within(hole.top, hole.top - reach(hole)), 0); + return { interferences: [{ a: 'Plate', b: 'PartBuddy probe 0', volume_mm3: probe.alongZ ? area(probe.diameter) * within(topAt(probe.x), 0) - cut : 0 }] }; + } + if (!trial && (step.command === 'solid.hole' || step.command === 'solid.cylinder')) { + const at = (step.params.position ?? step.params.base) as number[]; + holes.push({ x: at[0] ?? 0, y: at[1] ?? 0, top: at[2] ?? 0, diameter: num(step.params.diameter ?? '3.4'), depth: num(step.params.depth ?? step.params.height ?? '1000') }); } - if (!trial && (step.command === 'solid.hole' || step.command === 'solid.cylinder')) holes.push({ diameter: num(step.params.diameter ?? '3.4'), depth: num(step.params.depth ?? step.params.height ?? '1000') }); - if (step.command === 'edit.undo') holes.pop(); + if (step.command === 'edit.undo' && !stuck) holes.pop(); if (step.command === 'inspect.measure') return measured(); - if (step.command === 'document.inspect') return { undo: 1 + holes.length }; + if (step.command === 'document.inspect') return { undo: Math.min(cap, steps + holes.length), timeline: Array.from({ length: steps + holes.length }), sketches: [] }; return {}; }); // The trial ends on a step that must fail; SolidCraft answers it as an error that carries the results before it. return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify(trial ? { results: results.slice(0, -1), failed: { command: 'sketch.inspect' } } : { results }) }], ...(trial ? { isError: true } : {}) }); }, }; - return { tools: withBlockTool(upstream), upstream, batches, holes }; + const logged: string[] = []; + return { tools: withBlockTool(upstream, () => undefined, (line) => logged.push(line)), upstream, batches, holes, logged, active: () => active }; } const text = async (answer: Promise) => String((await answer).content[0]?.text); + const seatNeed = 'the seat of an M3 insert needs 2 mm of plastic round it and 0.6 mm under it. Thicken the part there, move the seat away from edges and other cuts, or use a boss.'; - it('names the plastic each block needs, measured from under the face so a curved face is not read as a gap', () => { + it('names the plastic each block needs, measured from half a millimetre under the face on', () => { const key: KeyholeSpec = { body: 'Plate', point: [20, 30, 0], normal: [0, 0, -1], up: [0, 1, 0], screw: 4, more: [], name: 'Keyhole' }; - // Behind the lip (2 mm) down to 6.7 mm, where the screw rests and at the entry 8 mm below it. - expect(keyholeStock(key)).toEqual([ - { at: [20, 30, 0], base: [20, 30, 2], axis: [0, 0, 1], diameter: 9, depth: 4.7, from: 2 }, - { at: [20, 22, 0], base: [20, 22, 2], axis: [0, 0, 1], diameter: 9, depth: 4.7, from: 2 }, + // Down to 6.7 mm, where the screw rests and at the entry 8 mm below it. + expect(keyholeStock(key, FACE_DROP)).toEqual([ + { at: [20, 30, 0], base: [20, 30, 0.5], axis: [0, 0, 1], diameter: 9, depth: 6.2, from: 0.5 }, + { at: [20, 22, 0], base: [20, 22, 0.5], axis: [0, 0, 1], diameter: 9, depth: 6.2, from: 0.5 }, ]); - // A 6 × 2 mm magnet: 6.3 + 2 × 1.2 wide, from half its 2.2 mm pocket down to 0.6 mm under it. - expect(magnetStock({ body: 'Plate', point: [10, 10, 3], normal: [0, 0, 2], more: [[30, 10, 3]], diameter: 6, height: 2, hold: 'glue', name: 'Magnet' })).toEqual([ - { at: [10, 10, 3], base: [10, 10, 1.9], axis: [0, 0, -1], diameter: 8.7, depth: 1.7, from: 1.1 }, - { at: [30, 10, 3], base: [30, 10, 1.9], axis: [0, 0, -1], diameter: 8.7, depth: 1.7, from: 1.1 }, + // A 6 × 2 mm magnet: 6.3 + 2 × 1.2 wide, down to 0.6 mm under its 2.2 mm pocket. + expect(magnetStock({ body: 'Plate', point: [10, 10, 3], normal: [0, 0, 2], more: [[30, 10, 3]], diameter: 6, height: 2, hold: 'glue', name: 'Magnet' }, FACE_DROP)).toEqual([ + { at: [10, 10, 3], base: [10, 10, 2.5], axis: [0, 0, -1], diameter: 8.7, depth: 2.3, from: 0.5 }, + { at: [30, 10, 3], base: [30, 10, 2.5], axis: [0, 0, -1], diameter: 8.7, depth: 2.3, from: 0.5 }, ]); - // The seat of an M3 insert: 4 + 2 × 2 wide, from half its 6.7 mm down to 0.6 mm under it, less the seat itself. - const [seat] = seatStock({ block: 'insertSeat', body: 'Plate', point: [10, 10, 10], size: 'M3', diameter: 4, depth: 6.7 }, [0, 0, -1]); - expect(seat).toMatchObject({ at: [10, 10, 10], base: [10, 10, 6.65], diameter: 8, depth: 3.95, from: 3.35 }); - expect(seat?.hole).toBeCloseTo(Math.PI * 4 * 3.35, 6); - // A 4 mm wood screw: 8.8 + 2 × 4 wide, from half the 2.05 mm countersink down to 0.6 mm of clearance hole under it. - const [screw] = woodScrewStock({ body: 'Plate', point: [10, 10, 10], more: [], screw: 4, name: 'Wall screw' }, [0, 0, -1]); - expect(screw).toMatchObject({ base: [10, 10, 8.975], diameter: 16.8, depth: 1.625, from: 1.025 }); + // The seat of an M3 insert: 4 + 2 × 2 wide, down to 0.6 mm under its 6.7 mm, less the seat itself. + const seat = seatStock({ block: 'insertSeat', body: 'Plate', point: [10, 10, 10], size: 'M3', diameter: 4, depth: 6.7 }, [10, 10, 10], [0, 0, -1]); + expect(seat).toMatchObject({ at: [10, 10, 10], base: [10, 10, 9.5], diameter: 8, depth: 6.8, from: 0.5 }); + expect(seat.hole).toBeCloseTo(Math.PI * 4 * 6.2, 6); + // A 4 mm wood screw: 4 mm of plastic round its 4.7 mm hole (so a 15 mm tab takes it), down to 0.6 mm of that hole under the 2.05 mm countersink. + const screw = woodScrewStock(4, [10, 10, 10], [0, 0, -1], FACE_DROP); + expect(screw).toMatchObject({ base: [10, 10, 9.5], diameter: 12.7, depth: 2.15, from: 0.5 }); + expect(screw.diameter).toBeLessThan(15); + // What the hole itself takes of that cylinder: the cone from 7.8 mm at its start down to 4.7 mm, then the hole. + expect(screw.hole).toBeCloseTo((Math.PI * 1.55 * (3.9 ** 2 + 3.9 * 2.35 + 2.35 ** 2)) / 3 + Math.PI * 2.35 ** 2 * 0.6, 6); }); - it('reads the way a hole went in from what it took out of the body', () => { + it('reads the way a hole went in from what it took out of the body, one hole at a time', () => { const before = { bodies: [{ name: 'Plate', volume_mm3: 16000, center_of_mass: [20, 20, 5] }] }; // A hole of 100 mm³ whose middle lies at [10, 10, 7], drilled from [10, 10, 10]. const after = { bodies: [{ name: 'Plate', volume_mm3: 15900, center_of_mass: [(16000 * 20 - 100 * 10) / 15900, (16000 * 20 - 100 * 10) / 15900, (16000 * 5 - 100 * 7) / 15900] }] }; - expect(cutAxis('Plate', before, after, [[10, 10, 10]])).toEqual([0, 0, -1]); - expect(cutAxis('Plate', before, before, [[10, 10, 10]])).toBeNull(); - expect(cutAxis('Ghost', before, after, [[10, 10, 10]])).toBeNull(); + expect(cutAxis('Plate', before, after, [10, 10, 10])).toEqual([0, 0, -1]); + expect(cutAxis('Plate', before, before, [10, 10, 10])).toBeNull(); + expect(cutAxis('Ghost', before, after, [10, 10, 10])).toBeNull(); + // A centre of mass nearer to the point than a tenth of a millimetre tells no direction. + expect(cutAxis('Plate', before, after, [10, 10, 7.05])).toBeNull(); + }); + + it('measures each of several holes along its own way in: two wood screws on a stepped plate (review 2 of PR 357)', async () => { + // 8 mm thick at x = 10, 4 mm at x = 50: the holes take unequal amounts, and their common centre lies askew under the middle. + const stepped = plate(8, { thin: 4 }); + expect(await text(stepped.tools.callTool('partbuddy_block', { block: 'woodScrew', body: 'Plate', point: [10, 20, 8], more: [[50, 20, 4]] }))).toMatch( + /^Built 2 countersunk holes .* PartBuddy measured that plastic, from 0\.5 mm under the face on: it is there\.$/, + ); + // Each hole is cut and measured by itself, then both are probed in one trial. + expect(stepped.batches).toContain('solid.hole inspect.measure solid.hole inspect.measure'); + expect(stepped.holes).toHaveLength(2); + // Two seats, one of them over a pocket that takes its lower half: only that one is short. + const pocket = plate(10, { thin: 5 }); + expect(await text(pocket.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [10, 20, 10], more: [[50, 20, 5]] }))).toContain('Not built: at [50, 20, 5] the seat would cut'); + expect(pocket.holes).toEqual([]); }); it('builds no keyhole in a 3 mm plate and no magnet pocket through it, and says what it measured', async () => { @@ -362,17 +407,19 @@ describe('a block is built only where the part has the plastic for it (audit R54 callTool: (_name, args) => { const commands = args.commands as { command: string }[]; // The first probe (where the screw rests) is all plastic, the second (the entry) is half in the air. - const results = commands.map((step) => (step.command === 'inspect.interference' ? { interferences: [{ volume_mm3: ((Math.PI * 81) / 4) * 4.7 * (probes++ === 0 ? 1 : 0.5) }] } : {})); + const results = commands.map((step) => (step.command === 'inspect.interference' ? { interferences: [{ volume_mm3: ((Math.PI * 81) / 4) * 6.2 * (probes++ === 0 ? 1 : 0.5) }] } : {})); return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }], isError: true }); }, }; - const stock = keyholeStock({ body: 'Plate', point: [20, 5, 0], normal: [0, 0, -1], up: [0, 1, 0], screw: 4, more: [], name: 'Keyhole' }); - expect(await shortOf(upstream, 'Plate', stock, undefined)).toContain('at [20, -3, 0] Plate has 4.35 mm of plastic where 6.7 mm are needed'); + const stock = keyholeStock({ body: 'Plate', point: [20, 5, 0], normal: [0, 0, -1], up: [0, 1, 0], screw: 4, more: [], name: 'Keyhole' }, FACE_DROP); + expect(await shortOf(upstream, 'Plate', stock, undefined)).toContain('at [20, -3, 0] Plate has 3.6 mm of plastic where 6.7 mm are needed'); }); - it('builds them where the plastic is, and says that it measured', async () => { + it('builds them where the plastic is, and says what it measured and from where', async () => { const { tools } = plate(10); - expect(await text(tools.callTool('partbuddy_block', { block: 'magnet', body: 'Plate', point: [20, 10, 10], normal: [0, 0, 1] }))).toMatch(/^Built 1 pocket .* PartBuddy measured that plastic before building: it is there\.$/); + expect(await text(tools.callTool('partbuddy_block', { block: 'magnet', body: 'Plate', point: [20, 10, 10], normal: [0, 0, 1] }))).toMatch( + /^Built 1 pocket .* PartBuddy measured that plastic before building, from 0\.5 mm under the face on: it is there\.$/, + ); }); it('says so when the plastic could not be measured, instead of passing it over', async () => { @@ -381,27 +428,40 @@ describe('a block is built only where the part has the plastic for it (audit R54 }); it('takes back the seat of an insert that comes out of the far side, and one that leaves no floor', async () => { - const need = 'the seat of an M3 insert needs 2 mm of plastic round it and 0.6 mm under it. Thicken the part there, move the seat away from edges and other cuts, or use a boss.'; const thin = plate(3); expect(await text(thin.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [20, 20, 3] }))).toBe( - `Not built: at [20, 20, 3] Plate has less than 3.35 mm of plastic where 7.3 mm are needed (the mean over a width of 8 mm; the far side, an edge or another cut takes the rest); ${need}`, + `Not built: at [20, 20, 3] the seat would cut 37.7 mm³ of plastic where its whole cylinder is 84.2 mm³: its rim breaks out of the face, into an edge, a chamfer, the far side or another cut; ${seatNeed}`, ); - expect(thin.batches.at(-1)).toBe('edit.undo'); expect(thin.holes).toEqual([]); // A plate just as thick as the seat is deep: the hole is whole, and has no floor (review of PR 357). const flush = plate(6.7); - expect(await text(flush.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [20, 20, 6.7] }))).toContain('Not built: at [20, 20, 6.7] Plate has 6.54 mm of plastic where 7.3 mm are needed'); + expect(await text(flush.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [20, 20, 6.7] }))).toBe( + `Not built: at [20, 20, 6.7] Plate has 6.52 mm of plastic where 7.3 mm are needed (the mean over a width of 8 mm; the far side, an edge or another cut takes the rest); ${seatNeed}`, + ); expect(flush.holes).toEqual([]); const thick = plate(10); expect(await text(thick.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [20, 20, 10] }))).toBe( - 'Built insertSeat on Plate at [20, 20, 10]: size M3, diameter 4, depth 6.7. Each seat needs 2 mm of plastic round it and 0.6 mm under it. PartBuddy measured that plastic: it is there.', + 'Built insertSeat on Plate at [20, 20, 10]: size M3, diameter 4, depth 6.7. Each seat needs 2 mm of plastic round it and 0.6 mm under it. PartBuddy measured that plastic, from 0.5 mm under the face on, and that each seat cut its whole cylinder: it is there.', + ); + }); + + it('takes back a seat whose rim breaks into a chamfer or an edge: it cuts less than its cylinder (review 2 of PR 357)', async () => { + // At a 3 mm chamfer the seat of an M3 insert took 1.3 % less than its cylinder; the plastic lower down is all there. + const chamfer = plate(20, { rim: 0.987 }); + expect(await text(chamfer.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [20, 20, 20] }))).toContain( + 'Not built: at [20, 20, 20] the seat would cut 83.1 mm³ of plastic where its whole cylinder is 84.2 mm³: its rim breaks out of the face, into an edge, a chamfer, the far side or another cut', ); + expect(chamfer.holes).toEqual([]); + // A curved face takes a little off the rim as well, and passes: 0.03 % on a rod of 20 mm radius, as measured. + const rod = plate(20, { rim: 0.9997 }); + expect(await text(rod.tools.callTool('partbuddy_block', { block: 'insertSeat', body: 'Plate', point: [20, 20, 20] }))).toMatch(/^Built insertSeat /); }); it('takes back a countersunk hole in a plate thinner than the countersink with its hole under it', async () => { const thin = plate(2); - expect(await text(thin.tools.callTool('partbuddy_block', { block: 'woodScrew', body: 'Plate', point: [20, 20, 2] }))).toContain( - 'a countersunk hole for a 4 mm wood screw needs 4 mm of plastic round the countersink and a plate at least 2.65 mm thick. Thicken the plate, or move the hole away from edges and other cuts.', + // (The double drills a plain hole, so the figure it "has" is not the engine's; that one is in the audit harness.) + expect(await text(thin.tools.callTool('partbuddy_block', { block: 'woodScrew', body: 'Plate', point: [20, 20, 2] }))).toMatch( + /^Not built: at \[20, 20, 2\] Plate has [\d.]+ mm of plastic where 2\.65 mm are needed \(the mean over a width of 12\.7 mm; the far side, an edge or another cut takes the rest\); a countersunk hole for a 4 mm wood screw needs 4 mm of plastic round its 4\.7 mm hole and a plate at least 2\.65 mm thick\. Thicken the plate, or move the hole away from edges and other cuts\.$/, ); expect(thin.holes).toEqual([]); }); @@ -410,9 +470,61 @@ describe('a block is built only where the part has the plastic for it (audit R54 expect(misplaced('Strip', new Map([['Strip', 720]]), new Map([['Strip', 300], ['Strip (1)', 300]]))).toBe( 'it would leave 2 bodies where Strip was one (Strip (1)): the cut goes right through the part there. Give a point with more plastic round it, or a smaller size', ); - const { tools, batches } = plate(10, true); + const { tools, holes } = plate(10, { split: true }); expect(await text(tools.callTool('partbuddy_block', { block: 'screwHole', body: 'Plate', point: [10, 10, 10], head: 'none' }))).toContain('Not built: it would leave 2 bodies where Plate was one (Plate (1))'); - expect(batches.at(-1)).toBe('edit.undo'); + expect(holes).toEqual([]); + }); +}); + +describe('a block is taken back by what the design measures, not by the depth of the undo history (review 2 of PR 357)', () => { + /** A design whose undo history is full: SolidCraft 0.40.1 reads 200 however many steps there are. */ + function capped(stuck = false) { + const batches: string[] = []; + let holes = 0; + let active = 7; + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve([]), + callTool: (_name, args) => { + const commands = args.commands as { command: string; params: Record }[]; + batches.push(commands.map((step) => step.command).join(' ')); + const results = commands.map((step) => { + if (step.command === 'solid.hole') holes += 1; + if (step.command === 'edit.undo' && !stuck) holes -= 1; + if (step.command === 'component.activate') active = step.params.component as number; + // Every hole lands on body B as well: the block must not stay. + if (step.command === 'inspect.measure') return { bodies: [{ name: 'A', volume_mm3: 16000 - holes * 50 }, { name: 'B', volume_mm3: 48000 - holes * 50 }] }; + if (step.command === 'document.inspect') return { undo: 200, timeline: Array.from({ length: 260 + holes }), sketches: [] }; + return {}; + }); + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }] }); + }, + }; + const logged: string[] = []; + return { tools: withBlockTool(upstream, () => undefined, (line) => logged.push(line)), batches, logged, left: () => holes, active: () => active }; + } + + it('undoes both holes of a block though the depth reads 200 before and after, and makes the top component active again', async () => { + const design = capped(); + const answer = await design.tools.callTool('partbuddy_block', { block: 'screwHole', body: 'A', point: [30, 10, 20], more: [[40, 10, 20]] }); + expect(answer).toMatchObject({ isError: true, content: [{ text: 'Not built: at this point it would change B, not only A; give a point on a face of A alone, away from other bodies' }] }); + expect(design.left()).toBe(0); + expect(design.batches.filter((batch) => batch === 'edit.undo')).toHaveLength(2); + expect(design.batches.at(-1)).toBe('component.activate'); + expect(design.active()).toBe(0); + expect(design.logged).toEqual([]); + }); + + it('gives up after the steps it sent and three more, and says that the block is still there', async () => { + const design = capped(true); + const answer = await design.tools.callTool('partbuddy_block', { block: 'screwHole', body: 'A', point: [30, 10, 20] }); + expect(String(answer.content[0]?.text)).toBe( + 'Not built: at this point it would change B, not only A; give a point on a face of A alone, away from other bodies. What was built of it could NOT be taken back: undo it before you call again', + ); + // One step sent, three in reserve. + expect(design.batches.filter((batch) => batch === 'edit.undo')).toHaveLength(5); + expect(design.logged).toEqual(['partbuddy_block screwHole on A could not be taken back after: at this point it would change B, not only A; give a point on a face of A alone, away from other bodies']); + expect(design.active()).toBe(0); }); }); From 0a3f08c0d8b59891fcba4509ad1fda1b92afc67b Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:18:04 +0100 Subject: [PATCH 19/24] fix(desktop): the check still comes past 200 undo steps and never ends on a preliminary note Second review of PR 357. At the cap of SolidCraft's undo history a moved revision counts as a change, or no step past it got its check. A preliminary check says how to get the full one in its short form too, and from partbuddy_view that the rest is coming. A clearance or joint measure that failed is named as not measured. Whether a check is unchanged is read off all its findings, not the lines shown. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/agents/workLog.ts | 8 ++- apps/desktop/src/main/index.ts | 4 +- apps/desktop/src/main/print/printCheckNote.ts | 24 ++++--- apps/desktop/src/renderer/src/print/report.ts | 3 +- .../src/renderer/src/print/usePrintCheck.ts | 14 ++-- apps/desktop/src/shared/contract.ts | 2 + apps/desktop/test/checkOfChange.test.ts | 68 +++++++++++++++++-- 7 files changed, 101 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/main/agents/workLog.ts b/apps/desktop/src/main/agents/workLog.ts index fbaff460..fa816257 100644 --- a/apps/desktop/src/main/agents/workLog.ts +++ b/apps/desktop/src/main/agents/workLog.ts @@ -219,9 +219,15 @@ export class WorkLog { * The revision alone says nothing: a trial, a save and a block that was taken back move it and leave the design as it was. */ export function designChanged(before: DesignHistory, after: DesignHistory): boolean { - return before.documentId !== after.documentId || before.undo !== after.undo || (before.redo > 0 && after.redo === 0); + if (before.documentId !== after.documentId || before.undo !== after.undo || (before.redo > 0 && after.redo === 0)) return true; + // At the cap a step adds to the history and its depth stays: there a moved revision is all that tells, and it + // counts, at the price of a check too many after a trial. Without this no change past the cap got its check. + return after.undo >= UNDO_CAP && before.revision !== after.revision; } +/** How deep SolidCraft's undo history goes (0.40.1, measured 2026-11-12: 260 steps read as 200). */ +export const UNDO_CAP = 200; + /** True when the history grew or a new change replaced what Redo could have brought back. */ export function addedToHistory(before: DesignHistory, after: DesignHistory): boolean { if (before.documentId !== after.documentId || before.revision === after.revision) return false; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 85706ade..ec9cc112 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -372,7 +372,7 @@ if (!app.requestSingleInstanceLock()) { const checkForView = () => { if (!printCheck) return null; if (printCheck.revision !== supervisor.currentRevision()?.revision) return CHECK_PENDING_VIEW; - return printCheckNote(printCheck); + return printCheckNote(printCheck, true); }; // What the tool answers of this turn already said of the check: the same verdict is not told in full again. const checkTold = new CheckTold(); @@ -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), keepMechanismFace)), (trial) => supervisor.trialSteps(trial)), () => viewState, () => notesService.forView(), checkForView), + 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), () => viewState?.bodies.map((body) => body.name) ?? [], saveAssembly, ), diff --git a/apps/desktop/src/main/print/printCheckNote.ts b/apps/desktop/src/main/print/printCheckNote.ts index 04920e31..aaf160c5 100644 --- a/apps/desktop/src/main/print/printCheckNote.ts +++ b/apps/desktop/src/main/print/printCheckNote.ts @@ -72,9 +72,14 @@ function line(finding: Finding, nozzle: number): string { /** What the mesh check looks at at once (the finding kinds of printCheck.ts), so "nothing found" claims no more. */ const CHECKED_MESH = 'build volume, wall and floor thickness, overhang angle, area on the bed, open bodies, failed features, bodies that touch'; /** What SolidCraft measures after it (withClearance, withMotion): it takes longer and comes with the full check. */ -const CHECKED_MEASURED = 'bodies that overlap or come closer than a push fit, joints that stop before a limit'; -/** Said with a check whose measures are still out (two bodies or more, or a joint). */ -const PRELIMINARY = `Preliminary: clearance and joints still being measured (${CHECKED_MEASURED}); call partbuddy_view for the full check before you call the part done.`; +const MEASURED = { clearance: 'bodies that overlap or come closer than a push fit', joints: 'joints that stop before a limit' }; +const CHECKED_MEASURED = `${MEASURED.clearance}, ${MEASURED.joints}`; +/** What the model is to do while the measures are out; partbuddy_view itself says only that they are coming, or a small model calls it in circles. */ +const FETCH_FULL = 'call partbuddy_view for the full check before you call the part done'; +const preliminaryLine = (view: boolean) => `Preliminary: clearance and joints still being measured (${CHECKED_MEASURED}); ${view ? 'the full check will be here in a moment' : FETCH_FULL}.`; +/** A measure that failed is said, never read as "nothing found" (audit H-05). */ +const unmeasuredLine = (failed: readonly ('clearance' | 'joints')[]) => + `NOT measured, because SolidCraft's measure failed: ${failed.map((what) => MEASURED[what]).join(', ')}. Measure it yourself (${failed.map((what) => (what === 'clearance' ? 'inspect.clearance' : 'joint.sweep')).join(', ')}) before you call the part done.`; /** What a clean check says nothing about (audit R3); the model must not pass the verdict on as more. */ const NOT_CHECKED = 'Not checked: strength and layer direction under load, the fits and hole allowances you chose, details and text too fine to print, bed-edge chamfers. Judge those yourself.'; @@ -121,12 +126,14 @@ function findingLines(report: PrintCheckReport): string[] { * model reads it behind the answer of each tool that changed the design, and with the next message * when the result changed. Face and feature names come from the design, so they are quoted. */ -export function printCheckNote(report: PrintCheckReport): string { +export function printCheckNote(report: PrintCheckReport, view = false): string { const head = '[PartBuddy: ready-to-print check of the part as it is now (each body lying on its print face when it has one, else as designed along +Z on its own lowest point; overhangs by angle only):'; - const preliminary = report.measuring ? [PRELIMINARY] : []; + const failed = report.unmeasured ?? []; + const preliminary = [...(report.measuring ? [preliminaryLine(view)] : []), ...(failed.length > 0 ? [unmeasuredLine(failed)] : [])]; + const measured = (['clearance', 'joints'] as const).filter((what) => !failed.includes(what)).map((what) => `, ${MEASURED[what]}`).join(''); if (report.findings.length === 0) - return [`${head} nothing found ${report.measuring ? `so far (${CHECKED_MESH})` : `in what it checks (${CHECKED_MESH}, ${CHECKED_MEASURED})`}.`, ...preliminary, ...measureLines(report), `${NOT_CHECKED}]`].join('\n'); + return [`${head} nothing found ${report.measuring ? `so far (${CHECKED_MESH})` : `in what it checks (${CHECKED_MESH}${measured})`}.`, ...preliminary, ...measureLines(report), `${NOT_CHECKED}]`].join('\n'); const verdict = report.status === 'block' ? 'not ready to print' : report.status === 'warn' ? 'prints, with warnings' : 'ready to print'; return [`${head} ${verdict}.`, ...findingLines(report), ...preliminary, ...measureLines(report), `Fix what blocks printing before you call the part done; the user sees the same findings. ${NOT_CHECKED}]`].join('\n'); } @@ -147,7 +154,8 @@ export class CheckTold { } note(report: PrintCheckReport): string { - const verdict = JSON.stringify([report.status, report.measuring === true, findingLines(report)]); + // Every finding with its faces and numbers, not the lines shown: a change in the thirteenth finding is a change. + const verdict = JSON.stringify([report.status, report.measuring === true, report.unmeasured ?? [], report.findings, report.omitted ?? 0]); const same = verdict === this.verdict; const changed = (body: Body) => this.bodies.get(body.name) !== measureLine(body); const short = same @@ -156,7 +164,7 @@ export class CheckTold { report.findings.length === 0 ? 'nothing found' : `${report.status === 'block' ? 'still not ready to print, ' : ''}the same ${String(report.findings.length + (report.omitted ?? 0))} ${report.findings.length + (report.omitted ?? 0) === 1 ? 'finding' : 'findings'} as before` - }${report.measuring ? '; clearance and joints still being measured' : ''}.`, + }${report.measuring ? `; clearance and joints still being measured: ${FETCH_FULL}` : ''}${(report.unmeasured ?? []).length > 0 ? `; ${unmeasuredLine(report.unmeasured ?? [])}` : '.'}`, ...measureLines(report, 'Measures that changed:', changed), ].join('\n') + ']' : printCheckNote(report); diff --git a/apps/desktop/src/renderer/src/print/report.ts b/apps/desktop/src/renderer/src/print/report.ts index eae9ec40..1c22d506 100644 --- a/apps/desktop/src/renderer/src/print/report.ts +++ b/apps/desktop/src/renderer/src/print/report.ts @@ -3,12 +3,13 @@ import type { PrintCheck } from '@partbuddy/geometry'; import type { PrintCheckReport } from '../../../shared/contract'; /** The check without the camera boxes, as the main process words it for the assistant; `bodies`: what each body costs to print. */ -export function reportOf(result: PrintCheck, nozzle: number, revision: number, bodies: NonNullable = [], more: Pick = {}): PrintCheckReport { +export function reportOf(result: PrintCheck, nozzle: number, revision: number, bodies: NonNullable = [], more: Pick = {}): PrintCheckReport { return { status: result.status, nozzle, revision, ...(more.measuring ? { measuring: true } : {}), + ...(more.unmeasured && more.unmeasured.length > 0 ? { unmeasured: more.unmeasured } : {}), ...(more.problem ? { problem: more.problem.slice(0, 300) } : {}), ...(bodies.length > 0 ? { bodies: bodies.slice(0, 40) } : {}), ...(bodies.length > 40 ? { bodiesOmitted: bodies.length - 40 } : {}), diff --git a/apps/desktop/src/renderer/src/print/usePrintCheck.ts b/apps/desktop/src/renderer/src/print/usePrintCheck.ts index f82c3b35..a8bc6b23 100644 --- a/apps/desktop/src/renderer/src/print/usePrintCheck.ts +++ b/apps/desktop/src/renderer/src/print/usePrintCheck.ts @@ -40,8 +40,8 @@ export function usePrintCheck(): void { useStudio.getState().setPrintCheck({ revision: model.revision, result: joints.length > 0 ? withMotion(result, { joints, stops: [] }) : result, ...(measuring ? { measuring } : {}) }); // What each closed body costs to print, for the assistant to see what a change saved. const measures = measuresOf(printed, { nozzle: print.printer.nozzle, layer: print.printer.layer, overhang: print.material.overhang }); - const report = (final: typeof result, pending = false) => { - const more = { ...(pending ? { measuring: true } : {}), ...(measures.problem ? { problem: measures.problem } : {}) }; + const report = (final: typeof result, pending = false, unmeasured: ('clearance' | 'joints')[] = []) => { + const more = { ...(pending ? { measuring: true } : {}), ...(unmeasured.length > 0 ? { unmeasured } : {}), ...(measures.problem ? { problem: measures.problem } : {}) }; void platform() .print.reportCheck(reportOf(final, print.printer.nozzle, model.revision, measures.bodies, more)) // A report the main process does not take would leave the assistant waiting for a check that never comes: @@ -57,6 +57,8 @@ export function usePrintCheck(): void { report(result, true); // What SolidCraft measures exactly. The mesh check shows at once; the light takes the measures // when they come, if nothing changed. A measure that fails leaves its findings out. + // A measure that fails leaves its findings out, and the check says that it does. + const failed: ('clearance' | 'joints')[] = []; const measure = async (): Promise => { let final = result; // Several parts (F2): overlaps and gaps under the push fit. @@ -69,6 +71,7 @@ export function usePrintCheck(): void { ) .catch(() => null); if (measured) final = withClearance(final, measured, pushFit); + else failed.push('clearance'); } // Parts that move (ADR 0027): every joint value runs from where it stands to its limits. if (joints.length > 0) { @@ -79,7 +82,10 @@ export function usePrintCheck(): void { ).slice(0, 40); const stopped = await platform() .design.sweepJoints(sweeps.map(({ joint, index, from, to }) => ({ joint, index, from, to }))) - .catch(() => []); + .catch(() => { + failed.push('joints'); + return []; + }); const stops = sweeps.flatMap((sweep, i) => { const stop = stopped[i]; return stop ? [{ joint: sweep.name, unit: sweep.unit, to: sweep.to, ...stop }] : []; @@ -91,7 +97,7 @@ export function usePrintCheck(): void { void measure().then((final) => { if (stale) return; useStudio.getState().setPrintCheck({ revision: model.revision, result: final }); - report(final); + report(final, false, failed); }); }, CHECK_DELAY_MS); let stale = false; diff --git a/apps/desktop/src/shared/contract.ts b/apps/desktop/src/shared/contract.ts index ba645ddf..c98062d5 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -1168,6 +1168,8 @@ export const printCheckReportSchema = z revision: z.number().int().nonnegative(), /** True for the mesh check sent ahead while SolidCraft still measures clearance and joints; the full check follows. */ measuring: z.boolean().optional(), + /** What SolidCraft could not measure for this check (its measure failed): the check then says nothing of it. */ + unmeasured: z.array(z.enum(['clearance', 'joints'])).max(2).optional(), /** * Per printed body, as it lies on the bed: solid volume and the shell estimate in mm³ (two wall lines on every * surface, 15 % infill inside), height in mm and its layers, mm² of surface, on the bed and leaning past the diff --git a/apps/desktop/test/checkOfChange.test.ts b/apps/desktop/test/checkOfChange.test.ts index bdea9b1e..b2445ad6 100644 --- a/apps/desktop/test/checkOfChange.test.ts +++ b/apps/desktop/test/checkOfChange.test.ts @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { GateProxy, type ToolResult, type UpstreamTools } from '../src/main/agents/gateProxy'; import { policyToolSet } from '../src/main/agents/loop'; import { Questions } from '../src/main/agents/questions'; -import { CHANGING_TOOLS, designChanged, WorkLog } from '../src/main/agents/workLog'; +import { CHANGING_TOOLS, UNDO_CAP, designChanged, WorkLog } from '../src/main/agents/workLog'; import { CHECK_PENDING, CHECK_PENDING_VIEW, CheckTold, printCheckNote, withCheckOfChange, type CheckState } from '../src/main/print/printCheckNote'; import type { PrintCheckReport } from '../src/shared/contract'; import { describeToolCall } from '../src/shared/stepPhrases'; @@ -30,8 +30,9 @@ interface Options { * The tool chain as index.ts builds it for every route, on a design with SolidCraft's linear history: `solid.extrude` * leaves a 0.3 mm wall, a trial moves the revision alone, `fail` changes the design and answers as an error. */ -function setup({ checkAfterMs = 100, measureMs = null, waitMs = 1000, fullMs = 500 }: Options = {}) { - const design = { revision: 1, undo: 0, redo: 0 }; +function setup({ checkAfterMs = 100, measureMs = null, waitMs = 1000, fullMs = 500, steps = 0 }: Options & { steps?: number } = {}) { + // SolidCraft reads the depth of its undo history as 200 at most. + const design = { revision: 1, steps, redo: 0, get undo() { return Math.min(UNDO_CAP, this.steps); } }; const state: CheckState = { mirrored: { documentId: 'doc-1', revision: 1 }, report: { status: 'ready', findings: [], nozzle: 0.4, revision: 1 } }; const logged: string[] = []; let historyFails = false; @@ -53,12 +54,12 @@ function setup({ checkAfterMs = 100, measureMs = null, waitMs = 1000, fullMs = 5 listTools: () => Promise.resolve(['execute', 'undo', 'partbuddy_place_drawing', 'partbuddy_move_drawing'].map((name) => ({ name, inputSchema: { type: 'object' as const } }))), callTool: (name, args): Promise => { if (args.command === 'solid.extrude' || args.command === 'fail' || name.endsWith('_drawing')) { - design.undo += 1; + design.steps += 1; design.redo = 0; changed(); } if (name === 'undo') { - design.undo -= 1; + design.steps -= 1; design.redo += 1; changed(); } @@ -69,7 +70,7 @@ function setup({ checkAfterMs = 100, measureMs = null, waitMs = 1000, fullMs = 5 }; const told = new CheckTold(); const log = new WorkLog({ - history: () => (historyFails ? Promise.reject(new Error('no answer')) : Promise.resolve({ documentId: 'doc-1', ...design })), + history: () => (historyFails ? Promise.reject(new Error('no answer')) : Promise.resolve({ documentId: 'doc-1', revision: design.revision, undo: design.undo, redo: design.redo })), travel: () => Promise.resolve(), emit: () => undefined, changed: (result, after) => withCheckOfChange(result, after, () => state, (report) => told.note(report), waitMs, fullMs), @@ -159,6 +160,33 @@ describe('designs of several bodies: the mesh check goes ahead of the measures ( expect(text).not.toContain(CHECK_PENDING); }); + it('never ends on a preliminary check without saying how to get the full one, in full and in short', () => { + const told = new CheckTold(); + const clean: PrintCheckReport = { status: 'ready', findings: [], nozzle: 0.4, revision: 2, measuring: true }; + expect(told.note(clean)).toContain('call partbuddy_view for the full check before you call the part done'); + expect(told.note({ ...clean, revision: 3 })).toBe( + '[PartBuddy: ready-to-print check after this change: unchanged, nothing found; clearance and joints still being measured: call partbuddy_view for the full check before you call the part done.]', + ); + }); + + it('does not send partbuddy_view back to partbuddy_view: from the view a preliminary check says that the rest is coming', () => { + const fromView = printCheckNote({ status: 'ready', findings: [], nozzle: 0.4, revision: 2, measuring: true }, true); + expect(fromView).toContain('Preliminary: clearance and joints still being measured (bodies that overlap or come closer than a push fit, joints that stop before a limit); the full check will be here in a moment.'); + expect(fromView).not.toContain('call partbuddy_view'); + }); + + it('says what SolidCraft could not measure instead of counting it as checked', () => { + const failed = printCheckNote({ status: 'ready', findings: [], nozzle: 0.4, revision: 2, unmeasured: ['clearance'] }); + expect(failed).toContain('nothing found in what it checks (build volume, wall and floor thickness, overhang angle, area on the bed, open bodies, failed features, bodies that touch, joints that stop before a limit).'); + expect(failed).toContain("NOT measured, because SolidCraft's measure failed: bodies that overlap or come closer than a push fit. Measure it yourself (inspect.clearance) before you call the part done."); + const both = printCheckNote({ status: 'warn', findings: [{ kind: 'open', severity: 'warn', bodies: ['A'] }], nozzle: 0.4, revision: 2, unmeasured: ['clearance', 'joints'] }); + expect(both).toContain('Measure it yourself (inspect.clearance, joint.sweep)'); + // A check that lost its measures is another check than the one before. + const told = new CheckTold(); + told.note({ status: 'ready', findings: [], nozzle: 0.4, revision: 2 }); + expect(told.note({ status: 'ready', findings: [], nozzle: 0.4, revision: 3, unmeasured: ['joints'] })).toContain('NOT measured'); + }); + it('does not call a preliminary clean check clean of what is still measured', () => { const note = printCheckNote({ status: 'ready', findings: [], nozzle: 0.4, revision: 1, measuring: true }); expect(note).toContain('nothing found so far (build volume, wall and floor thickness, overhang angle, area on the bed, open bodies, failed features, bodies that touch).'); @@ -181,6 +209,17 @@ describe('only a call that changed the design gets a check (review of PR 357)', expect(Date.now() - started).toBeLessThan(1000); }); + it('still gives the check once the undo history is full: 200 before, 200 after (review 2 of PR 357)', async () => { + // Measured at SolidCraft 0.40.1: a step past the cap moves the revision and leaves undo at 200 and redo at 0. + expect(designChanged({ documentId: 'a', revision: 300, undo: 200, redo: 0 }, { documentId: 'a', revision: 301, undo: 200, redo: 0 })).toBe(true); + // A call that only read moves nothing, at the cap as below it. + expect(designChanged({ documentId: 'a', revision: 300, undo: 200, redo: 0 }, { documentId: 'a', revision: 300, undo: 200, redo: 0 })).toBe(false); + const { tools } = setup({ steps: 260 }); + expect(textOf(await tools.callTool('execute', extrude))).toContain(BLOCKED); + expect(textOf(await tools.callTool('execute', extrude))).toContain('ready-to-print check after this change: unchanged'); + expect(textOf(await tools.callTool('execute', { command: 'inspect.measure' }))).toBe('ok'); + }); + it('gives the check to a call that failed half way and left the design changed', async () => { const text = textOf(await setup().tools.callTool('execute', { command: 'fail' })); expect(text).toContain('failed half way'); @@ -271,6 +310,23 @@ describe('the same check is not told in full with every step (review of PR 357)' expect(told.note(thinner)).toBe(printCheckNote(thinner)); }); + it('reads a change in any finding as a change, also past the twelve it lists and the faces it shows (review 2 of PR 357)', () => { + const wall = (i: number): PrintCheckReport['findings'][number] => ({ kind: 'feature', severity: 'warn', feature: `Shell${String(i)}`, message: 'thin result' }); + const many: PrintCheckReport = { status: 'warn', findings: Array.from({ length: 14 }, (_, i) => wall(i)), nozzle: 0.4, revision: 2 }; + const told = new CheckTold(); + told.note(many); + // The fourteenth finding is another one: the lines shown are the same, the check is not. + const other = { ...many, revision: 3, findings: [...many.findings.slice(0, 13), wall(99)] }; + expect(told.note(other)).toBe(printCheckNote(other)); + // A fifth body in a finding that names four: the same. + const places = (count: number) => Array.from({ length: count }, (_, i) => ({ body: `Pin ${String(i)}`, faces: [] })); + const loose = (count: number): PrintCheckReport => ({ status: 'warn', nozzle: 0.4, revision: 4, findings: [{ kind: 'contact', severity: 'warn', area: 2, bodies: 6, places: places(count) }] }); + told.note(loose(5)); + const moved = { ...loose(5), findings: [{ kind: 'contact' as const, severity: 'warn' as const, area: 2, bodies: 6, places: [...places(4), { body: 'Peg', faces: [] }] }] }; + expect(told.note(moved)).toBe(printCheckNote(moved)); + expect(told.note({ ...moved, revision: 9 })).toContain('unchanged'); + }); + it('keeps it short through the tool chain: the second step of a turn gets one line', async () => { const { tools } = setup(); expect(textOf(await tools.callTool('execute', extrude))).toContain(BLOCKED); From ee59e9f5cd142ac6f97d8d5cc9348fc8eda92127 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:08:19 +0100 Subject: [PATCH 20/24] test(geometry): the cases of the three reviews of the print check, turned and mirrored 178 cases, each in nine turns on the bed and mirrored. The 27 the check still gets wrong run as expected failures (ten overhangs that pass as printable: low stairs, a drip edge with a cheek; seventeen printable shapes that are reported: channels that turn, tunnels with cut or rounded corners, lying nut pockets). The check itself is unchanged. Co-Authored-By: Claude Fable 5.2 --- packages/geometry/src/overhangCases.test.ts | 351 ++++++++++++++++++++ packages/geometry/src/testShapes.ts | 68 ++++ 2 files changed, 419 insertions(+) create mode 100644 packages/geometry/src/overhangCases.test.ts diff --git a/packages/geometry/src/overhangCases.test.ts b/packages/geometry/src/overhangCases.test.ts new file mode 100644 index 00000000..ab21be19 --- /dev/null +++ b/packages/geometry/src/overhangCases.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, it } from 'vitest'; + +import { Overhang, overhangLimits, overhangOf, smallOverhangReach } from './overhang'; +import { circle, gridBody, lyingTube, meshOf, prismBody, revolved, ringSurface, soup, type RawMesh } from './testShapes'; + +/** + * Every case of the three reviews of the print check (pull request 353), each with what it should + * give: the area (mm²) that needs support. Each is also checked turned on the bed and mirrored: the + * answer must not depend on how the part lies or which way round it was drawn. + */ + +type Point = [number, number, number]; + +/** What a case should give: an area (within 3 %), 'free' (under 1 mm², the print check's noise), 'reported' (1 mm² or more), or a range. */ +type Want = number | 'free' | 'reported' | [number, number]; + +const TURNS = [0, 1, 7.5, 15, 30, 45, 60, 75, 90]; + +function area(mesh: RawMesh, degrees = 0, mirrored = false): number { + const cos = Math.cos((degrees * Math.PI) / 180); + const sin = Math.sin((degrees * Math.PI) / 180); + const source = mesh.positions; + const positions = new Float32Array(source.length); + let bed = Infinity; + for (let i = 0; i < source.length; i += 3) { + const x = (mirrored ? -1 : 1) * (source[i] ?? 0); + positions[i] = cos * x - sin * (source[i + 1] ?? 0); + positions[i + 1] = sin * x + cos * (source[i + 1] ?? 0); + positions[i + 2] = source[i + 2] ?? 0; + bed = Math.min(bed, positions[i + 2] ?? 0); + } + // Mirrored, the triangles turn the other way round. + const index = mirrored ? Uint32Array.from(mesh.index, (_, i) => mesh.index[i % 3 === 1 ? i + 1 : i % 3 === 2 ? i - 1 : i] ?? 0) : mesh.index; + return overhangOf({ ...mesh, positions, index, normals: new Float32Array(source.length) }, bed, overhangLimits(45), smallOverhangReach(0.4)).area[Overhang.Support]; +} + +const meets = (found: number, want: Want): boolean => { + if (want === 'free') return found < 1; + if (want === 'reported') return found >= 1; + if (Array.isArray(want)) return found >= want[0] && found <= want[1]; + return Math.abs(found - want) <= Math.max(0.5, want * 0.03); +}; + +const arc = (cx: number, cz: number, r: number, from: number, to: number, n: number): [number, number][] => + Array.from({ length: n + 1 }, (_, i) => { + const a = ((from + ((to - from) * i) / n) * Math.PI) / 180; + return [cx + r * Math.cos(a), cz + r * Math.sin(a)]; + }); + +// ---- the shapes ------------------------------------------------------------------------------------------------- + +// Two walls 2 mm thick, `span` apart, `length` deep and 8 high, under a roof 2 mm thick. +const portal = (span: number, length = 30) => meshOf(gridBody('Portal', [0, 2, 2 + span, 4 + span], [0, length], [0, 8, 10], (i, _j, k) => k === 1 || i !== 1)); +const meshed = meshOf(gridBody('Meshed', [0, 2, 4.5, 7.5, 10, 12], [0, 13.5, 16.5, 30], [0, 8, 10], (i, _j, k) => k === 1 || i === 0 || i === 4)); +const pocket = (span: number) => meshOf(gridBody('Pocket', [0, 2, 2 + span, 4 + span], [0, 2, 32, 34], [0, 8, 10], (i, j, k) => k === 1 || i !== 1 || j !== 1)); +const arm = meshOf(gridBody('Arm', [0, 2, 42], [0, 30], [0, 8, 10], (i, _j, k) => k === 1 || i === 0)); +// The roof of a blind T-slot: a slot 3 mm wide down the middle, joined at the back, arms `wide` out. +const slotted = (wide: number, slot = true) => meshOf(gridBody('Slot', [0, 2, 2 + wide, 5 + wide, 5 + 2 * wide, 7 + 2 * wide], [0, 27, 30, 32], [0, 8, 10], (i, j, k) => (k === 1 ? !(slot && i === 2 && j === 0) : i === 0 || i === 4 || j === 2))); +// The ring a counterbore of `outer` leaves under a through hole of `inner` (both square). +const bore = (outer: number, inner: number, hole = true) => { + const cuts = [0, 2, 2 + (outer - inner) / 2, 2 + (outer + inner) / 2, 2 + outer, 4 + outer]; + return meshOf(gridBody('Bore', cuts, cuts, [0, 3, 10], (i, j, k) => (k === 1 ? !(hole && i === 2 && j === 2) : i === 0 || i === 4 || j === 0 || j === 4))); +}; +// A bridge of 8 mm with a hole 3 × 3 mm through its middle. +const holed = (length: number) => meshOf(gridBody('Holed', [0, 2, 4.5, 7.5, 10, 12], [0, length / 2 - 1.5, length / 2 + 1.5, length], [0, 8, 10], (i, j, k) => (k === 1 ? !(i === 2 && j === 1) : i === 0 || i === 4))); +// A shelf 8 mm out of a wall, with something hanging from its tip. +const shelf = (lip: [number, number][]) => meshOf(prismBody('Shelf', [[0, 0], [5, 0], [5, 16], [13, 16], ...lip, [15, 20], [0, 20]], 30)); +const sealed = meshOf(gridBody('Sealed', [0, 2, 10, 12], [0, 2, 32, 34], [0, 2, 8, 10], (i, j, k) => k !== 1 || i !== 1 || j !== 1)); +const tray = meshOf(gridBody('Tray', [0, 2, 10, 12], [0, 30], [0, 2, 8, 10], (i, _j, k) => k !== 1 || i !== 1)); +// A tunnel under a roof given by its outline between the legs (4 mm thick, the roof starting 4 mm up). +const onLegs = (roof: [number, number][], width: number, top: number) => meshOf(prismBody('Hole', [[0, 0], [4, 0], ...roof, [4 + width, 0], [8 + width, 0], [8 + width, top], [0, top]], 30)); +const arch = (d: number, segments = 48) => onLegs(arc(4 + d / 2, 4, d / 2, 180, 0, segments), d, 10 + d / 2); +const filleted = (span: number, r: number) => meshOf(prismBody('Fillet', [[0, 0], [4, 0], ...arc(4 + r, 8 - r, r, 180, 90, 12), ...arc(4 + span - r, 8 - r, r, 90, 0, 12), [4 + span, 0], [8 + span, 0], [8 + span, 14], [0, 14]], 30)); +// A tunnel whose roof corners are cut `c` mm at 30° from the horizontal. +const cut = (span: number, c: number) => onLegs([[4, 8 - c * Math.tan(Math.PI / 6)], [4 + c, 8], [4 + span - c, 8], [4 + span, 8 - c * Math.tan(Math.PI / 6)]], span, 14); +const chamfered = (span: number, c: number) => onLegs([[4, 8 - c], [4 + c, 8], [4 + span - c, 8], [4 + span, 8 - c]], span, 14); +// A lying hexagon pocket of the width across flats: a corner up (a roof of two flats at 30°), or a flat up. +const hexCornerUp = (flats: number) => onLegs([[4, 4], [4 + flats / 2, 4 + (flats / 2) * Math.tan(Math.PI / 6)], [4 + flats, 4]], flats, 4 + flats); +const hexFlatUp = (flats: number) => { + const a = flats / Math.sqrt(3); + return onLegs([[4, 4], [4 + a / 2, 4 + flats / 2], [4 + 1.5 * a, 4 + flats / 2], [4 + 2 * a, 4]], 2 * a, 6 + flats); +}; +const frame = (wide: number, post = 10) => { + const cuts = [0, 2, 2 + wide, 2 + wide + post, 2 + 2 * wide + post, 4 + 2 * wide + post]; + return meshOf(gridBody('Frame', cuts, cuts, [0, 8, 10], (i, j, k) => k === 1 || !(i >= 1 && i <= 3 && j >= 1 && j <= 3 && !(i === 2 && j === 2)))); +}; +// A wall and an apron 2 mm thick hanging 4 mm from the roof, 8 mm apart, with cheeks at the ends named. +const apron = (length: number, cheeks: ('front' | 'back')[], hangs = true) => { + const ys = [0, ...(cheeks.includes('front') ? [2] : []), length + (cheeks.includes('front') ? 2 : 0), ...(cheeks.includes('back') ? [length + (cheeks.includes('front') ? 4 : 2)] : [])]; + const cheek = (j: number) => (cheeks.includes('front') && j === 0) || (cheeks.includes('back') && j === ys.length - 2); + return meshOf(gridBody('Apron', [0, 2, 10, 12], ys, [0, 4, 8, 10], (i, j, k) => k === 2 || i === 0 || cheek(j) || (hangs && i === 2 && k === 1))); +}; +/** Steps going out and up: `out` mm out, then `up` mm up along a riser that leans `lean` mm further out. */ +const stair = (out: number, up: number, steps: number, lean = 0) => { + const outline: [number, number][] = [[0, 0], [5, 0], [5, 4]]; + let [x, z] = [5, 4]; + for (let s = 0; s < steps; s += 1) { + x += out; + outline.push([x, z]); + x += lean; + z += up; + outline.push([x, z]); + } + outline.push([x, z + 2], [0, z + 2]); + return meshOf(prismBody('Stair', outline, 20)); +}; +// The shelf with a pointed drip edge, and with a cheek 2 mm thick at one end that the lip runs into. +const DRIP: [number, number][] = [[0, 0], [5, 0], [5, 16], [13, 16], [13, 14], [15, 16], [15, 20], [0, 20]]; +const CHEEK: [number, number][] = [[0, 0], [5, 0], [15, 0], [15, 16], [15, 20], [0, 20]]; +const sides = (outline: [number, number][], from: number, to: number): Point[][] => + outline.flatMap(([x, z], i) => { + const [x2, z2] = outline[(i + 1) % outline.length] ?? [0, 0]; + return [[[x, from, z], [x2, to, z2], [x2, from, z2]], [[x, from, z], [x, to, z], [x2, to, z2]]] as Point[][]; + }); +const cap = (triangles: [number, number][][], y: number, flip: boolean): Point[][] => triangles.map(([a, b, c]) => (flip ? [[a?.[0] ?? 0, y, a?.[1] ?? 0], [c?.[0] ?? 0, y, c?.[1] ?? 0], [b?.[0] ?? 0, y, b?.[1] ?? 0]] : [[a?.[0] ?? 0, y, a?.[1] ?? 0], [b?.[0] ?? 0, y, b?.[1] ?? 0], [c?.[0] ?? 0, y, c?.[1] ?? 0]])); +const at = (outline: [number, number][], ...corners: number[]): [number, number][] => corners.map((i): [number, number] => outline[i] ?? [0, 0]); +const DRIP_CAP = [at(DRIP, 0, 1, 2), at(DRIP, 0, 2, 7), at(DRIP, 2, 3, 6), at(DRIP, 2, 6, 7), at(DRIP, 3, 4, 5), at(DRIP, 3, 5, 6)]; +const BETWEEN: [number, number][][] = [[[5, 0], [15, 0], [13, 14]], [[15, 0], [15, 16], [13, 14]], [[5, 0], [13, 14], [13, 16]], [[5, 0], [13, 16], [5, 16]]]; +const CHEEK_CAP = [at(CHEEK, 5, 0, 1), at(CHEEK, 5, 1, 2), at(CHEEK, 5, 2, 3), at(CHEEK, 5, 3, 4)]; +const dripOnly = (length: number) => soup([...cap(DRIP_CAP, 0, false), ...sides(DRIP, 0, length), ...cap(DRIP_CAP, length, true)]); +const dripCheek = (length: number) => soup([...cap(DRIP_CAP, 0, false), ...sides(DRIP, 0, length), ...cap(BETWEEN, length, false), ...sides(CHEEK, length, length + 2), ...cap(CHEEK_CAP, length + 2, true)]); +// A pocket of `n` sides inscribed in the diameter `d`, under a ceiling, in a body 2 mm thick round it. +const pocketN = (d: number, n: number) => { + const outer = circle(d / 2 + 2, 0, n); + const inner = circle(d / 2, 0, n); + const lift = (p: Point | undefined, z: number): Point => [p?.[0] ?? 0, p?.[1] ?? 0, z]; + const triangles: Point[][] = []; + for (let k = 0; k < n; k += 1) { + const j = (k + 1) % n; + triangles.push([lift(outer[k], 0), lift(outer[j], 0), lift(outer[j], 10)], [lift(outer[k], 0), lift(outer[j], 10), lift(outer[k], 10)]); + triangles.push([lift(inner[k], 0), lift(inner[j], 8), lift(inner[j], 0)], [lift(inner[k], 0), lift(inner[k], 8), lift(inner[j], 8)]); + triangles.push([lift(outer[k], 0), lift(inner[j], 0), lift(outer[j], 0)], [lift(outer[k], 0), lift(inner[k], 0), lift(inner[j], 0)]); + triangles.push([[0, 0, 8], lift(inner[j], 8), lift(inner[k], 8)]); + triangles.push([[0, 0, 10], lift(outer[k], 10), lift(outer[j], 10)]); + } + return soup(triangles); +}; +/** An outside thread on a solid core that stands on the bed, as a surface with its bottom. */ +const thread = (root: number, crest: number, lift: number, turns: number, n: number) => { + const helix = (radius: number, add: number, z?: number): Point[] => Array.from({ length: turns * n + 1 }, (_, i) => [radius * Math.cos((i * 2 * Math.PI) / n), radius * Math.sin((i * 2 * Math.PI) / n), z ?? i / n + add]); + return ringSurface([helix(0, 0, -1), helix(root, 0, -1), helix(root, 0), helix(crest, lift), helix(root, 1)], [-1, 0, 0, 0], false); +}; +const engraved = (deep: number, wide: number) => meshOf(gridBody('Engraved', [0, deep, 10], [0, 10, 10 + wide, 20 + wide], [0, 5, 8, 12], (i, j, k) => !(i === 0 && j === 1 && k === 1))); +const snap = (nose: number) => meshOf(prismBody('Snap', [[0, 0], [20, 0], [20, 2], [12, 2], [12, 10], [12 + nose, 10], [12 + nose, 11], [12, 11 + nose], [12, 14], [10, 14], [10, 2], [0, 2]], 8)); +const tube = (hole: number, segments = 96) => meshOf(lyingTube('Tube', 10, hole / 2, 10, segments)); + +// ---- the cases -------------------------------------------------------------------------------------------------- + +const cases: [string, () => RawMesh, Want][] = [ + // Bridges between two walls (audit MG-01, N-mechanik-1; first and second review). + ['bridge 3', () => portal(3), 'free'], + ['bridge 5', () => portal(5), 'free'], + ['bridge 5.97, the narrowest groove of a guide', () => portal(5.97), 'free'], + ['bridge 8', () => portal(8), 'free'], + ['bridge 10', () => portal(10), 'free'], + ['bridge 10.001', () => portal(10.001), 300.03], + ['bridge 13.9', () => portal(13.9), 417], + ['bridge 27.9', () => portal(27.9), 837], + ['bridge 101.9', () => portal(101.9), 3057], + ['bridge 8, roof meshed as a grid', () => meshed, 'free'], + ['bridge 0.3 long and 9.9 wide', () => meshOf(gridBody('Narrow', [0, 2, 11.9, 13.9], [0, 0.3], [0, 8, 10], (i, _j, k) => k === 1 || i !== 1)), 'free'], + ['pocket 8, closed at both ends', () => pocket(8), 'free'], + ['pocket 9.95', () => pocket(9.95), 'free'], + ['arm 40', () => arm, 1200], + // What ends at a free edge or a hole is no bridge (second review). + ['T-slot roof, arms 1.5', () => slotted(1.5), 'free'], + ['T-slot roof, arms 1.8', () => slotted(1.8), 'free'], + ['T-slot roof, arms 1.9', () => slotted(1.9), [95, 125]], + ['T-slot roof, arms 2', () => slotted(2), [100, 130]], + ['T-slot roof, arms 3', () => slotted(3), [155, 190]], + ['T-slot roof, arms 5', () => slotted(5), [265, 330]], + ['T-slot roof without the slot', () => slotted(3, false), 'free'], + ['counterbore 6 with hole 3.4: ledge 1.3', () => bore(6, 3.4), 'free'], + ['counterbore 7 with hole 3.4: ledge 1.8', () => bore(7, 3.4), 'free'], + ['counterbore 7 with hole 4.4: ledge 1.3', () => bore(7, 4.4), 'free'], + ['counterbore 9 with hole 4: ledge 2.5', () => bore(9, 4), 'reported'], + ['counterbore 10 with hole 5.5: ledge 2.25', () => bore(10, 5.5), 'reported'], + ['counterbore 9 without the hole', () => bore(9, 4, false), 'free'], + ['bridge 8 with a hole 3 × 3, 30 long', () => holed(30), [10, 60]], + ['bridge 8 with a hole 3 × 3, 100 long', () => holed(100), [10, 60]], + // Nothing hangs from material that ends in the air (first, second and third review). + ['shelf 10', () => meshOf(prismBody('Shelf', [[0, 0], [5, 0], [5, 16], [15, 16], [15, 20], [0, 20]], 30)), 300], + ['shelf 8 with a drip edge', () => shelf([[13, 14], [15, 16]]), 240], + ['shelf 8 with a hook curled back to the wall', () => shelf([[13, 13], [11, 13], [11, 12.5], [13, 10.5], [15, 12.5]]), 240], + ['shelf 8 with a lip with a step of 0.5 that looks up', () => shelf([[13, 13], [12.5, 13], [12.5, 12.5], [13.75, 11.25], [15, 12.5]]), 240], + ['shelf 8 with a bead whose back looks up', () => shelf([[12, 14], [13.5, 12.5], [15, 14]]), 240], + ['shelf 8 with an apron 6 down, 2 wide', () => shelf([[13, 10], [15, 10]]), 300], + ['shelf 8 onto a second wall down to the bed', () => meshOf(prismBody('Portal', [[0, 0], [5, 0], [5, 16], [13, 16], [13, 0], [15, 0], [15, 20], [0, 20]], 30)), 'free'], + ['shelf 8 with a pointed drip edge, as triangles', () => dripOnly(30), 240], + ['the same with a cheek at one end, 30 long: the lip stands only at the cheek', () => dripCheek(30), [200, 245]], + ['the same, 100 long', () => dripCheek(100), [750, 805]], + ['bridge 8 onto an apron hanging from the roof', () => apron(30, []), 300], + ['the same with a cheek at the back', () => apron(30, ['back']), [270, 305]], + ['the same with a cheek at the front', () => apron(30, ['front']), [270, 305]], + ['a cheek only, no apron: a plate 8 × 30 held on two sides', () => apron(30, ['back'], false), [230, 305]], + ['an apron 60 long between two cheeks', () => apron(60, ['front', 'back']), [540, 605]], + ['ceiling 8 that ends on a step 2 down of a ceiling 22 wide', () => meshOf(prismBody('Step', [[0, 0], [2, 0], [2, 16], [10, 16], [10, 14], [32, 14], [32, 0], [34, 0], [34, 20], [0, 20]], 30)), 900], + ['a rib along under a 9 mm bridge, hanging free', () => meshOf(gridBody('Rib', [0, 2, 5.5, 7.5, 11, 13], [0, 30], [0, 6, 8, 10], (i, _j, k) => k === 2 || i === 0 || i === 4 || (k === 1 && i === 2))), 270], + ['a rib across under a 9 mm bridge', () => meshOf(gridBody('Rib', [0, 2, 11, 13], [0, 14, 16, 30], [0, 6, 8, 10], (i, j, k) => k === 2 || i !== 1 || (k === 1 && j === 1))), 'free'], + ['box on the floor of a tray', () => tray, 'free'], + ['a cavity closed all round, 8 wide', () => sealed, 'free'], + // Steps going out (third review): each step small, the stair as a whole an overhang. + ['stair 1 out per 1 up (45°)', () => stair(1, 1, 20), 'free'], + ['stair 1.7 per 1 (60°)', () => stair(1.7, 1, 20), 'reported'], + ['stair 1.8 per 1 (61°)', () => stair(1.8, 1, 20), 'reported'], + ['stair 2 per 1 (63°)', () => stair(2, 1, 20), 800], + ['stair 1.7 per 0.2 (83°)', () => stair(1.7, 0.2, 20), 'reported'], + ['stair 1.7 per 0.05 (88°), 40 steps', () => stair(1.7, 0.05, 40), 'reported'], + ['stair 1 per 0.2 (79°), 40 steps', () => stair(1, 0.2, 40), 'reported'], + ['stair 1.7 out, then a riser leaning 44°', () => stair(1.7, 1, 15, 0.96), 'reported'], + ['a T beam: stem 4, 6 out each side', () => meshOf(prismBody('T', [[6, 0], [10, 0], [10, 10], [16, 10], [16, 12], [0, 12], [0, 10], [6, 10]], 30)), 360], + ['the same T beam as a grid', () => meshOf(gridBody('T', [0, 6, 10, 16], [0, 30], [0, 10, 12], (i, _j, k) => k === 1 || i === 1)), 360], + // Ceilings held on some sides (third review). + ['L ceiling 30 × 30, legs 8, walls on the two outer sides only', () => meshOf(gridBody('L', [0, 2, 10, 32], [0, 2, 10, 32], [0, 8, 10], (i, j, k) => !(i === 2 && j === 2) && (k === 1 || i === 0 || j === 0))), 416], + ['ceiling 9 × 60 resting on its short ends', () => meshOf(gridBody('Long', [0, 9], [0, 2, 62, 64], [0, 8, 10], (_i, j, k) => k === 1 || j !== 1)), 540], + ['ceiling 9 × 12 resting on its short ends', () => meshOf(gridBody('Long', [0, 9], [0, 2, 14, 16], [0, 8, 10], (_i, j, k) => k === 1 || j !== 1)), 108], + ['corner plate 10 × 30 held on two adjacent sides', () => meshOf(gridBody('Corner', [0, 2, 12], [0, 2, 32], [0, 8, 10], (i, j, k) => k === 1 || i === 0 || j === 0)), 300], + ['corner plate 8 × 8', () => meshOf(gridBody('Corner', [0, 2, 10], [0, 2, 10], [0, 8, 10], (i, j, k) => k === 1 || i === 0 || j === 0)), 64], + ['pocket 8 wide and 30 deep, open at the front', () => meshOf(gridBody('U', [0, 2, 10, 12], [0, 2, 32], [0, 8, 10], (i, j, k) => k === 1 || i !== 1 || j === 0)), 'free'], + ['niche 30 wide and 8 deep with a free front edge', () => meshOf(gridBody('U', [0, 2, 32, 34], [0, 2, 10], [0, 8, 10], (i, j, k) => k === 1 || i !== 1 || j === 0)), 240], + ['niche 30 wide and 12 deep', () => meshOf(gridBody('U', [0, 2, 32, 34], [0, 2, 14], [0, 8, 10], (i, j, k) => k === 1 || i !== 1 || j === 0)), 360], + ['flange 10 out on a tube', () => revolved([[5, 0], [5, 10], [15, 10], [15, 12]], [0, -1, 0]), 628], + ['flange 1.5 out', () => revolved([[5, 0], [5, 10], [6.5, 10], [6.5, 12]], [0, -1, 0]), 'free'], + ['flange 2 out', () => revolved([[5, 0], [5, 10], [7, 10], [7, 12]], [0, -1, 0]), 75.3], + ['the lip of a box 1.5 out', () => revolved([[20, 0], [20, 18], [21.5, 18], [21.5, 20]], [0, -1, 0]), 'free'], + ['the lip of a box 2 out', () => revolved([[20, 0], [20, 18], [22, 18], [22, 20]], [0, -1, 0]), 263.7], + // Channels that change direction, walled all round (third review). + ['L channel 8 wide', () => meshOf(gridBody('L', [0, 2, 10, 32, 34], [0, 2, 10, 32, 34], [0, 8, 10], (i, j, k) => k === 1 || !((i === 1 && (j === 1 || j === 2)) || (j === 1 && (i === 1 || i === 2))))), 'free'], + ['cross of two channels 8 wide', () => meshOf(gridBody('X', [0, 2, 13, 21, 32, 34], [0, 2, 13, 21, 32, 34], [0, 8, 10], (i, j, k) => k === 1 || !((i === 2 && j >= 1 && j <= 3) || (j === 2 && i >= 1 && i <= 3)))), 'free'], + ['T of two channels 8 wide', () => meshOf(gridBody('T', [0, 2, 13, 21, 32, 34], [0, 2, 10, 32, 34], [0, 8, 10], (i, j, k) => k === 1 || !((j === 1 && i >= 1 && i <= 3) || (i === 2 && j === 2)))), 'free'], + ...([[1.5, 10], [3, 10], [3.5, 10], [3.6, 10], [3.7, 10], [5, 10], [8, 10], [3.5, 4], [3, 2]] as const).map(([wide, post]): [string, () => RawMesh, Want] => [`channel ${String(wide)} wide round a post of ${String(post)}`, () => frame(wide, post), 'free']), + ['ring channel 6 wide', () => revolved([[17, 0], [17, 8], [23, 8], [23, 0]], [0, -1, 0]), 'free'], + // The roof of a tunnel: its width between the walls counts, not the shape of its sides (third review). + ...[6, 8, 9, 10].flatMap((span) => [0.5, 1, 2, 3, 4].filter((r) => 2 * r < span).map((r): [string, () => RawMesh, Want] => [`tunnel ${String(span)} with corners rounded R ${String(r)}`, () => filleted(span, r), 'free'])), + ['tunnel 10 with corners cut 2 mm at 30° from the horizontal', () => cut(10, 2), 'free'], + ['tunnel 8 with corners cut 2 mm at 30°', () => cut(8, 2), 'free'], + ['tunnel 14 with corners chamfered 2 mm at 45°: flat 10', () => chamfered(14, 2), 'free'], + ['tunnel 10 with corners chamfered 2 mm', () => chamfered(10, 2), 'free'], + ['lying slot 20 wide and 5 high', () => onLegs([...arc(6.5, 4, 2.5, 180, 90, 12), ...arc(21.5, 4, 2.5, 90, 0, 12)], 20, 12), 'reported'], + ['slot 5 wide and 20 high under a round end', () => onLegs(arc(6.5, 16, 2.5, 180, 0, 24), 5, 24), 'free'], + ['teardrop hole 12, pointed', () => onLegs([...arc(10, 4, 6, 180, 135, 8), [10, 4 + 6 * Math.SQRT2], ...arc(10, 4, 6, 45, 0, 8)], 12, 18), 'free'], + ['teardrop hole 12 with a flat top 3 wide', () => onLegs([...arc(10, 4, 6, 180, 135, 8), [8.5, 4 + 6 * Math.SQRT2 - 1.5], [11.5, 4 + 6 * Math.SQRT2 - 1.5], ...arc(10, 4, 6, 45, 0, 8)], 12, 18), 'free'], + ...([['M3', 5.5], ['M4', 7], ['M5', 8], ['M6', 10]] as const).flatMap(([size, flats]): [string, () => RawMesh, Want][] => [ + [`lying nut pocket ${size}, a corner up`, () => hexCornerUp(flats), 'free'], + [`lying nut pocket ${size}, a flat up`, () => hexFlatUp(flats), 'free'], + ]), + // With the 0.3 mm of room a printed nut gets (screwSizes.ts) the M6 pocket is 10.3 mm wide: over a bridge span. + ['lying nut pocket M6 with its allowance (10.3), a corner up', () => hexCornerUp(10.3), 'reported'], + // Holes lying across the layers (first and second review): round up to 8 mm. + ...[6, 8].map((d): [string, () => RawMesh, Want] => [`arch ${String(d)}`, () => arch(d), 'free']), + ...[8.5, 9, 10, 12].map((d): [string, () => RawMesh, Want] => [`arch ${String(d)}`, () => arch(d), 'reported']), + ...[12, 16, 20, 24, 36].flatMap((segments): [string, () => RawMesh, Want][] => [ + [`arch 8 with ${String(segments)} segments`, () => arch(8, segments), 'free'], + [`arch 9 with ${String(segments)} segments`, () => arch(9, segments), 'reported'], + ]), + // Round and many-sided pockets under a ceiling (third review). + ['round pocket 8', () => pocketN(8, 48), 'free'], + ['round pocket 10', () => pocketN(10, 48), 'free'], + ['round pocket 10.3', () => pocketN(10.3, 48), 'reported'], + ['round pocket 11', () => pocketN(11, 48), 'reported'], + ['round pocket 12', () => pocketN(12, 48), 'reported'], + ['pocket of 7 sides in 12', () => pocketN(12, 7), 'reported'], + ['round pocket 20', () => pocketN(20, 96), 'reported'], + ['pocket of 3 sides in 11 (8.25 high)', () => pocketN(11, 3), 'free'], + ['pocket of 5 sides in 9', () => pocketN(9, 5), 'free'], + ['pocket of 5 sides in 11 (9.95 high)', () => pocketN(11, 5), 'free'], + ['pocket of 6 sides in 14 (12.1 across flats)', () => pocketN(14, 6), 'reported'], + // Everyday details (third review). + ['engraving on a side wall, 3 wide and 1 deep', () => engraved(1, 3), 'free'], + ['engraving 3 wide and 3 deep', () => engraved(3, 3), 'free'], + ['niche in a side wall, 12 wide and 3 deep', () => engraved(3, 12), 36], + ['niche 12 wide and 1.5 deep', () => engraved(1.5, 12), 'free'], + ['bottom chamfer 2 mm at 45°', () => meshOf(prismBody('Chamfer', [[2, 0], [18, 0], [20, 2], [20, 10], [0, 10], [0, 2]], 30)), 'free'], + ['thread M6 standing', () => thread(2.46, 3, 0.31, 12, 48), 'free'], + ['thread with a flank 1.9 deep', () => thread(4, 5.9, 0.2, 6, 48), 'reported'], + ['snap hook, nose 1.5', () => snap(1.5), 'free'], + ['snap hook, nose 2', () => snap(2), 16], + ['snap hook, nose 2.5', () => snap(2.5), 20], +]; + +/** A hole lying in a tube: what the tube reports beyond its outside alone. */ +const holes: [number, number, 'free' | 'reported'][] = [ + ...[16, 20, 24, 36, 48, 96].flatMap((segments): [number, number, 'free' | 'reported'][] => [[8, segments, 'free'], [9, segments, 'reported'], [10, segments, 'reported']]), + [5, 96, 'free'], + [6, 96, 'free'], + [7, 96, 'free'], +]; + +/** + * The cases the check still gets wrong (third review, measured on the check as committed): they + * run as expected failures, so that the one that comes right says so. The first ten are overhangs + * that pass as printable, the rest printable shapes that are reported. + */ +const OPEN = new Set([ + 'the same with a cheek at one end, 30 long: the lip stands only at the cheek', + 'the same, 100 long', + 'the same with a cheek at the back', + 'an apron 60 long between two cheeks', + 'stair 1.7 per 1 (60°)', + 'stair 1.8 per 1 (61°)', + 'stair 1.7 per 0.2 (83°)', + 'stair 1.7 per 0.05 (88°), 40 steps', + 'stair 1 per 0.2 (79°), 40 steps', + 'stair 1.7 out, then a riser leaning 44°', + 'L channel 8 wide', + 'cross of two channels 8 wide', + 'T of two channels 8 wide', + ...[3.5, 3.6, 3.7, 5, 8].map((wide) => `channel ${String(wide)} wide round a post of 10`), + 'channel 3.5 wide round a post of 4', + 'ring channel 6 wide', + 'tunnel 9 with corners rounded R 4', + 'tunnel 10 with corners rounded R 4', + 'tunnel 10 with corners cut 2 mm at 30° from the horizontal', + 'tunnel 8 with corners cut 2 mm at 30°', + 'lying nut pocket M4, a corner up', + 'lying nut pocket M5, a corner up', + 'lying nut pocket M6, a corner up', +]); + +describe('overhangs that hold and overhangs that do not: the cases of the reviews', () => { + it('names only cases there are as open', () => { + expect([...OPEN].filter((name) => !cases.some(([known]) => known === name))).toEqual([]); + }); + + describe.each(cases)('%s', (name, build, want) => { + (OPEN.has(name) ? it.fails : it)('gives what it should, however the part lies', () => { + const mesh = build(); + const found = area(mesh); + expect(meets(found, want), `as drawn: ${found.toFixed(1)} mm², should be ${JSON.stringify(want)}`).toBe(true); + // Turned and mirrored the answer is the same, within 1 %. + const others = [...TURNS.slice(1).map((degrees): [string, number] => [`turned ${String(degrees)}°`, area(mesh, degrees)]), ...[0, 30].map((degrees): [string, number] => [`mirrored, turned ${String(degrees)}°`, area(mesh, degrees, true)])]; + for (const [how, other] of others) expect(Math.abs(other - found), `${how}: ${other.toFixed(1)} mm² for ${found.toFixed(1)} mm² as drawn`).toBeLessThanOrEqual(Math.max(0.5, found * 0.01)); + }); + }); + + it.each(holes)('hole of %s mm lying in a tube, %s segments: %s', (diameter, segments, want) => { + const outside = area(tube(3, segments)); + for (const degrees of TURNS) { + const found = area(tube(diameter, segments), degrees) - outside; + expect(want === 'free' ? Math.abs(found) < 1 : found >= 1, `turned ${String(degrees)}°: ${found.toFixed(1)} mm² more than the outside alone`).toBe(true); + } + }); + + it('takes a mesh without triangles, and one without a reach', () => { + const empty = { positions: new Float32Array(0), normals: new Float32Array(0), index: new Uint32Array(0), vertexFaces: new Uint32Array(0), faceCount: 0 }; + expect(Array.from(overhangOf(empty, 0, overhangLimits(45), 1.8).area)).toEqual([0, 0, 0, 0]); + const bridge = portal(8); + // Without a number for the reach nothing counts as small. + expect(overhangOf({ ...bridge, normals: new Float32Array(bridge.positions.length) }, 0, overhangLimits(45), Number.NaN).area[Overhang.Support]).toBeCloseTo(240, 3); + }); +}); diff --git a/packages/geometry/src/testShapes.ts b/packages/geometry/src/testShapes.ts index 706233da..f273d766 100644 --- a/packages/geometry/src/testShapes.ts +++ b/packages/geometry/src/testShapes.ts @@ -201,3 +201,71 @@ export function gridBody(name: string, xs: readonly number[], ys: readonly numbe } return finish(name, built); } + +/** A mesh as the overhang check takes it, without the normals: for shapes that are no body with faces. */ +export interface RawMesh { + positions: Float32Array; + index: Uint32Array; + vertexFaces: Uint32Array; + faceCount: number; +} + +export function meshOf(body: { model: ViewerModel }): RawMesh { + const geometry = body.model.geometry; + return { positions: geometry.getAttribute('position').array as Float32Array, index: geometry.index?.array as Uint32Array, vertexFaces: body.model.vertexFaces, faceCount: body.model.table.faces.length }; +} + +/** A mesh from triangles given by their corners, outward counter-clockwise; equal corners become one vertex. */ +export function soup(triangles: readonly (readonly Point[])[]): RawMesh { + const seen = new Map(); + const positions: number[] = []; + const index: number[] = []; + for (const triangle of triangles) { + for (const point of triangle) { + const key = point.join(','); + let v = seen.get(key); + if (v === undefined) { + v = positions.length / 3; + seen.set(key, v); + positions.push(...point); + } + index.push(v); + } + } + return { positions: Float32Array.from(positions), index: Uint32Array.from(index), vertexFaces: new Uint32Array(positions.length / 3), faceCount: 1 }; +} + +/** A circle of `n` points round the Z axis. */ +export const circle = (radius: number, z: number, n: number): Point[] => Array.from({ length: n }, (_, i) => [radius * Math.cos((i * 2 * Math.PI) / n), radius * Math.sin((i * 2 * Math.PI) / n), z]); + +/** + * A surface through rings of points, each joined to the next; `wrap` closes every ring. Triangles + * face away from the Z axis, flat rings up or down as `facing` says (1, −1) for the step after them. + */ +export function ringSurface(rings: readonly (readonly Point[])[], facing: readonly number[], wrap = true): RawMesh { + const positions = rings.flat(2); + const per = rings[0]?.length ?? 0; + const index: number[] = []; + const point = (v: number) => [positions[v * 3] ?? 0, positions[v * 3 + 1] ?? 0, positions[v * 3 + 2] ?? 0] as const; + for (let r = 0; r + 1 < rings.length; r += 1) { + for (let i = 0; i < (wrap ? per : per - 1); i += 1) { + const j = (i + 1) % per; + const quad = [r * per + i, r * per + j, (r + 1) * per + j, (r + 1) * per + i] as const; + for (const [a, b, c] of [[quad[0], quad[1], quad[2]], [quad[0], quad[2], quad[3]]] as const) { + const [pa, pb, pc] = [point(a), point(b), point(c)]; + const u = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]] as const; + const w = [pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2]] as const; + const normal = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]] as const; + const radial = normal[0] * (pa[0] + pb[0] + pc[0]) + normal[1] * (pa[1] + pb[1] + pc[1]); + const flip = Math.abs(radial) > 1e-9 ? radial < 0 : normal[2] * (facing[r] ?? 0) < 0; + index.push(a, flip ? c : b, flip ? b : c); + } + } + } + return { positions: Float32Array.from(positions), index: Uint32Array.from(index), vertexFaces: new Uint32Array(positions.length / 3), faceCount: 1 }; +} + +/** A body of revolution round Z from a profile of [radius, z]; a radius of 0 closes it on the axis. */ +export function revolved(profile: readonly (readonly [number, number])[], facing: readonly number[], n = 96): RawMesh { + return ringSurface(profile.map(([radius, z]) => circle(radius, z, n)), facing); +} From 1546201c1644b407e9fc0257ec29c693ee77e5eb Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:08:23 +0100 Subject: [PATCH 21/24] test(geometry): the run time of the overhang check is held by its growth, not in seconds The thread took 3.9 s against a limit of 3 s on a busy runner. Each mesh is now timed against one a quarter of its size, the fastest of three runs each: one pass takes 4 times as long (measured 4.2 to 7.3 under load), searching anew from every rim 16 times; the limit is 12. Co-Authored-By: Claude Fable 5.2 --- packages/geometry/src/overhangSpeed.test.ts | 43 +++++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/geometry/src/overhangSpeed.test.ts b/packages/geometry/src/overhangSpeed.test.ts index f1ac3a00..fa939c82 100644 --- a/packages/geometry/src/overhangSpeed.test.ts +++ b/packages/geometry/src/overhangSpeed.test.ts @@ -53,36 +53,55 @@ function stem(rings: number, n: number, from: number): OverhangInput { return surface([...all, circle(6.5, from + rings * 0.2, n)], [...Array.from({ length: rings }, () => 0), -1], true); } +/** The check on a mesh, with the fastest of three runs: one run alone says more about the runner than about the check. */ function timed(mesh: OverhangInput, bed: number) { - const start = performance.now(); - const map = overhangOf(mesh, bed, overhangLimits(45), smallOverhangReach(0.4)); - return { ms: performance.now() - start, triangles: mesh.index.length / 3, support: Math.round(map.area[Overhang.Support]), small: Math.round(map.area[Overhang.Borderline]) }; + let ms = Infinity; + let map = overhangOf(mesh, bed, overhangLimits(45), smallOverhangReach(0.4)); + for (let run = 0; run < 3; run += 1) { + const start = performance.now(); + map = overhangOf(mesh, bed, overhangLimits(45), smallOverhangReach(0.4)); + ms = Math.min(ms, performance.now() - start); + } + return { ms, triangles: mesh.index.length / 3, support: Math.round(map.area[Overhang.Support]), small: Math.round(map.area[Overhang.Borderline]) }; } +/** + * How many times longer a mesh four times the size may take. One pass takes 4 times as long, + * searching anew from every rim 16 times; a limit in seconds failed on a busy runner instead + * (3.9 s against 3 s for the thread). + */ +const GROWTH = 12; +/** Time for a test to finish at all on a busy runner; no measure of the check. */ +const PATIENCE = 120_000; + describe('what stands and what hangs on large meshes, in one pass', () => { // Searching anew from every rim took 14 s on 24 000 triangles of the funnel and 56 s on 48 000. - // The limits are wide for a busy CI runner; measured locally all stay under 1 s. - it('finds every step of a stepped funnel small: each ledge stands on the riser below', () => { - expect(timed(funnel(60, 100), 0)).toMatchObject({ triangles: 24_000, support: 0, small: 13_186 }); + it('finds every step of a stepped funnel small: each ledge stands on the riser below', { timeout: PATIENCE }, () => { + const small = timed(funnel(60, 100), 0); + expect(small).toMatchObject({ triangles: 24_000, support: 0, small: 13_186 }); const large = timed(funnel(250, 100), 0); expect(large).toMatchObject({ triangles: 100_000, support: 0 }); - expect(large.ms).toBeLessThan(2000); + expect(large.ms).toBeLessThan(small.ms * GROWTH); }); - it('finds the flanks of a long thread small, turn over turn', () => { + it('finds the flanks of a long thread small, turn over turn', { timeout: PATIENCE }, () => { + const short = timed(thread(65, 96), -1); + expect(short).toMatchObject({ triangles: 37_440, support: 0 }); const bolt = timed(thread(260, 96), -1); expect(bolt.triangles).toBe(149_760); expect(bolt.support).toBe(0); expect(bolt.small).toBeGreaterThan(4700); - expect(bolt.ms).toBeLessThan(3000); + expect(bolt.ms).toBeLessThan(short.ms * GROWTH); }); - it('holds a ledge on a finely meshed stem that stands on the bed, and reports it on one that floats', () => { + it('holds a ledge on a finely meshed stem that stands on the bed, and reports it on one that floats', { timeout: PATIENCE }, () => { + const short = timed(stem(62, 200, 0), 0); + expect(short).toMatchObject({ triangles: 25_200, support: 0, small: 54 }); const standing = timed(stem(250, 200, 0), 0); expect(standing).toMatchObject({ triangles: 100_400, support: 0, small: 54 }); - expect(standing.ms).toBeLessThan(2000); + expect(standing.ms).toBeLessThan(short.ms * GROWTH); const floating = timed(stem(250, 200, 1), 0); expect(floating).toMatchObject({ support: 54, small: 0 }); - expect(floating.ms).toBeLessThan(2000); + expect(floating.ms).toBeLessThan(timed(stem(62, 200, 1), 0).ms * GROWTH); }); }); From 560cd453dedc454f5ad4438cba87970b840d007e Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:34:59 +0100 Subject: [PATCH 22/24] test(desktop): the text pick test sets the top view again before each click Two contract runs on unrelated branches failed here with every click missing the text although the clicks arrived within milliseconds. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/e2e/solidcraft.contract.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop/e2e/solidcraft.contract.spec.ts b/apps/desktop/e2e/solidcraft.contract.spec.ts index f694bb22..58dd2ff6 100644 --- a/apps/desktop/e2e/solidcraft.contract.spec.ts +++ b/apps/desktop/e2e/solidcraft.contract.spec.ts @@ -2976,6 +2976,9 @@ test('a built text is picked as a whole and changed afterwards: words, height an await expect(async () => { const [x, y] = spots[spot++ % spots.length] ?? [0.5, 0.5]; await page.keyboard.press('Escape'); + // Again each time: the first key 7 is lost when the view was not ready for it, and every click then misses. + await page.keyboard.press('7'); + await page.waitForTimeout(700); await page.mouse.click(box.x + box.width * x, box.y + box.height * y); await expect(page.getByTestId('logo-text')).toBeVisible({ timeout: 3000 }); }, 'the built text under a click').toPass({ timeout: 60_000, intervals: [500] }); @@ -3001,6 +3004,9 @@ test('a built text is picked as a whole and changed afterwards: words, height an await expect(async () => { const [x, y] = spots[spot++ % spots.length] ?? [0.5, 0.5]; await page.keyboard.press('Escape'); + // Again each time: the first key 7 is lost when the view was not ready for it, and every click then misses. + await page.keyboard.press('7'); + await page.waitForTimeout(700); await page.mouse.click(box.x + box.width * x, box.y + box.height * y); await expect(page.getByTestId('logo-words').getByRole('textbox')).toHaveValue('AB', { timeout: 3000 }); }, 'the changed text under a click').toPass({ timeout: 60_000, intervals: [500] }); From 8df84a893b5e3ecacf8208773da71e6463e7a100 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:27:21 +0100 Subject: [PATCH 23/24] test(desktop): the joint tests read the revision once no trial is rebuilding Co-Authored-By: Claude Fable 5.2 --- apps/desktop/e2e/solidcraft.contract.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/e2e/solidcraft.contract.spec.ts b/apps/desktop/e2e/solidcraft.contract.spec.ts index 58dd2ff6..819d98b6 100644 --- a/apps/desktop/e2e/solidcraft.contract.spec.ts +++ b/apps/desktop/e2e/solidcraft.contract.spec.ts @@ -3125,6 +3125,8 @@ test('a hinge the assistant joined moves with its slider and goes back to as des return count; }); expect(await turned()).toBe(0); + // Not while a trial still shows "rebuilding": the revision that stands is what the test compares with. + await expect(page.getByTestId('connection-status')).toHaveText(/Revision \d+/, { timeout: 30_000 }); const revision = await page.getByTestId('connection-status').textContent(); const track = await page.getByTestId('joint-value').locator('.relative').first().boundingBox(); if (!track) throw new Error('the slider has no track'); @@ -3195,6 +3197,8 @@ test('a hinge plays in the view, stops where SolidCraft found the parts touching await page.keyboard.press('Escape'); await expect(findings).toBeHidden({ timeout: 1000 }); }).toPass({ timeout: 15_000 }); + // Not while a trial still shows "rebuilding": the revision that stands is what the test compares with. + await expect(page.getByTestId('connection-status')).toHaveText(/Revision \d+/, { timeout: 30_000 }); const revision = await page.getByTestId('connection-status').textContent(); // One click plays it: down to its limit first (the longer way), then up until the parts touch. await page.getByTestId('joints-play').click(); From e0f821e1fb347fdd7e12e369d7bc3e312ecff780 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:31:08 +0100 Subject: [PATCH 24/24] test(desktop): contract tests wait for the turn's end and read the whole answer Since the tool's answer waits for the print check of its change, the part shows before the assistant's turn ends; a user action sent right then was refused ("the assistant is working on the design"). The first-build sends now wait for the Stop button to go. The wall plate and the clipped cover read the answer as a whole: the check behind each block ends in a list of measures, which splits the answer into several elements. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/e2e/solidcraft.contract.spec.ts | 46 +++++++++++++++++--- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/apps/desktop/e2e/solidcraft.contract.spec.ts b/apps/desktop/e2e/solidcraft.contract.spec.ts index 1ea518a2..ac11b3f2 100644 --- a/apps/desktop/e2e/solidcraft.contract.spec.ts +++ b/apps/desktop/e2e/solidcraft.contract.spec.ts @@ -1,10 +1,18 @@ import AxeBuilder from '@axe-core/playwright'; -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; import { execFileSync } from 'node:child_process'; import { launchApp } from './launch'; +/** + * The assistant's turn is over. The user's own actions are refused while it works, and since PR 357 its tool's + * answer waits for the print check of what it built: the part shows before the turn ends. + */ +async function turnEnded(page: Page): Promise { + await expect(page.getByTestId('composer-stop')).toBeHidden({ timeout: 30_000 }); +} + /** A renderer element in evaluate callbacks, typed loosely because this file is compiled for Node. */ interface LooseRect { left: number; @@ -91,6 +99,7 @@ test('an assistant step changes the headless design, and it is back after a rest await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await expect(page.getByTestId('assistant-text').last()).toHaveText('The design has one body.'); await page.screenshot({ path: test.info().outputPath('headless-box.png') }); // The conversation is written next to the design a second after the chat went quiet. @@ -156,6 +165,7 @@ test('the user undoes, exports and opens the headless design in the SolidCraft w await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); // Undo and redo in the real engine. await page.getByRole('button', { name: 'Undo' }).click(); @@ -222,6 +232,7 @@ test('the dimension field changes the driving parameter directly, with a preview await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); // The first save names the new part after its project (B2) and mirrors it once more; picks wait for that. await expect(page.getByTestId('design-name')).toHaveText('Part', { timeout: 15_000 }); await page.waitForTimeout(800); @@ -352,6 +363,7 @@ test('two edges are selected with Ctrl, measured, and rounded together with a pr await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await page.waitForTimeout(800); const box = await page.getByTestId('viewport').boundingBox(); if (!box) throw new Error('viewport not visible'); @@ -759,6 +771,7 @@ test('a note on a face stays with the design, out of the assistant\'s reach, and await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await page.waitForTimeout(800); const box = await page.getByTestId('viewport').boundingBox(); @@ -849,6 +862,7 @@ test('the dimensions of the box show in the view, and its height changes through await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await page.getByTestId('dimensions').click(); const labels = page.getByTestId('mark-label'); @@ -1006,6 +1020,7 @@ test('a part becomes a project folder that is renamed, duplicated, opened, start await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await expect(name).toHaveText('Part', { timeout: 15_000 }); await expect.poll(() => existsSync(join(projects, 'Part', 'Part.sc')), { timeout: 15_000 }).toBe(true); // The conversation is kept in the project, next to the design. @@ -1106,6 +1121,7 @@ test('PartBuddy starts on the start page with no project open; a saved part gets await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await expect(name).toHaveText('Part', { timeout: 15_000 }); // After the save the project gets its picture and the light it was taken with. const preview = join(projects, 'Part', 'preview.png'); @@ -1215,6 +1231,7 @@ test('a STEP file becomes a project, meshes are inserted into it, converted to f await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await pick(join(outside, 'Platine.step')); await page.evaluate(() => (globalThis as unknown as Bridge).partbuddy.design.export('step')); expect(existsSync(join(outside, 'Platine.step'))).toBe(true); @@ -1479,6 +1496,7 @@ test('a standard screw is seated in a hole as a counterpart that is neither expo await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); // A 3.4 mm hole through the 20 × 20 × 10 mm box, then an M3 × 12 screw in it. // SolidCraft 0.40 (rc.2 and rc.3, 2026-11-08) keeps the bore one cylinder face. await page.evaluate(() => (globalThis as unknown as Bridge).partbuddy.design.applyAction({ action: 'hole', body: 'Body1', points: [[10, 10, 10]], size: 3.4 })); @@ -1529,6 +1547,7 @@ test('the export menu offers the slicers SolidCraft finds, and a print face is s await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); const slicers = await page.evaluate(() => (globalThis as unknown as Bridge).partbuddy.design.slicers()); // The menu lists exactly what SolidCraft found; nothing is sent here, so no slicer window opens. await page.getByTestId('export').click(); @@ -1571,6 +1590,7 @@ test('function blocks are previewed and built at a face: tapped hole, screw hole await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); const top = (x: number, y: number) => ({ body: 'Body1', point: [x, y, 10] }); const blocks = [ { block: 'tappedHole', ...top(5, 5), size: 'M4', depth: 6, modeled: true, more: [[5, 10, 10]] }, @@ -1627,6 +1647,7 @@ test('sketched blocks and a lip are previewed and built: text, nut trap, slot, l await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); // An open case with 3 mm walls: the 20 × 20 × 10 mm box without its top face. await page.evaluate(() => (globalThis as unknown as Bridge).partbuddy.design.applyAction({ action: 'shell', body: 'Body1', points: [[10, 10, 10]], size: 3 })); await expect(page.getByTestId('timeline-feature')).toHaveCount(2, { timeout: 30_000 }); @@ -1698,6 +1719,7 @@ test('a snap fit stands out of a face, its catch across the arm, with its strain await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); const snap = { block: 'snapFit', body: 'Body1', size: 'M3', length: 10, thickness: 1.5, width: 5, catchDepth: 0.8, catchLength: 1.5 }; // Up from the top face, and level out of a side face with the catch pointing up. for (const block of [ @@ -1771,6 +1793,7 @@ test('a logo from an SVG file is engraved and built up at a face, a ring keeping await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); const logo = { block: 'logo', body: 'Body1', size: 'M3', name: 'ring', svg, width: 12 }; for (const block of [ { ...logo, point: [10, 10, 10], normal: [0, 0, 1], depth: 0.6, mode: 'deboss' }, @@ -1935,6 +1958,7 @@ test('a logo in colours becomes a coloured body per fill, inlaid flush in the fa await expect(page.getByTestId('connection-status')).toHaveAttribute('data-engine', 'headless', { timeout: 30_000 }); await say('Build a box'); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); // 16 mm wide on the top face of the 20 mm box, centred at (10, 10): the plate 16 × 8, the dot 4 across. const block = { block: 'logo', body: 'Body1', size: 'M3', name: 'Badge', svg, width: 16, point: [10, 10, 10], normal: [0, 0, 1], depth: 0.6, mode: 'deboss', colours: true }; expect(await page.evaluate((b) => (globalThis as unknown as Bridge).partbuddy.design.previewBlock(b).then((r) => ({ ok: r.ok, error: r.error ?? null })), block)).toEqual({ ok: true, error: null }); @@ -2065,6 +2089,7 @@ test('the light measures parts against each other: an overlap and a gap under th await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await expect(page.getByTestId('print-check')).toHaveAttribute('data-status', 'ready', { timeout: 30_000 }); // A case, a lid sunk 1 mm into it and a clip 0.1 mm beside it (PLA's push fit is 0.15 mm). @@ -2723,6 +2748,7 @@ test('an SVG sent along is placed as a logo by partbuddy_place_drawing: engraved await expect(page.getByTestId('connection-status')).toHaveAttribute('data-engine', 'headless', { timeout: 30_000 }); await say('Build a box'); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); const data = [...readFileSync(join(__dirname, '..', 'test', 'fixtures', 'svg', 'logo-o.svg'))]; await composer.evaluate((field, bytes) => { const view = globalThis as unknown as PictureEvents; @@ -2960,6 +2986,7 @@ test('a built text is picked as a whole and changed afterwards: words, height an await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); // Raised 0.6 mm on the top face's middle, the sketch named after its words. const text = { block: 'text', body: 'Body1', point: [10, 10, 10], normal: [0, 0, 1], size: 'M3', text: 'PB', height: 6, depth: 0.6, mode: 'emboss', bold: false }; await page.evaluate((b) => (globalThis as unknown as Bridge).partbuddy.design.applyBlock(b), text); @@ -3045,6 +3072,7 @@ test('a hole is placed on its face before it is drilled: across and up from the await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a box'); await page.getByRole('button', { name: 'Send' }).click(); await expect(page.getByTestId('mesh-summary')).toContainText('1 body · 6 faces', { timeout: 30_000 }); + await turnEnded(page); await page.getByTestId('viewport').hover(); await page.keyboard.press('7'); await page.waitForTimeout(1000); @@ -3400,11 +3428,11 @@ test('the assistant hangs a part on the wall with its blocks: keyhole slots on t await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a wall plate'); await page.getByRole('button', { name: 'Send' }).click(); // Each block says that the plastic it needs was measured at the real engine (audit R54): the 8 mm plate has it. - await expect( - page.getByText( - /Built 2 keyhole slots for 4 mm wood screws in Plate: entry 9 mm for the head, a slot 4\.5 mm wide running 8 mm up .* PartBuddy measured that plastic before building, from 0\.5 mm under the face on: it is there\..* Built 2 countersunk holes for 4 mm wood screws in Plate: 4\.7 mm through, countersink 8\.8 mm at 90°.* PartBuddy measured that plastic, from 0\.5 mm under the face on: it is there\./, - ), - ).toBeVisible({ timeout: 60_000 }); + // In the whole answer, not one element of it: the print check behind each block ends in a list of measures. + await expect(page.getByTestId('assistant-text').last()).toContainText( + /Built 2 keyhole slots for 4 mm wood screws in Plate: entry 9 mm for the head, a slot 4\.5 mm wide running 8 mm up [\s\S]* PartBuddy measured that plastic before building, from 0\.5 mm under the face on: it is there\.[\s\S]* Built 2 countersunk holes for 4 mm wood screws in Plate: 4\.7 mm through, countersink 8\.8 mm at 90°[\s\S]* PartBuddy measured that plastic, from 0\.5 mm under the face on: it is there\./, + { timeout: 60_000 }, + ); await expect(page.getByTestId('mesh-summary')).toContainText('1 body', { timeout: 30_000 }); // Each block is one line of the work log, and no feature failed. await expect(page.getByText('Keyhole slot for a 4 mm screw, to hang on a wall')).toHaveCount(1); @@ -3547,7 +3575,11 @@ test('the assistant clips a cover onto its base with snap hooks: each hook catch 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 clipped cover'); await page.getByRole('button', { name: 'Send' }).click(); - await expect(page.getByText(/Built 1 snap hook "Back clip" on Base, each with its window through the 2 mm wall of Cover: an arm 12 mm long, 1\.5 mm thick and 5 mm wide, 0\.2 mm off the wall.* Built 1 snap hook "Front clip" on Base/)).toBeVisible({ timeout: 90_000 }); + // In the whole answer, not one element of it: the print check behind each hook ends in a list of measures. + await expect(page.getByTestId('assistant-text').last()).toContainText( + /Built 1 snap hook "Back clip" on Base, each with its window through the 2 mm wall of Cover: an arm 12 mm long, 1\.5 mm thick and 5 mm wide, 0\.2 mm off the wall[\s\S]* Built 1 snap hook "Front clip" on Base/, + { 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('Snap hooks “Back clip” with their windows')).toHaveCount(1);