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 6034e2633af7f614a176ae3d67413a5baef6ce34 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:23:01 +0100 Subject: [PATCH 09/24] fix(desktop): a mechanism that fails after its first step is taken back, not left under "not built" A joint SolidCraft refused, or a guide whose travel could not be measured, left the knuckles and cuts in the design while the answer said "Not built"; the next call built them again (audit 2026-11-12, H-02: 2 -> 11 -> 20 features). Every step since the undo depth read before the build is now undone in one batch, and the answer names SolidCraft's failed command and its reason instead of its raw JSON. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/e2e/solidcraft.contract.spec.ts | 2 +- apps/desktop/src/main/agents/mechanismTool.ts | 104 +++++++++++++----- apps/desktop/test/mechanism.test.ts | 47 +++++++- 3 files changed, 117 insertions(+), 36 deletions(-) diff --git a/apps/desktop/e2e/solidcraft.contract.spec.ts b/apps/desktop/e2e/solidcraft.contract.spec.ts index d702eb3c..31ea93e8 100644 --- a/apps/desktop/e2e/solidcraft.contract.spec.ts +++ b/apps/desktop/e2e/solidcraft.contract.spec.ts @@ -3733,7 +3733,7 @@ test('a hinge whose line is out of reach of the bodies is taken back, with where await expect(page.getByTestId('connection-status')).toHaveAttribute('data-engine', 'headless', { timeout: 30_000 }); await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a flap out of reach'); await page.getByRole('button', { name: 'Send' }).click(); - await expect(page.getByText(/Not built: 3 piece\(s\) did not touch their body and stand apart from it\. Put the hinge line within 2\.5 mm of both bodies .* Everything was taken back; call again with the line moved\./)).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText(/Not built: 3 piece\(s\) did not touch their body and stand apart from it\. Put the hinge line within 2\.5 mm of both bodies .* Everything was taken back./)).toBeVisible({ timeout: 60_000 }); // The two plates are as they were: no knuckle, no cut, no joint. await expect(page.getByTestId('mesh-summary')).toContainText('2 bodies · 12 faces', { timeout: 30_000 }); await expect(page.getByTestId('joints')).toHaveCount(0); diff --git a/apps/desktop/src/main/agents/mechanismTool.ts b/apps/desktop/src/main/agents/mechanismTool.ts index abd089d8..eec47959 100644 --- a/apps/desktop/src/main/agents/mechanismTool.ts +++ b/apps/desktop/src/main/agents/mechanismTool.ts @@ -277,6 +277,16 @@ function reach(sweep: unknown, to: number, step: number): number { } type Run = (commands: Step[]) => Promise; +/** + * One call of the tool: how it reaches SolidCraft, and SolidCraft's undo depth before the first step was built + * (null when it could not be read, unset while nothing is begun), so a call that fails later is taken back. + */ +interface Site { + upstream: UpstreamTools; + run: Run; + callId: string | undefined; + mark: { undo?: number | null }; +} /** Keeps the face a mechanism laid its part on, by the face's name, for the plate view. */ type KeepFace = (body: string, face: string) => void; const round2 = (value: number) => Math.round(value * 100) / 100; @@ -307,11 +317,11 @@ export function activated(steps: Step[], listing: unknown): Step[] { /** * Builds the solids. A joined piece that does not touch its body stays in it as a separate piece, * and SolidCraft says so in the step's warning (0.40.0-rc.3: "… is now in 2 separate pieces"); it - * means the line is not where the bodies are: everything is taken back (practice run 2026-11-10: the model kept a gap between the + * means the line is not where the bodies are: the call fails and with that everything is taken back (practice run 2026-11-10: the model kept a gap between the * hinge line and the walls, mended the loose knuckles with webs, and their warnings stayed in the * timeline), and `where` tells the model where the line goes. Volume the two still share is a warning. */ -async function build(upstream: UpstreamTools, run: Run, spec: { fixed: string; moving: string }, steps: Step[] | (() => Step[]), where: string, callId: string | undefined, makes = false, ready: () => Promise = () => Promise.resolve()): Promise { +async function build({ upstream, run, callId, mark }: Site, spec: { fixed: string; moving: string }, steps: Step[] | (() => Step[]), where: string, makes = false, ready: () => Promise = () => Promise.resolve()): Promise { const before = jsonOf(await upstream.callTool('inspect_design', {}, callId)) as { bodies?: { name?: unknown }[]; undo?: unknown }; const bodies = (before.bodies ?? []).map((body) => body.name); // A mechanism that makes its moving body (a lid) needs the name free. @@ -329,23 +339,16 @@ async function build(upstream: UpstreamTools, run: Run, spec: { fixed: string; m if (away.length > 0) throw new Error(`the joint(s) ${away.map((name) => JSON.stringify(name)).join(', ')} stand away from 0; drive them back with joint.drive first, so the bodies are where they were designed`); // The bodies are there and stand as designed: what must be measured on them before anything is built. await ready(); + // From here on the design changes: a call that fails later is taken back to this depth. + mark.undo = typeof before.undo === 'number' ? before.undo : null; const built = await run(activated(typeof steps === 'function' ? steps() : steps, listing)); const apart = built.filter((result) => { const warning = (result as { warning?: unknown } | undefined)?.warning; return typeof warning === 'string' && warning.includes('separate pieces'); }).length; - const [after, clash] = await run([ - { command: 'document.inspect', params: {} }, - { command: 'inspect.interference', params: { bodies: [spec.fixed, spec.moving] } }, - ]); + if (apart > 0) throw new Error(`${String(apart)} piece(s) did not touch their body and stand apart from it. ${where}`); + const [clash] = await run([{ command: 'inspect.interference', params: { bodies: [spec.fixed, spec.moving] } }]); const overlaps = ((clash as { count?: unknown } | undefined)?.count as number | undefined) ?? 0; - if (apart > 0) { - const loose = `${String(apart)} piece(s) did not touch their body and stand apart from it. ${where}`; - const undone = (after as { undo?: unknown } | undefined)?.undo; - if (typeof before.undo !== 'number' || typeof undone !== 'number' || undone <= before.undo) throw new Error(`${loose} Undo what was built, then call again.`); - for (let step = before.undo; step < undone; step += 1) await upstream.callTool('undo', {}, callId); - throw new Error(`${loose} Everything was taken back; call again with the line moved.`); - } return overlaps > 0 ? ' Warning: the two bodies still share volume away from the mechanism; make room between them.' : ''; } @@ -356,7 +359,7 @@ async function join(run: Run, spec: { fixed: string; moving: string; from: Vec3; if (loners.length > 0) [, listing] = await run([{ command: 'component.from_bodies', params: { bodies: loners } }, { command: 'component.list', params: {} }]); const a = occurrenceOf(listing, spec.fixed); const b = occurrenceOf(listing, spec.moving); - if (!a || !b) throw new Error('the solids are built, but the bodies did not become components; join them with joint.as_built'); + if (!a || !b) throw new Error(`${spec.fixed} and ${spec.moving} did not become components, so no joint could join them.`); const middle = spec.from.map((value, i) => (value + (spec.to[i] ?? value)) / 2); const axis = spec.to.map((value, i) => value - (spec.from[i] ?? 0)); const sweeps = range.filter((to) => to !== 0).map((to) => ({ command: 'joint.sweep', params: { joint: spec.name, from: 0, to } })); @@ -456,12 +459,57 @@ const MOVE = 'The user moves it with the slider "Moving parts" in the view.'; const unswept = (reason: string) => `SolidCraft could not run it through its range (${reason.slice(0, 160)}), so no limits are set. The two bodies probably lie against each other somewhere: look with inspect.clearance, make room, then set joint.limits yourself after joint.sweep.`; +/** + * Takes back what a call built before it failed: every step SolidCraft counted since `before`, in one batch. (A batch + * with rollback around the build itself, or a checkpoint from an earlier batch, does not do it: measured on 0.40.1.) + * False when the design could not be put back. + */ +async function takeBack(run: Run, before: number | null): Promise { + if (before === null) return false; + try { + const [now] = await run([{ command: 'document.inspect', params: {} }]); + const depth = (now as { undo?: unknown } | undefined)?.undo; + if (typeof depth !== 'number') return false; + if (depth > before) await run(Array.from({ length: depth - before }, () => ({ command: 'edit.undo', params: {} }))); + return true; + } catch { + return false; + } +} + +/** + * A call ends in one of two states: built, with what is missing said in the answer, or not built and the design as it + * was. A failure after the first step (the joint refused, the travel not measurable) left knuckles and cuts behind under + * "Not built", and the next call built them twice (audit 2026-11-12, H-02). + */ async function buildMechanism(upstream: UpstreamTools, args: Record, nozzle: number, callId: string | undefined, keepFace: KeepFace): Promise { const run: Run = async (commands) => { - const reply = jsonOf(await upstream.callTool('batch', { commands }, callId)) as { results?: unknown[]; failed?: { command?: unknown; error?: unknown } }; - if (reply.failed) throw new Error(`SolidCraft refused ${String(reply.failed.command)}: ${String(reply.failed.error).slice(0, 200)}`); + const result = await upstream.callTool('batch', { commands }, callId); + const text = result.content.find((part) => part.type === 'text')?.text; + let reply: { results?: unknown[]; failed?: { command?: unknown; error?: unknown } } | undefined; + try { + reply = JSON.parse(typeof text === 'string' ? text : '') as typeof reply; + } catch { + reply = undefined; + } + // SolidCraft marks a batch with a failed step as an error and still answers in JSON: only the step and its reason go on. + if (reply?.failed) throw new Error(`SolidCraft refused ${String(reply.failed.command)}: ${String(reply.failed.error).slice(0, 200)}`); + if (result.isError || !reply) throw new Error(typeof text === 'string' ? text.slice(0, 300) : 'SolidCraft gave no answer'); return reply.results ?? []; }; + const site: Site = { upstream, run, callId, mark: {} }; + try { + return await mechanism(site, args, nozzle, keepFace); + } catch (cause) { + const message = (cause as Error).message; + // Unset: the call was refused before anything was built. + if (site.mark.undo === undefined) throw cause; + throw new Error(`${message.replace(/\.?$/, '.')} ${(await takeBack(run, site.mark.undo)) ? 'Everything was taken back.' : 'What was begun could not be taken back: undo it before you call again.'}`, { cause }); + } +} + +async function mechanism(site: Site, args: Record, nozzle: number, keepFace: KeepFace): Promise { + const { upstream, run, callId } = site; if (args.kind === 'hinge') { const spec = hingeOf(args, nozzle); if (typeof spec === 'string') throw new Error(spec); @@ -470,10 +518,7 @@ async function buildMechanism(upstream: UpstreamTools, args: Record depths ? ` The knuckles' rooms go ${String(depths.fixed)} mm into ${spec.fixed} and ${String(depths.moving)} mm into ${spec.moving}, measured from the face nearest the line: a wall there must be ${least} mm thicker than that, or thinner than it (the room then opens through the wall); anything between is left as a thin skin.` : ''; - const warnings = await build( - upstream, - run, - spec, + const warnings = await build(site, spec, // With the walls and the bed measured, knuckles that start in the air get their webs. () => { const steps = hingeSteps(depths?.support ? { ...spec, support: depths.support } : spec); @@ -481,7 +526,6 @@ async function buildMechanism(upstream: UpstreamTools, args: Record { depths = await roomDepths(upstream, spec, callId); @@ -501,10 +545,10 @@ async function buildMechanism(upstream: UpstreamTools, args: Record []); @@ -547,7 +591,7 @@ async function buildMechanism(upstream: UpstreamTools, args: Record 1 ? 's' : ''} "${spec.name}" on ${spec.fixed}, each with its window through the ${String(spec.wall)} mm wall of ${spec.moving}: an arm ${String(SNAP.length)} mm long, ${String(SNAP.thickness)} mm thick and ${String(SNAP.width)} mm wide, ${String(SNAP.gap)} mm off the wall, with a catch of ${String(SNAP.catch)} mm that lies over the window's edge. ` + @@ -575,7 +619,7 @@ async function buildMechanism(upstream: UpstreamTools, args: Record { // Where the line lies, measured on trial with a rod that is taken back. 'solid.cylinder inspect.clearance inspect.measure sketch.inspect', [...Array.from({ length: 7 }, () => 'solid.cylinder'), 'sketch.create', 'sketch.line', 'solid.extrude', 'solid.cylinder'].join(' '), - 'document.inspect inspect.interference', + 'inspect.interference', 'component.list', 'component.from_bodies component.list', 'joint.as_built', @@ -164,8 +164,8 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { calls.push(name); // Before: three steps to undo. Of the cylinders the five knuckles (48 mm of hinge line) do not touch their body, as SolidCraft 0.40.0-rc.3 warns. if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Box' }, { name: 'Lid' }], undo: 3 }) }] }); - if (name === 'undo') return Promise.resolve({ content: [{ type: 'text', text: '{}' }] }); const commands = args.commands as { command: string; params: { operation?: string; diameter?: string } }[]; + calls.push(...commands.filter((step) => step.command === 'edit.undo').map((step) => step.command)); const results = commands.map((step) => step.command === 'document.inspect' ? { bodies: [{ name: 'Box' }, { name: 'Lid' }], undo: 11 } @@ -180,10 +180,47 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { expect(built.isError).toBe(true); expect(built.content[0]).toMatchObject({ text: 'Not built: 5 piece(s) did not touch their body and stand apart from it. Put the hinge line within 2.5 mm of both bodies (the barrel is 6 mm, so it must overlap each by at least 0.5 mm). ' + - "PartBuddy cuts each knuckle's room in the other body itself. Everything was taken back; call again with the line moved.", + "PartBuddy cuts each knuckle's room in the other body itself. Everything was taken back.", }); - // Every step SolidCraft counted since (11 − 3) is undone, and no joint was made. - expect(calls).toEqual(['inspect_design', 'batch', 'batch', 'batch', 'batch', ...Array.from({ length: 8 }, () => 'undo')]); + // Every step SolidCraft counted since (11 − 3) is undone in one batch, and no joint was made. + expect(calls).toEqual(['inspect_design', 'batch', 'batch', 'batch', 'batch', 'batch', ...Array.from({ length: 8 }, () => 'edit.undo')]); + }); + + // Audit 2026-11-12, H-02: the joint was refused after the knuckles were built, the answer said "Not built" with + // SolidCraft's JSON, and the knuckles stayed (2 → 11 features; the next call built them again). + it('takes the knuckles back when the joint cannot be made, and passes on SolidCraft\'s reason in one sentence', async () => { + const refused = { applied: [], completed: 0, failed: { command: 'joint.as_built', error: 'invalid parameters for `joint.create`: a joint needs two different occurrences', index: 0 }, results: [] }; + const tools = (undo: (depth: number) => unknown) => { + const sent: string[] = []; + let depth = 2; + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve([]), + callTool: (name, args) => { + if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Base' }, { name: 'Flap' }], undo: undo(depth) }) }] }); + const commands = args.commands as { command: string; params: { operation?: string } }[]; + sent.push(commands.map((step) => step.command).join(' ')); + // As SolidCraft 0.40.1 answers a batch whose step fails: marked as an error, the reason inside its JSON. + if (commands[0]?.command === 'joint.as_built') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify(refused, null, 2) }], isError: true }); + // The probe's batch fails on purpose and SolidCraft rolls it back. + if (!commands.some((step) => step.command === 'sketch.inspect')) depth += commands.filter((step) => step.params.operation !== undefined).length - commands.filter((step) => step.command === 'edit.undo').length; + const results = commands.map((step) => (step.command === 'document.inspect' ? { undo: undo(depth) } : step.command === 'component.list' ? { components: [{ id: 3, parent: 0, bodies: ['Base', 'Flap'], occurrences: [{ id: 4, name: 'Both:1' }] }] } : {})); + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }] }); + }, + }; + return { sent, depth: () => depth, call: () => withMechanismTool(upstream, () => 0.4).callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42, 0, 3], to: [42, 30, 3], knuckles: 3 }) }; + }; + const taken = tools((depth) => depth); + expect(await taken.call()).toEqual({ isError: true, content: [{ type: 'text', text: 'Not built: SolidCraft refused joint.as_built: invalid parameters for `joint.create`: a joint needs two different occurrences. Everything was taken back.' }] }); + expect(taken.sent.at(-1)).toBe(Array.from({ length: 9 }, () => 'edit.undo').join(' ')); + expect(taken.depth()).toBe(2); + // A second call starts from the same design: it does not build on top of the first. + await taken.call(); + expect(taken.depth()).toBe(2); + // Where SolidCraft's undo depth cannot be read nothing is taken back, and the answer says so instead of "as it was". + const stuck = tools(() => undefined); + expect((await stuck.call()).content[0]).toMatchObject({ text: expect.stringMatching(/^Not built: SolidCraft refused joint\.as_built: .* What was begun could not be taken back: undo it before you call again\.$/) as string }); + expect(stuck.sent.join(' ')).not.toContain('edit.undo'); }); it('builds onto a body that is a component with that component active, so the knuckles move with it', () => { From acac0d5387253ad34e09bba9ee1ecfa1d08c2504 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:27:01 +0100 Subject: [PATCH 10/24] fix(desktop): a second hinge gets a name of its own, and none is built twice between two bodies SolidCraft takes a joint name twice; sweep and limits by that name then met the first joint (audit 2026-11-12, N-mechanik-3: the answer gave the first hinge's limits, the second had limits null, a third call built a third joint between the same parts). Measured after: second joint "Hinge (2)" with limits [-67, 145], the repeated call refused with the design unchanged. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/agents/mechanismTool.ts | 30 +++++++++++++--- apps/desktop/test/mechanism.test.ts | 36 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/agents/mechanismTool.ts b/apps/desktop/src/main/agents/mechanismTool.ts index eec47959..dee00a5f 100644 --- a/apps/desktop/src/main/agents/mechanismTool.ts +++ b/apps/desktop/src/main/agents/mechanismTool.ts @@ -242,12 +242,17 @@ export function screwOf(args: Record): ScrewSpec | string { /** The occurrence that places a body, from `component.list`; null while the body is still in the design itself. */ export function occurrenceOf(listing: unknown, body: string): string | null { + return placed(listing, body)?.name ?? null; +} + +/** That occurrence with its id, which is what `joint.list` names a joint's two sides by. */ +function placed(listing: unknown, body: string): { id?: unknown; name: string } | null { const components = (listing as { components?: unknown } | undefined)?.components; if (!Array.isArray(components)) return null; for (const entry of components as { parent?: unknown; bodies?: unknown; occurrences?: unknown }[]) { if (entry.parent === null || !Array.isArray(entry.bodies) || !entry.bodies.includes(body)) continue; - const name = (Array.isArray(entry.occurrences) ? (entry.occurrences[0] as { name?: unknown } | undefined) : undefined)?.name; - if (typeof name === 'string') return name; + const occurrence = Array.isArray(entry.occurrences) ? (entry.occurrences[0] as { id?: unknown; name?: unknown } | undefined) : undefined; + if (typeof occurrence?.name === 'string') return { id: occurrence.id, name: occurrence.name }; } return null; } @@ -320,8 +325,13 @@ export function activated(steps: Step[], listing: unknown): Step[] { * means the line is not where the bodies are: the call fails and with that everything is taken back (practice run 2026-11-10: the model kept a gap between the * hinge line and the walls, mended the loose knuckles with webs, and their warnings stayed in the * timeline), and `where` tells the model where the line goes. Volume the two still share is a warning. + * + * With `joins` the mechanism ends in a joint of that type: a second one between the same two bodies is refused, and a + * name another joint has gets a number, in `spec` itself, before the steps are made. SolidCraft takes a name twice, and + * sweep and limits by that name then met the first joint (audit 2026-11-12, N-mechanik-3: the answer gave the first + * hinge's limits, the second had none, and a third call built a third joint between the same parts). */ -async function build({ upstream, run, callId, mark }: Site, spec: { fixed: string; moving: string }, steps: Step[] | (() => Step[]), where: string, makes = false, ready: () => Promise = () => Promise.resolve()): Promise { +async function build({ upstream, run, callId, mark }: Site, spec: { fixed: string; moving: string; name: string }, steps: Step[] | (() => Step[]), where: string, makes = false, ready: () => Promise = () => Promise.resolve(), joins?: 'revolute' | 'slider'): Promise { const before = jsonOf(await upstream.callTool('inspect_design', {}, callId)) as { bodies?: { name?: unknown }[]; undo?: unknown }; const bodies = (before.bodies ?? []).map((body) => body.name); // A mechanism that makes its moving body (a lid) needs the name free. @@ -337,6 +347,17 @@ async function build({ upstream, run, callId, mark }: Site, spec: { fixed: strin .filter((joint) => (joint.values ?? []).some((value) => typeof value.value === 'number' && Math.abs(value.value) > 1e-6)) .map((joint) => String(joint.name)); if (away.length > 0) throw new Error(`the joint(s) ${away.map((name) => JSON.stringify(name)).join(', ')} stand away from 0; drive them back with joint.drive first, so the bodies are where they were designed`); + if (joins) { + const list = ((joints as { joints?: unknown } | undefined)?.joints ?? []) as { name?: unknown; type?: unknown; a?: unknown; b?: unknown }[]; + const [a, b] = [placed(listing, spec.fixed), placed(listing, spec.moving)]; + const side = (occurrence: { id?: unknown; name: string }, value: unknown) => value === occurrence.id || value === occurrence.name; + const same = a && b ? list.find((joint) => joint.type === joins && ((side(a, joint.a) && side(b, joint.b)) || (side(b, joint.a) && side(a, joint.b)))) : undefined; + const kind = joins === 'revolute' ? 'hinge' : 'guide'; + if (same) throw new Error(`the joint ${JSON.stringify(same.name)} already joins ${spec.fixed} and ${spec.moving} as a ${kind}, and a second ${kind} between the same two bodies would bind it. Change that one instead: take it back with undo, or delete its features and its joint, then call again`); + const taken = new Set(list.map((joint) => joint.name)); + const asked = spec.name; + for (let n = 2; taken.has(spec.name); n += 1) spec.name = `${asked} (${String(n)})`; + } // The bodies are there and stand as designed: what must be measured on them before anything is built. await ready(); // From here on the design changes: a call that fails later is taken back to this depth. @@ -532,6 +553,7 @@ async function mechanism(site: Site, args: Record, nozzle: numb const thin = depths && typeof args.wall === 'number' ? skinProblem(depths, spec, args.wall, nozzle) : null; if (thin) throw new Error(thin); }, + 'revolute', ); const range = await join(run, spec, 'revolute', [-180, 180], 1); if (typeof range === 'string') return `Built the hinge "${spec.name}" between ${spec.fixed} (stays) and ${spec.moving} (turns) and joined them, but ${unswept(range)}${warnings}`; @@ -545,7 +567,7 @@ async function mechanism(site: Site, args: Record, nozzle: numb if (args.kind === 'slide') { const spec = slideOf(args, nozzle); if (typeof spec === 'string') throw new Error(spec); - const warnings = await build(site, spec, slideSteps(spec), `Put the line from–to on a face of ${spec.fixed}, with up as that face's normal, so the rail stands on it.`); + const warnings = await build(site, spec, () => slideSteps(spec), `Put the line from–to on a face of ${spec.fixed}, with up as that face's normal, so the rail stands on it.`, false, undefined, 'slider'); const [measure] = await run([{ command: 'inspect.measure', params: { bodies: [spec.moving] } }]); const travel = railTravel(measure, spec.from, spec.to); if (!travel) throw new Error(`${spec.moving} could not be measured, so how far the guide may travel is not known.`); diff --git a/apps/desktop/test/mechanism.test.ts b/apps/desktop/test/mechanism.test.ts index 6bda7534..1fca2cf6 100644 --- a/apps/desktop/test/mechanism.test.ts +++ b/apps/desktop/test/mechanism.test.ts @@ -223,6 +223,42 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { expect(stuck.sent.join(' ')).not.toContain('edit.undo'); }); + // Audit 2026-11-12, N-mechanik-3: a second hinge with the default name answered with the first joint's limits and + // had none itself; the same call again built a third joint between the same two parts. + it('names a second hinge apart from the first, and refuses a second one between the same two bodies before it builds', async () => { + const batches: { command: string; params: Record }[][] = []; + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve([]), + callTool: (name, args) => { + if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Base' }, { name: 'Flap' }, { name: 'Flap2' }], undo: 12 }) }] }); + const commands = args.commands as { command: string; params: Record }[]; + batches.push(commands); + const results = commands.map((step) => { + // As SolidCraft 0.40.1 lists them: a joint's sides by the ids of their occurrences. + if (step.command === 'joint.list') return { joints: [{ name: 'Hinge', type: 'revolute', a: 15, b: 17, values: [{ value: 0 }] }] }; + if (step.command === 'component.list') return { components: [{ id: 14, parent: 0, bodies: ['Base'], occurrences: [{ id: 15, name: 'Base:1' }] }, { id: 16, parent: 0, bodies: ['Flap'], occurrences: [{ id: 17, name: 'Flap:1' }] }, { id: 31, parent: 0, bodies: ['Flap2'], occurrences: [{ id: 32, name: 'Flap2:1' }] }] }; + return step.command === 'joint.sweep' ? { stopped_at: step.params.to === 180 ? 67.4 : -145.8 } : {}; + }); + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }] }); + }, + }; + const tools = withMechanismTool(upstream, () => 0.4); + const again = await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Flap', moving: 'Base', from: [42, 0, 3], to: [42, 30, 3], name: 'Another' }); + expect(again).toEqual({ isError: true, content: [{ type: 'text', text: 'Not built: the joint "Hinge" already joins Flap and Base as a hinge, and a second hinge between the same two bodies would bind it. Change that one instead: take it back with undo, or delete its features and its joint, then call again' }] }); + expect(batches.map((batch) => batch.map((step) => step.command).join(' '))).toEqual(['component.list joint.list']); + // Another pair of bodies: built, under a name of its own, which the joint, its sweeps and its limits all carry. + const second = await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap2', from: [-2, 0, 3], to: [-2, 30, 3] }); + expect(second.isError).toBeUndefined(); + expect(second.content[0]).toMatchObject({ text: expect.stringContaining('Built the hinge "Hinge (2)" between Base (stays) and Flap2 (turns)') as string }); + const sent = batches.flat(); + expect(sent.find((step) => step.command === 'joint.as_built')?.params).toMatchObject({ a: 'Base:1', b: 'Flap2:1', name: 'Hinge (2)' }); + expect(sent.filter((step) => step.command === 'joint.sweep' || step.command === 'joint.limits').map((step) => step.params.joint)).toEqual(['Hinge (2)', 'Hinge (2)', 'Hinge (2)']); + // A guide between the two bodies of the hinge is another kind of joint: it is not refused for the hinge. + const guide = await tools.callTool('partbuddy_mechanism', { kind: 'slide', fixed: 'Base', moving: 'Flap', from: [0, 15, 4], to: [40, 15, 4] }); + expect(guide.content[0]).toMatchObject({ text: expect.not.stringContaining('already joins') as string }); + }); + it('builds onto a body that is a component with that component active, so the knuckles move with it', () => { // The flap is a component already (a second mechanism, or the assistant made components first); the base is not. const listing = { active: 0, components: [{ id: 0, parent: null, bodies: ['Base'] }, { id: 5, parent: 0, bodies: ['Flap'], occurrences: [{ name: 'Flap:1' }] }] }; From 63f4dfbbd1a17695c286f202bd10f186647c5726 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:28:48 +0100 Subject: [PATCH 11/24] fix(desktop): the bead of a lid with walls reaches 0.25 mm into its rille The finger notch was cut after the bead and took away the part of it past the wall's plane: the bead ended on the plane and caught nothing, in the tool and in the hinged box template (audit 2026-11-12, N-mechanik-2). The notch is now cut first. Measured: bead reach past the wall 0.000 -> 0.250 mm (walls 1.6, 2 and 10 mm, rooms 20x12 and 56x36, radius 0, 2 and 8, the template), flat lid 0.250 mm as before, no interference. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/shared/mechanisms.ts | 26 ++++++++++++++------------ apps/desktop/test/mechanism.test.ts | 13 +++++++++---- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/shared/mechanisms.ts b/apps/desktop/src/shared/mechanisms.ts index 9866621d..d2ac33db 100644 --- a/apps/desktop/src/shared/mechanisms.ts +++ b/apps/desktop/src/shared/mechanisms.ts @@ -361,6 +361,20 @@ export function lipSteps(spec: LipSpec): Step[] { ...(spec.flat ? [] : ring(`${name} ·`, by(spec.rim, up > 0 ? -LIP.foot : 0), -0.2, LIP.foot)), ...ring(name, by(spec.rim, up > 0 ? 0 : -LIP.high), LIP.clear, LIP.high), ...(spec.flat ? [] : [{ command: 'solid.chamfer', params: { edges: [xyz(middleAt, at(wallAt) - out * inner, at(spec.rim) - up * LIP.foot)], distance: mm(inner - 0.05), body: spec.moving, name: `${name} ◢` } }]), + // The notch before the bead: it reaches below the rim, where the bead stands past the wall's plane, and cut after + // it took that part away, so the bead ended on the plane and caught nothing (audit 2026-11-12, N-mechanik-2). + ...(spec.flat ? [] : [{ + command: 'solid.cylinder', + params: { + diameter: mm(LIP.notch), + height: ex(spec.wall), + base: xyz(middle, out > 0 ? ex(wallAt) : `${ex(wallAt)} - ${ex(spec.wall)}`, by(spec.rim, up * (LIP.notch / 2 - 2.5))), + axis: alongX ? 'Y' : 'X', + operation: 'cut', + targets: [spec.moving], + name: `${name} ◠`, + }, + }]), { command: 'solid.box', params: { @@ -384,18 +398,6 @@ export function lipSteps(spec: LipSpec): Step[] { name: `${name} ○`, }, }, - ...(spec.flat ? [] : [{ - command: 'solid.cylinder', - params: { - diameter: mm(LIP.notch), - height: ex(spec.wall), - base: xyz(middle, out > 0 ? ex(wallAt) : `${ex(wallAt)} - ${ex(spec.wall)}`, by(spec.rim, up * (LIP.notch / 2 - 2.5))), - axis: alongX ? 'Y' : 'X', - operation: 'cut', - targets: [spec.moving], - name: `${name} ◠`, - }, - }]), ]; } diff --git a/apps/desktop/test/mechanism.test.ts b/apps/desktop/test/mechanism.test.ts index 1fca2cf6..615890ec 100644 --- a/apps/desktop/test/mechanism.test.ts +++ b/apps/desktop/test/mechanism.test.ts @@ -375,11 +375,16 @@ describe('the snap closure of a lid (G3)', () => { ['sketch.rectangle.two_point', ''], ['solid.extrude', 'Closure'], ['solid.chamfer', 'Closure ◢'], + ['solid.cylinder', 'Closure ◠'], ['solid.box', 'Closure ●'], ['solid.chamfer', 'Closure ◆'], ['solid.cylinder', 'Closure ○'], - ['solid.cylinder', 'Closure ◠'], ]); + // The notch is cut before the bead is built: its cylinder (16 mm wide, from the wall's plane outwards, 2.5 mm into the lid + // above the rim) also covers the bead's height below the rim, and would take the 0.25 mm of bead past that plane with it. + expect(steps[9]?.params).toMatchObject({ diameter: '16 mm', base: ['(2 mm + 58 mm) / 2', '2 mm - 2 mm', '20 mm - 5.5 mm'], axis: 'Y', operation: 'cut', targets: ['Lid'] }); + const [notch, bead] = [steps.findIndex((step) => step.params.name === 'Closure ◠'), steps.findIndex((step) => step.params.name === 'Closure ●')]; + expect(notch).toBeLessThan(bead); // The foot lies above the rim in the lid, 0.2 mm into its wall; the lip hangs 4 mm below the rim, 0.25 mm clear, 1.2 mm thick. expect(steps[0]?.params.offset).toBe('20 mm'); expect(steps[1]?.params).toEqual({ p0: ['2 mm - 0.2 mm', '2 mm - 0.2 mm'], p1: ['58 mm + 0.2 mm', '38 mm + 0.2 mm'] }); @@ -389,8 +394,8 @@ describe('the snap closure of a lid (G3)', () => { // Only the rille is cut from the box; everything else belongs to the lid. expect(steps.filter((step) => JSON.stringify(step.params.targets ?? step.params.body ?? '').includes('Box')).map((step) => step.params.name)).toEqual(['Closure ○']); // The bead stands 0.25 mm past the wall's face (0.5 mm proud of the lip), 14 mm long in the wall's middle; its rille sits where it lands. - expect(steps[9]?.params).toMatchObject({ length: '14 mm', width: '0.5 mm', height: '1 mm', corner: ['(2 mm + 58 mm) / 2 - 7 mm', '2 mm - 0.25 mm', '20 mm - 3 mm'] }); - expect(steps[11]?.params).toMatchObject({ diameter: '1.4 mm', axis: 'X', base: ['(2 mm + 58 mm) / 2 - 7.5 mm', '2 mm + 0.25 mm', '20 mm - 2.5 mm'] }); + expect(steps[10]?.params).toMatchObject({ length: '14 mm', width: '0.5 mm', height: '1 mm', corner: ['(2 mm + 58 mm) / 2 - 7 mm', '2 mm - 0.25 mm', '20 mm - 3 mm'] }); + expect(steps[12]?.params).toMatchObject({ diameter: '1.4 mm', axis: 'X', base: ['(2 mm + 58 mm) / 2 - 7.5 mm', '2 mm + 0.25 mm', '20 mm - 2.5 mm'] }); // A flat plate as the lid gets the lip alone on its face: no foot that would come out of its top, no notch in a wall it does not have. expect(lipSteps({ ...lid, flat: true }).map((step) => step.params.name ?? '').filter(Boolean)).toEqual(['Closure', 'Closure', 'Closure ●', 'Closure ◆', 'Closure ○']); expect(lipOf({ fixed: 'Box', moving: 'Lid', from: [2, 2, 20], to: [58, 38, 20], side: 'y-', flat: true })).toMatchObject({ flat: true }); @@ -400,7 +405,7 @@ describe('the snap closure of a lid (G3)', () => { it('puts the rille where the bead lands when the lid is built open and closes on a hinge, and follows a template\'s parameters', () => { const open = lipSteps({ ...lid, up: 1, side: 'y+', rim: { at: 16, expr: 'height' }, hinge: { along: 'x', across: 0.5, z: { at: 16, expr: 'height' } } }); - expect(open[11]?.params.base).toEqual(['(2 mm + 58 mm) / 2 - 7.5 mm', '2 * 0.5 mm - (38 mm - 0.25 mm)', '2 * (height) - ((height) + 2.5 mm)']); + expect(open[12]?.params.base).toEqual(['(2 mm + 58 mm) / 2 - 7.5 mm', '2 * 0.5 mm - (38 mm - 0.25 mm)', '2 * (height) - ((height) + 2.5 mm)']); expect(open[0]?.params.offset).toBe('(height) - 3 mm'); }); From 00e7dda1d632175159ac8a20a5d85761adb6cc87 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:37:18 +0100 Subject: [PATCH 12/24] =?UTF-8?q?fix(desktop):=20the=20guide's=20groove=20?= =?UTF-8?q?gets=20a=20roof=20at=2045=C2=B0=20and=20its=20answer=20says=20h?= =?UTF-8?q?ow=20the=20parts=20print?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The groove's ceiling was level and 13.9 mm wide at the defaults (up to 102 mm), a gap over the rail's head, while the tool called every mechanism "printed in one piece without support" (audit 2026-11-12, T5 and N-mechanik-6). Where the moving body stands high enough over the rail's face the ceiling is now a roof at 45° with a ridge of 5 mm; where not, it stays flat and the answer says so, with how to lay the part. The two parts print apart, which the description and the answer now say. Measured with the print check of this branch, PLA 0.4: default guide warn 418 mm² -> ready, dovetail to plug warn 389 mm² -> ready; gap, travel and body count as before. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/agents/mechanismTool.ts | 65 ++++++++++++++++--- apps/desktop/src/shared/mechanisms.ts | 22 ++++++- apps/desktop/test/mechanism.test.ts | 55 +++++++++++++++- 3 files changed, 128 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/main/agents/mechanismTool.ts b/apps/desktop/src/main/agents/mechanismTool.ts index dee00a5f..e90c24c8 100644 --- a/apps/desktop/src/main/agents/mechanismTool.ts +++ b/apps/desktop/src/main/agents/mechanismTool.ts @@ -1,4 +1,4 @@ -import { LID, LIP, hingeProblem, hingeSteps, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, pinDiameter, screwLength, screwProblem, screwSteps, slideProblem, slideSteps, snapProblem, snapSteps, latchProblem, latchReach, latchSteps, LATCH, pegLength, pegProblem, pegSteps, PEG, SNAP, type HingeSpec, type HingeSide, type LatchSpec, type LidSpec, type LipSpec, type PegSpec, type ScrewSpec, type SlideSpec, type SnapSpec } from '../../shared/mechanisms'; +import { LID, LIP, hingeProblem, hingeSteps, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, pinDiameter, screwLength, screwProblem, screwSteps, slideProblem, slideRoof, slideSteps, snapProblem, snapSteps, latchProblem, latchReach, latchSteps, LATCH, pegLength, pegProblem, pegSteps, PEG, SNAP, type HingeSpec, type HingeSide, type LatchSpec, type LidSpec, type LipSpec, type PegSpec, type ScrewSpec, type SlideSpec, type SnapSpec } from '../../shared/mechanisms'; import { BLOCK_SIZES, INSERT, type BlockSize } from '../../shared/screwSizes'; import { componentOf } from '../solidcraft/actions'; import type { ToolResult, UpstreamTool, UpstreamTools } from './gateProxy'; @@ -17,10 +17,10 @@ const vector = { type: 'array', items: { type: 'number' }, minItems: 3, maxItems const mechanismTool: UpstreamTool = { name: MECHANISM_TOOL, description: - 'Build a print-in-place mechanism between two bodies that exist, printed in one piece without support, with the same gap everywhere. ' + + 'Build a mechanism between two bodies that exist, with the same gap everywhere; the answer says how it prints. ' + 'hinge: knuckles in turn on both bodies and a pin through them. Give the hinge line (from, to) in the gap between the bodies, level with the bed and half the barrel diameter above it when the part prints lying flat; ' + 'the line must lie closer to both bodies than the barrel radius less 0.5 mm, so the knuckles grow out of them; PartBuddy cuts each knuckle\'s room in the other body itself, a gap wider than the barrel, and carries knuckles that start above the bed on webs at 45° down to their own walls: add no supports of your own. Give wall, the thickness of the walls along the line: a room that would leave less than two lines of a wall behind it is refused, with how far to move the line. ' + - 'slide: a dovetail rail on the fixed body and its groove in the moving body, which lies over the rail a gap above the face. Give the middle line of the rail (from, to) on that face and the face normal (up). ' + + 'slide: a dovetail rail on the fixed body and its groove in the moving body, which lies over the rail a gap above the face. Give the middle line of the rail (from, to) on that face and the face normal (up). The two print apart and the moving body is pushed onto the rail from an end. Where the moving body stands high enough over that face (8.8 mm at the defaults; the answer names it) the groove gets a roof at 45° and prints as it stands; otherwise its ceiling is flat and the answer says how to lay the part. ' + 'PartBuddy also joins the two with a joint, finds how far it moves before the bodies meet (a slide: while the moving body stays on its rail) and sets that as its limits; the user then moves it with a slider in the view. ' + 'closure: a snap closure between a lid and its box, nothing standing out: a lip inside the lid that goes into the box along its wall and centres the lid, a bead that snaps into a rille of the box\'s wall, and a notch for a fingernail. ' + 'Give from and to as opposite corners of the lid\'s inner room at its rim (both at the rim\'s height), up as the way the lip stands out of the lid (+Z when the lid lies open side up, -Z when it sits on the box), wall and radius of the lid, and the side with the catch (a long wall, opposite the hinge). ' + @@ -275,6 +275,16 @@ export function railTravel(measure: unknown, from: Vec3, to: Vec3): [number, num return [round(Math.min(0, -Math.min(...along))), round(Math.max(0, length - Math.max(...along)))]; } +/** How high a body stands over a face, along the face's normal `up` from a point on it: from the body's box (`inspect.measure`). */ +function heightOver(measure: unknown, from: Vec3, up: Vec3): number | null { + const box = ((measure as { bodies?: { bbox?: { min?: unknown; max?: unknown } }[] } | undefined)?.bodies ?? [])[0]?.bbox; + const min = vec3(box?.min); + const max = vec3(box?.max); + if (!min || !max) return null; + const length = Math.hypot(...up); + return Math.max(...[0, 1, 2, 3, 4, 5, 6, 7].map((corner) => [0, 1, 2].reduce((sum, i) => sum + ((((corner >> i) & 1 ? max[i] : min[i]) ?? 0) - (from[i] ?? 0)) * ((up[i] ?? 0) / length), 0))); +} + /** How far a sweep ran before two parts met, cut towards zero so the limit is clear of the contact; `to` when it ran through. */ function reach(sweep: unknown, to: number, step: number): number { const stopped = (sweep as { stopped_at?: unknown } | undefined)?.stopped_at; @@ -476,6 +486,41 @@ export function skinProblem(depths: { fixed: number; moving: number }, spec: { f const MOVE = 'The user moves it with the slider "Moving parts" in the view.'; +/** + * Builds the rail and the groove of a guide or a dovetail. The moving body is measured first: where it stands high + * enough over the rail's face, the groove gets its roof; where not, the ceiling stays flat and `print` says so, with + * how to lay the part instead (audit 2026-11-12, T5: "printed in one piece without support" over a ceiling of 13.9 mm). + * shortcut: the height is the body's box, so a body lower over the rail than elsewhere gets a roof that comes out of + * its top; measure along the line if such bodies turn up. + */ +async function buildGuide(site: Site, spec: SlideSpec, nozzle: number, joins?: 'slider'): Promise<{ warnings: string; measure: unknown; print: string }> { + const roof = slideRoof(spec); + // Two lines of plastic stay over the ridge. + const needed = Math.round((roof.top + nozzle * 2 * 1.125) * 10) / 10; + let measure: unknown; + let high = 0; + const warnings = await build( + site, + spec, + () => slideSteps(spec), + `Put the line from–to on a face of ${spec.fixed}, with up as that face's normal, so the rail stands on it.`, + false, + async () => { + [measure] = await site.run([{ command: 'inspect.measure', params: { bodies: [spec.moving] } }]); + const over = heightOver(measure, spec.from, spec.up); + if (over === null) throw new Error(`${spec.moving} could not be measured, so neither the room for the groove's roof nor how far the guide may travel is known`); + high = Math.round(over * 10) / 10; + spec.roof = over >= needed; + }, + joins, + ); + const apart = `The two print apart, each with up pointing up, and ${spec.moving} is then pushed onto the rail from an end.`; + const print = spec.roof + ? `${apart} Neither needs support: the rail's flanks and the roof over the groove lean 45°, and the roof's ridge is a bridge of ${String(roof.ridge)} mm.` + : `${apart} The groove's ceiling is flat and ${String(Math.round(roof.ceiling * 10) / 10)} mm wide, more than prints as a bridge (10 mm): a roof at 45° over it needs ${spec.moving} to stand ${String(needed)} mm high over the face the rail is on, and it stands ${String(high)} mm. Print ${spec.moving} turned over instead, lying on its top with the groove open upwards (set that face as its print face), or make it higher and build this again.`; + return { warnings, measure, print }; +} + /** What the model hears when the joint is made but its range could not be found. */ const unswept = (reason: string) => `SolidCraft could not run it through its range (${reason.slice(0, 160)}), so no limits are set. The two bodies probably lie against each other somewhere: look with inspect.clearance, make room, then set joint.limits yourself after joint.sweep.`; @@ -567,18 +612,18 @@ async function mechanism(site: Site, args: Record, nozzle: numb if (args.kind === 'slide') { const spec = slideOf(args, nozzle); if (typeof spec === 'string') throw new Error(spec); - const warnings = await build(site, spec, () => slideSteps(spec), `Put the line from–to on a face of ${spec.fixed}, with up as that face's normal, so the rail stands on it.`, false, undefined, 'slider'); - const [measure] = await run([{ command: 'inspect.measure', params: { bodies: [spec.moving] } }]); + // The box along the line is the same before and after the groove is cut. + const { warnings, measure, print } = await buildGuide(site, spec, nozzle, 'slider'); const travel = railTravel(measure, spec.from, spec.to); if (!travel) throw new Error(`${spec.moving} could not be measured, so how far the guide may travel is not known.`); const range = await join(run, spec, 'slider', travel, 0.1); - if (typeof range === 'string') return `Built the guide "${spec.name}" between ${spec.fixed} (stays) and ${spec.moving} (slides) and joined them, but ${unswept(range)}${warnings}`; + if (typeof range === 'string') return `Built the guide "${spec.name}" between ${spec.fixed} (stays) and ${spec.moving} (slides) and joined them, but ${unswept(range)} ${print}${warnings}`; const [min, max] = range; const mm = (value: number) => String(Math.round(value * 10) / 10); return ( `Built the guide "${spec.name}" between ${spec.fixed} (stays) and ${spec.moving} (slides): a dovetail rail ${String(spec.width)} mm wide at its foot and ${String(spec.height)} mm high, ` + `gap ${String(spec.gap)} mm everywhere. It slides from ${mm(min)} mm to ${mm(max)} mm while ${spec.moving} stays on the rail and the bodies do not meet, and those are its limits now; 0 mm is as designed. ` + - `${MOVE} Nothing holds it at the ends of the rail; add stops if it must not come off.${warnings}` + `${MOVE} Nothing holds it at the ends of the rail; add stops if it must not come off. ${print}${warnings}` ); } if (args.kind === 'closure') { @@ -652,10 +697,10 @@ async function mechanism(site: Site, args: Record, nozzle: numb // The guide's own dovetail as a close fit: 0.2 mm, where guides for printed dovetails run from 0.1 to 0.3. const spec = slideOf({ ...args, gap: typeof args.gap === 'number' ? args.gap : 0.2, name: typeof args.name === 'string' && args.name.trim() !== '' ? args.name : 'Dovetail' }, nozzle); if (typeof spec === 'string') throw new Error(spec); - const warnings = await build(site, spec, slideSteps(spec), `Put the line from–to on a face of ${spec.fixed}, with up as that face's normal, so the rail stands on it.`); + const { warnings, print } = await buildGuide(site, spec, nozzle); return ( `Built the dovetail "${spec.name}" between ${spec.fixed} (carries the rail) and ${spec.moving} (has the groove): ${String(spec.width)} mm wide at its foot and ${String(spec.height)} mm high with 45° flanks, ${String(spec.gap)} mm clear all round. ` + - `It makes no joint: ${spec.moving} is pushed on along the line and stops where the groove ends inside it; where the groove runs through, nothing stops it. Print both with the rail's line level or upright, so the flanks need no support. Not printed yet: when it binds or rattles, build it again with another gap.${warnings}` + `It makes no joint: ${spec.moving} is pushed on along the line and stops where the groove ends inside it; where the groove runs through, nothing stops it. ${print} Not printed yet: when it binds or rattles, build it again with another gap.${warnings}` ); } if (args.kind === 'plug') { @@ -691,4 +736,4 @@ export function withMechanismTool(upstream: UpstreamTools, nozzle: () => number, } /** Added to the role prompt of every route, after the joint guidance. */ -export const MECHANISM_GUIDANCE = `For a hinge (a lid that folds, a flap, a clamp that opens) or a guide (a drawer, a slider, a carriage on a rail) call ${MECHANISM_TOOL} once both bodies exist instead of modelling knuckles, a pin or a rail yourself: it builds the print-in-place geometry with the right gaps, joins the bodies and sets the limits. For a lid that should hold shut by itself (a box, a case, a container) call it with kind closure instead of modelling a latch, a hook or a clip: a lip with a snap bead inside the lid, nothing standing out and no wall cut through. For parts that are screwed together (a lid on a housing, a bracket on a base, a cover) call it with kind screw instead of drilling the holes yourself: it sizes the clearance hole, the room for the head and the seat of the heat-set insert, and says which screws to buy. For a round container that should screw shut (a jar, a canister, a bottle cap) build the container with a plain cylindrical neck and call it with kind lid: it threads the neck and makes the lid, instead of modelling a thread yourself. For a cover or a housing half that should clip on and come off again by pressing on catches (an electronics housing, a battery cover) call it with kind snap: hooks on one part, their windows through the other part's wall. For a lid that should be held down by a latch one sees and opens with a finger (a tool box, a case with a hinged lid) call it with kind latch: a snap latch outside, on the wall opposite the hinge. For a part split in two to fit the printer, or two parts that are plugged or glued together, call it with kind plug: pegs with their pockets (two or more), or shape dovetail to push one part onto the other.`; +export const MECHANISM_GUIDANCE = `For a hinge (a lid that folds, a flap, a clamp that opens) or a guide (a drawer, a slider, a carriage on a rail) call ${MECHANISM_TOOL} once both bodies exist instead of modelling knuckles, a pin or a rail yourself: it builds the geometry with the right gaps, joins the bodies and sets the limits. For a lid that should hold shut by itself (a box, a case, a container) call it with kind closure instead of modelling a latch, a hook or a clip: a lip with a snap bead inside the lid, nothing standing out and no wall cut through. For parts that are screwed together (a lid on a housing, a bracket on a base, a cover) call it with kind screw instead of drilling the holes yourself: it sizes the clearance hole, the room for the head and the seat of the heat-set insert, and says which screws to buy. For a round container that should screw shut (a jar, a canister, a bottle cap) build the container with a plain cylindrical neck and call it with kind lid: it threads the neck and makes the lid, instead of modelling a thread yourself. For a cover or a housing half that should clip on and come off again by pressing on catches (an electronics housing, a battery cover) call it with kind snap: hooks on one part, their windows through the other part's wall. For a lid that should be held down by a latch one sees and opens with a finger (a tool box, a case with a hinged lid) call it with kind latch: a snap latch outside, on the wall opposite the hinge. For a part split in two to fit the printer, or two parts that are plugged or glued together, call it with kind plug: pegs with their pockets (two or more), or shape dovetail to push one part onto the other.`; diff --git a/apps/desktop/src/shared/mechanisms.ts b/apps/desktop/src/shared/mechanisms.ts index d2ac33db..1c280155 100644 --- a/apps/desktop/src/shared/mechanisms.ts +++ b/apps/desktop/src/shared/mechanisms.ts @@ -189,9 +189,24 @@ export interface SlideSpec { height: number; /** The gap between rail and groove in mm. */ gap: number; + /** The moving body is high enough over the rail's face for the roof over the groove (`slideRoof`); without it the groove's ceiling is flat. */ + roof?: boolean; name: string; } +/** The flat ridge of the roof over a groove, in mm: a bridge that short does not sag (Protolabs Network, Xometry Pro; overhang.ts). */ +const RIDGE = 5; + +/** + * The ceiling of a guide's groove and the roof that takes its place, in mm. The ceiling is flat, as wide as the rail's + * head and a gap more on each side, and lies only a gap over the head: 13.9 mm at the defaults and up to 102 mm, where + * a bridge prints up to 10 mm (audit 2026-11-12, T5). The roof rises at 45° from both edges of the ceiling to a ridge + * `ridge` wide, `top` above the face the rail stands on. + */ +export function slideRoof(spec: Pick): { ceiling: number; ridge: number; top: number } { + const half = spec.width / 2 + spec.gap * Math.SQRT2 + spec.height + spec.gap; + return { ceiling: round(2 * half), ridge: RIDGE, top: round(spec.height + spec.gap + half - RIDGE / 2) }; +} /** What is wrong with a guide before anything is built; null when it can be built. */ export function slideProblem(spec: SlideSpec): string | null { @@ -210,8 +225,9 @@ export function slideProblem(spec: SlideSpec): string | null { /** * The solids of a dovetail guide along the line `from`–`to`: a rail on the fixed body that widens - * upwards at 45°, and its groove in the moving body, a gap wider all round. Both are a sketch at - * `from`, square to the line, extruded along it. + * upwards at 45°, and its groove in the moving body, a gap wider all round, with a roof instead of + * a flat ceiling where the body is high enough. Both are a sketch at `from`, square to the line, + * extruded along it. */ export function slideSteps(spec: SlideSpec): Step[] { const delta = spec.to.map((value, i) => value - (spec.from[i] ?? 0)) as Vec3; @@ -225,6 +241,7 @@ export function slideSteps(spec: SlideSpec): Step[] { const { height, gap } = spec; // The flank moved out by the gap, measured square to it. const out = gap * Math.SQRT2; + const roof = slideRoof(spec); const profile = (points: [number, number][], operation: 'cut' | 'join', target: string, name: string): Step[] => [ { command: 'sketch.create', params: { plane, project_edges: false, name } }, { command: 'sketch.line', params: { points: points.map(([x, y]) => [round(x), round(y)]), closed: true } }, @@ -238,6 +255,7 @@ export function slideSteps(spec: SlideSpec): Step[] { [half + out, -gap], [half + out, 0], [half + out + height + gap, height + gap], + ...(spec.roof ? ([[roof.ridge / 2, roof.top], [-roof.ridge / 2, roof.top]] as [number, number][]) : []), [-(half + out + height + gap), height + gap], [-(half + out), 0], ], diff --git a/apps/desktop/test/mechanism.test.ts b/apps/desktop/test/mechanism.test.ts index 615890ec..e7f199da 100644 --- a/apps/desktop/test/mechanism.test.ts +++ b/apps/desktop/test/mechanism.test.ts @@ -4,7 +4,7 @@ import type { UpstreamTools } from '../src/main/agents/gateProxy'; import { activated, hingeOf, skinProblem, latchOf, lidOf, lipOf, occurrenceOf, pegOf, railTravel, screwOf, slideOf, snapOf, withMechanismTool } from '../src/main/agents/mechanismTool'; import { decide } from '../src/main/agents/policy'; import { describeToolCall } from '../src/shared/stepPhrases'; -import { hingeProblem, hingeSteps, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, screwLength, screwProblem, screwSteps, slideProblem, slideSteps, snapProblem, snapSteps, latchReach, latchSteps, LATCH, pegLength, pegSteps, PEG, SNAP, type HingeSpec, type LidSpec, type LipSpec, type ScrewSpec, type SlideSpec } from '../src/shared/mechanisms'; +import { hingeProblem, hingeSteps, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, screwLength, screwProblem, screwSteps, slideProblem, slideRoof, slideSteps, snapProblem, snapSteps, latchReach, latchSteps, LATCH, pegLength, pegSteps, PEG, SNAP, type HingeSpec, type LidSpec, type LipSpec, type ScrewSpec, type SlideSpec } from '../src/shared/mechanisms'; const spec: HingeSpec = { fixed: 'Base', moving: 'Flap', from: [42, 0, 3], to: [42, 30, 3], diameter: 6, gap: 0.4, knuckles: 3, name: 'Hinge' }; @@ -324,6 +324,12 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { }); }); +/** The tool's description, as the model reads it. */ +const withMechanismDescription = async (): Promise => { + const tools = await withMechanismTool({ instructions: () => undefined, listTools: () => Promise.resolve([]), callTool: () => Promise.resolve({ content: [] }) }, () => 0.4).listTools(); + return String(tools[0]?.description); +}; + describe('the dovetail guide (ADR 0027, G3)', () => { const guide: SlideSpec = { fixed: 'Base', moving: 'Carriage', from: [0, 15, 4], to: [60, 15, 4], up: [0, 0, 1], width: 6, height: 3, gap: 0.4, name: 'Guide' }; @@ -340,6 +346,51 @@ describe('the dovetail guide (ADR 0027, G3)', () => { expect(steps[1]?.params.points).toEqual([[-3.566, -0.4], [3.566, -0.4], [3.566, 0], [6.966, 3.4], [-6.966, 3.4], [-3.566, 0]]); }); + // Audit 2026-11-12, T5: that roof is a level ceiling 13.9 mm wide (102 mm at the largest rail), a gap over the rail's + // head, and the tool called the guide "printed in one piece without support". + it('puts a roof at 45° with a 5 mm ridge over the groove where the moving body is high enough, and says how the parts print', async () => { + expect(slideRoof(guide)).toEqual({ ceiling: 13.931, ridge: 5, top: 7.866 }); + expect(slideRoof({ width: 60, height: 20, gap: 0.4 })).toMatchObject({ ceiling: 101.931, top: 68.866 }); + // From both edges of the old ceiling (±6.966 at 3.4) up at 45° to the ridge: 4.466 mm in, 4.466 mm up. + const roofed = slideSteps({ ...guide, roof: true }); + expect(roofed[1]?.params.points).toEqual([[-3.566, -0.4], [3.566, -0.4], [3.566, 0], [6.966, 3.4], [2.5, 7.866], [-2.5, 7.866], [-6.966, 3.4], [-3.566, 0]]); + // The rail is the same. + expect(roofed.slice(3)).toEqual(slideSteps(guide).slice(3)); + // The tool measures the moving body first: its top lies `top` over the base's face at z = 4. + const answer = async (top: number, args: Record = {}) => { + const sent: { command: string; params: Record }[] = []; + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve([]), + callTool: (name, call) => { + if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Base' }, { name: 'Carriage' }], undo: 2 }) }] }); + const commands = call.commands as { command: string; params: Record }[]; + sent.push(...commands); + const results = commands.map((step) => (step.command === 'inspect.measure' ? { bodies: [{ name: 'Carriage', bbox: { min: [20, 0, 4.4], max: [40, 30, 4 + top] } }] } : step.command === 'component.list' ? { components: [{ id: 3, parent: 0, bodies: ['Base'], occurrences: [{ id: 4, name: 'Base:1' }] }, { id: 5, parent: 0, bodies: ['Carriage'], occurrences: [{ id: 6, name: 'Carriage:1' }] }] } : {})); + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }] }); + }, + }; + const built = await withMechanismTool(upstream, () => 0.4).callTool('partbuddy_mechanism', { kind: 'slide', fixed: 'Base', moving: 'Carriage', from: [0, 15, 4], to: [60, 15, 4], ...args }); + return { text: String(built.content[0]?.text), groove: sent.find((step) => step.command === 'sketch.line')?.params.points as number[][], sent: sent.map((step) => step.command) }; + }; + // 7.866 mm of roof and two lines over its ridge: 8.8 mm. + const high = await answer(8.8); + expect(high.groove).toHaveLength(8); + expect(high.text).toContain("The two print apart, each with up pointing up, and Carriage is then pushed onto the rail from an end. Neither needs support: the rail's flanks and the roof over the groove lean 45°, and the roof's ridge is a bridge of 5 mm."); + // The body is measured before the first step is built. + expect(high.sent.indexOf('inspect.measure')).toBeLessThan(high.sent.indexOf('sketch.create')); + // Lower than that the ceiling stays flat, and the answer says what that means instead of "without support". + const low = await answer(8.7); + expect(low.groove).toHaveLength(6); + expect(low.text).not.toContain('Neither needs support'); + expect(low.text).toContain("The groove's ceiling is flat and 13.9 mm wide, more than prints as a bridge (10 mm): a roof at 45° over it needs Carriage to stand 8.8 mm high over the face the rail is on, and it stands 8.7 mm. Print Carriage turned over instead, lying on its top with the groove open upwards (set that face as its print face), or make it higher and build this again."); + // The dovetail to plug together is the same groove: the same roof, the same words. + const plug = await answer(12, { kind: 'plug', shape: 'dovetail' }); + expect(plug.groove).toHaveLength(8); + expect(plug.text).toContain('nothing stops it. The two print apart, each with up pointing up'); + expect(await withMechanismDescription()).not.toContain('printed in one piece without support'); + }); + it('says what is wrong before anything is built, and fills in the defaults', () => { expect(slideProblem(guide)).toBeNull(); expect(slideProblem({ ...guide, up: [1, 0, 0] })).toContain('up must be square to the line'); @@ -632,7 +683,7 @@ describe('a plugged connection (standards)', () => { if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Left' }, { name: 'Right' }] }) }] }); const commands = input.commands as { command: string }[]; calls.push(commands.map((step) => step.command)); - return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results: commands.map((step) => (step.command === 'inspect.interference' ? { count: 0 } : step.command === 'component.list' ? { components: [] } : {})) }) }] }); + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results: commands.map((step) => (step.command === 'inspect.interference' ? { count: 0 } : step.command === 'component.list' ? { components: [] } : step.command === 'inspect.measure' ? { bodies: [{ bbox: { min: [0, 0, 4.2], max: [40, 30, 16] } }] } : {})) }) }] }); }, }); From 22137a09d8d8b495c2be413716f716458e0a6e24 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:23:32 +0100 Subject: [PATCH 13/24] fix(desktop): hinge webs carry the whole barrel and stand only on a wall that is there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The answer said knuckles above the bed "stand on webs at 45°, so they print without support", and the print check still found 90 mm² (barrel 6), 121 mm² (8) and 619 mm² (40), up to 89.5° (audit 2026-11-12, N-mechanik-4): each web ended a gap before the other body, so the far half of every barrel hung, and so did the pin inside the moving knuckles. A web was also drawn to the wall's plane where the wall had ended, 4.64 mm in the air under a lid plate (N-mechanik-5). - The web starts where the barrel leans 45° on the far side; the other body is cut back by the same profile a gap further out. - A second trial rod, inside each wall just under the web's foot, says whether the wall reaches down there: a body without it gets no web, and the answer names it. - The pin is cut flat underneath inside each moving knuckle: a level bridge from one fixed knuckle to the next. The default knuckle count of a lying hinge keeps that bridge at 10 mm or less (30 mm of line: 5 knuckles, was 3). - The answer says "without support" only where every knuckle stands on a web and the bridge is within 10 mm; otherwise what is left. Measured, print check of this branch, PLA 0.4: hinge on 16 mm blocks warn 121 mm² -> ready (barrel 6: 90 -> ready, 40: 619 -> ready), on the template's shells 174 -> ready; lid plate in the air: no web under it, said so. Gap 0.400 mm, no shared volume, one shell per body, limits as before. The template keeps its joint on the pin's round top and draws every profile at its own place. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/e2e/solidcraft.contract.spec.ts | 12 +-- apps/desktop/src/main/agents/mechanismTool.ts | 99 ++++++++++++++++--- apps/desktop/src/shared/mechanisms.ts | 87 +++++++++++----- apps/desktop/src/shared/templates.ts | 18 ++-- apps/desktop/test/mechanism.test.ts | 97 +++++++++++++++--- 5 files changed, 248 insertions(+), 65 deletions(-) diff --git a/apps/desktop/e2e/solidcraft.contract.spec.ts b/apps/desktop/e2e/solidcraft.contract.spec.ts index 31ea93e8..79c85927 100644 --- a/apps/desktop/e2e/solidcraft.contract.spec.ts +++ b/apps/desktop/e2e/solidcraft.contract.spec.ts @@ -3243,8 +3243,8 @@ test('a hinge whose knuckles start above the bed carries them on webs at 45°: o await expect(page.getByTestId('connection-status')).toHaveAttribute('data-engine', 'headless', { timeout: 30_000 }); await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a hinged case'); await page.getByRole('button', { name: 'Send' }).click(); - await expect(page.getByText(/Built the hinge "Case hinge" between Base \(stays\) and Flap \(turns\): 3 knuckles/)).toBeVisible({ timeout: 60_000 }); - await expect(page.getByText(/3 knuckles start above the bed and stand on webs at 45° down to their own walls/)).toBeVisible(); + await expect(page.getByText(/Built the hinge "Case hinge" between Base \(stays\) and Flap \(turns\): 5 knuckles/)).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText(/All 5 knuckles start above the bed and stand on webs at 45° that run from under the barrel down to their own wall, and the pin is flat underneath where it bridges from knuckle to knuckle, so the hinge prints as it stands, without support./)).toBeVisible(); // With the webs the flap still folds all the way onto the base. await expect(page.getByText(/It turns from -179° to/)).toBeVisible(); await expect(page.getByText(/Warning:/)).toHaveCount(0); @@ -3254,8 +3254,8 @@ test('a hinge whose knuckles start above the bed carries them on webs at 45°: o const findings = page.getByTestId('print-check-panel'); await expect(findings).toBeVisible(); await page.waitForTimeout(1500); - // What is left hanging is the pin where it bridges the gaps between the knuckles. - await expect(findings.locator('[data-kind="overhang"]')).toContainText('25 mm²'); + // Nothing is left hanging: the webs pass under the whole barrel, and the pin is flat where it bridges 6.5 mm from knuckle to knuckle. + await expect(findings.locator('[data-kind="overhang"]')).toHaveCount(0); await expect(findings.locator('[data-kind="blocked"], [data-kind="overlap"], [data-kind="thinWall"]')).toHaveCount(0); await page.screenshot({ path: test.info().outputPath('case-hinge.png') }); } finally { @@ -3284,7 +3284,7 @@ test('the assistant builds a print-in-place hinge with one call: one piece per b await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a hinged flap'); await page.getByRole('button', { name: 'Send' }).click(); // PartBuddy's answer to the tool: the gap is the nozzle of the printer in use, and the limits are where the plates meet. - await expect(page.getByText(/Built the hinge "Flap hinge" between Base \(stays\) and Flap \(turns\): 3 knuckles, barrel 6 mm, pin 3 mm, gap 0\.4 mm everywhere\. It turns from -145° to 67°/)).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText(/Built the hinge "Flap hinge" between Base \(stays\) and Flap \(turns\): 5 knuckles, barrel 6 mm, pin 3 mm, gap 0\.4 mm everywhere\. It turns from -145° to 67°/)).toBeVisible({ timeout: 60_000 }); // The line was measured against both plates on trial, and the answer says how deep the knuckles' rooms go into each. // The line lies 2 mm from each plate, and a room is 3.4 mm about it. const rooms = page.getByText(/The knuckles' rooms go 1\.4 mm into Base and 1\.4 mm into Flap/); @@ -3733,7 +3733,7 @@ test('a hinge whose line is out of reach of the bodies is taken back, with where await expect(page.getByTestId('connection-status')).toHaveAttribute('data-engine', 'headless', { timeout: 30_000 }); await page.getByRole('textbox', { name: 'Message to the assistant' }).fill('Build a flap out of reach'); await page.getByRole('button', { name: 'Send' }).click(); - await expect(page.getByText(/Not built: 3 piece\(s\) did not touch their body and stand apart from it\. Put the hinge line within 2\.5 mm of both bodies .* Everything was taken back./)).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText(/Not built: 5 piece\(s\) did not touch their body and stand apart from it\. Put the hinge line within 2\.5 mm of both bodies .* Everything was taken back./)).toBeVisible({ timeout: 60_000 }); // The two plates are as they were: no knuckle, no cut, no joint. await expect(page.getByTestId('mesh-summary')).toContainText('2 bodies · 12 faces', { timeout: 30_000 }); await expect(page.getByTestId('joints')).toHaveCount(0); diff --git a/apps/desktop/src/main/agents/mechanismTool.ts b/apps/desktop/src/main/agents/mechanismTool.ts index e90c24c8..66a61a2e 100644 --- a/apps/desktop/src/main/agents/mechanismTool.ts +++ b/apps/desktop/src/main/agents/mechanismTool.ts @@ -1,4 +1,4 @@ -import { LID, LIP, hingeProblem, hingeSteps, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, pinDiameter, screwLength, screwProblem, screwSteps, slideProblem, slideRoof, slideSteps, snapProblem, snapSteps, latchProblem, latchReach, latchSteps, LATCH, pegLength, pegProblem, pegSteps, PEG, SNAP, type HingeSpec, type HingeSide, type LatchSpec, type LidSpec, type LipSpec, type PegSpec, type ScrewSpec, type SlideSpec, type SnapSpec } from '../../shared/mechanisms'; +import { LID, LIP, WEB, hangs, hingeProblem, hingeSteps, pinBridge, webFoot, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, pinDiameter, screwLength, screwProblem, screwSteps, slideProblem, slideRoof, slideSteps, snapProblem, snapSteps, latchProblem, latchReach, latchSteps, LATCH, pegLength, pegProblem, pegSteps, PEG, SNAP, type HingeSpec, type HingeSide, type LatchSpec, type LidSpec, type LipSpec, type PegSpec, type ScrewSpec, type SlideSpec, type SnapSpec } from '../../shared/mechanisms'; import { BLOCK_SIZES, INSERT, type BlockSize } from '../../shared/screwSizes'; import { componentOf } from '../solidcraft/actions'; import type { ToolResult, UpstreamTool, UpstreamTools } from './gateProxy'; @@ -18,8 +18,8 @@ const mechanismTool: UpstreamTool = { name: MECHANISM_TOOL, description: 'Build a mechanism between two bodies that exist, with the same gap everywhere; the answer says how it prints. ' + - 'hinge: knuckles in turn on both bodies and a pin through them. Give the hinge line (from, to) in the gap between the bodies, level with the bed and half the barrel diameter above it when the part prints lying flat; ' + - 'the line must lie closer to both bodies than the barrel radius less 0.5 mm, so the knuckles grow out of them; PartBuddy cuts each knuckle\'s room in the other body itself, a gap wider than the barrel, and carries knuckles that start above the bed on webs at 45° down to their own walls: add no supports of your own. Give wall, the thickness of the walls along the line: a room that would leave less than two lines of a wall behind it is refused, with how far to move the line. ' + + 'hinge: knuckles in turn on both bodies and a pin through them, printed in place as one piece. Give the hinge line (from, to) in the gap between the bodies, level with the bed and half the barrel diameter above it when the part prints lying flat; ' + + 'the line must lie closer to both bodies than the barrel radius less 0.5 mm, so the knuckles grow out of them; PartBuddy cuts each knuckle\'s room in the other body itself, a gap wider than the barrel, and carries knuckles that start above the bed on webs at 45° down to their own walls, where the wall reaches about one and a quarter barrel diameters below the line (the answer says where it does not, and what then needs support): add no supports of your own. Give wall, the thickness of the walls along the line: a room that would leave less than two lines of a wall behind it is refused, with how far to move the line. ' + 'slide: a dovetail rail on the fixed body and its groove in the moving body, which lies over the rail a gap above the face. Give the middle line of the rail (from, to) on that face and the face normal (up). The two print apart and the moving body is pushed onto the rail from an end. Where the moving body stands high enough over that face (8.8 mm at the defaults; the answer names it) the groove gets a roof at 45° and prints as it stands; otherwise its ceiling is flat and the answer says how to lay the part. ' + 'PartBuddy also joins the two with a joint, finds how far it moves before the bodies meet (a slide: while the moving body stays on its rail) and sets that as its limits; the user then moves it with a slider in the view. ' + 'closure: a snap closure between a lid and its box, nothing standing out: a lip inside the lid that goes into the box along its wall and centres the lid, a bead that snaps into a rille of the box\'s wall, and a notch for a fingernail. ' + @@ -120,8 +120,9 @@ export function hingeOf(args: Record, nozzle: number): HingeSpe const spec: HingeSpec = { ...rest, diameter: typeof args.diameter === 'number' ? args.diameter : 6, - // Knuckles of at most 10 mm where the line allows: inside each one the pin bridges from neighbour to neighbour. - knuckles: typeof args.knuckles === 'number' ? args.knuckles : ([3, 5, 7, 9].find((count) => length / count <= 10) ?? 9), + // Knuckles of at most 10 mm where the line allows. Lying, it is the pin's bridge that counts: inside each knuckle of the + // moving body it bridges from one fixed knuckle to the next, a gap further on each side than the knuckle is long. + knuckles: typeof args.knuckles === 'number' ? args.knuckles : ([3, 5, 7, 9].find((count) => (Math.abs(rest.to[2] - rest.from[2]) < 1e-6 ? (length + (count + 1) * rest.gap) / count : length / count) <= 10) ?? 9), }; return hingeProblem(spec) ?? spec; } @@ -416,6 +417,10 @@ const PROBE = 'PartBuddy probe'; * reach it). A wall thicker than that by less than two lines is left as a skin behind the room (practice run * 2026-11-12: 0.8 mm of a 1.8 mm wall). Measured on trial with a thin rod on the line: a step that must fail ends the * batch, and SolidCraft takes the rod back. Null when it could not be measured. + * + * `support` names the bed and, for the webs of a hinge that hangs, each body whose wall reaches down to where its web + * would meet it: tried with a second rod inside that wall, just under the web's foot. A web drawn to the wall's plane + * where the wall had ended hung 4.6 mm in the air under a lid plate (audit 2026-11-12, N-mechanik-5). */ export async function roomDepths(upstream: UpstreamTools, spec: HingeSpec, callId: string | undefined): Promise<{ fixed: number; moving: number; support?: NonNullable } | null> { try { @@ -463,14 +468,59 @@ export async function roomDepths(upstream: UpstreamTools, spec: HingeSpec, callI return { toward: [off[0] / level, off[1] / level, 0], distance: Math.round(level * 100) / 100 }; }; const lows = (((reply.results?.[2] as { bodies?: { bbox?: { min?: unknown } }[] } | undefined)?.bodies ?? []) as { bbox?: { min?: unknown } }[]).flatMap((body) => vec3(body.bbox?.min)?.[2] ?? []); - const [fixed, moving] = [side(spec.fixed), side(spec.moving)]; - const support = fixed && moving && lows.length > 0 ? { bed: Math.min(...lows), fixed, moving } : undefined; - return { fixed: depth(spec.fixed), moving: depth(spec.moving), ...(support ? { support } : {}) }; + if (lows.length === 0) return { fixed: depth(spec.fixed), moving: depth(spec.moving) }; + const bed = Math.min(...lows); + const sides = hangs(spec, bed) ? await carried(upstream, spec, bed, { fixed: side(spec.fixed), moving: side(spec.moving) }, callId) : {}; + return { fixed: depth(spec.fixed), moving: depth(spec.moving), support: { bed, ...sides } }; } catch { return null; } } +/** + * Of the walls beside a hinge line, those that can carry a web: the web ends on the bed, or a rod inside the wall just + * under the web's foot lies in the body along the whole line. Measured on trial like the line itself. + */ +async function carried(upstream: UpstreamTools, spec: HingeSpec, bed: number, sides: { fixed: HingeSide | null; moving: HingeSide | null }, callId: string | undefined): Promise<{ fixed?: HingeSide; moving?: HingeSide }> { + const axis = spec.to.map((value, i) => value - (spec.from[i] ?? 0)); + const length = Math.hypot(...axis); + const rod = 0.2; + const tried = (['fixed', 'moving'] as const).flatMap((key) => { + const wall = sides[key]; + if (!wall) return []; + const drop = webFoot(spec.diameter, wall.distance) + rod; + // null: the web ends on the bed before it comes to the wall's foot. + const base = spec.from[2] - drop <= bed ? null : spec.from.map((value, i) => value + (wall.toward[i] ?? 0) * (wall.distance + WEB.into / 2) - (i === 2 ? drop : 0)); + return [{ key, wall, base, name: `${PROBE} ${key}` }]; + }); + const rods = tried.filter((entry) => entry.base !== null); + let overlaps: { a?: unknown; b?: unknown; volume_mm3?: unknown }[] = []; + if (rods.length > 0) { + const answer = await upstream.callTool( + 'batch', + { + commands: [ + ...rods.map((entry) => ({ command: 'solid.cylinder', params: { diameter: rod, height: length, base: entry.base, axis, operation: 'new', body_name: entry.name } })), + { command: 'inspect.clearance', params: { bodies: rods.map((entry) => entry.name), against: [spec.fixed, spec.moving], min_volume: 0 } }, + { command: 'sketch.inspect', params: { sketch: 2147483647 } }, + ], + rollback: true, + }, + callId, + ); + const text = answer.content.find((part) => part.type === 'text')?.text; + const found = ((JSON.parse(typeof text === 'string' ? text : '') as { results?: unknown[] }).results?.[rods.length] as { overlaps?: unknown } | undefined)?.overlaps; + if (Array.isArray(found)) overlaps = found as typeof overlaps; + } + const whole = (Math.PI * rod * rod * length) / 4; + const out: { fixed?: HingeSide; moving?: HingeSide } = {}; + for (const entry of tried) { + const inside = overlaps.find((pair) => [pair.a, pair.b].includes(entry.name) && [pair.a, pair.b].includes(spec[entry.key]))?.volume_mm3; + if (entry.base === null || (typeof inside === 'number' && inside >= 0.9 * whole)) out[entry.key] = entry.wall; + } + return out; +} + /** What is wrong with the walls behind the knuckles' rooms, when the model said how thick they are; null when they print. */ export function skinProblem(depths: { fixed: number; moving: number }, spec: { fixed: string; moving: string }, wall: number, nozzle: number): string | null { const least = Math.round(nozzle * 2 * 1.125 * 100) / 100; @@ -486,6 +536,27 @@ export function skinProblem(depths: { fixed: number; moving: number }, spec: { f const MOVE = 'The user moves it with the slider "Moving parts" in the view.'; +/** + * What a lying hinge that hangs above the bed needs to print, from what was measured and built: said only as far as it + * holds. "Stand on webs …, so they print without support" stood over 90 to 619 mm² the print check still found + * (audit 2026-11-12, N-mechanik-4), and over webs that had no wall under them (N-mechanik-5). + */ +function hingePrint(spec: HingeSpec, depths: Awaited>): string { + if (Math.abs(spec.to[2] - spec.from[2]) > 1e-6) return ''; + const bridge = pinBridge(spec); + const sags = bridge > 10 ? ` The pin bridges ${String(Math.round(bridge * 10) / 10)} mm inside each knuckle of ${spec.moving}, more than prints clean (10 mm): use more knuckles.` : ''; + const support = depths?.support; + if (!support) return ` PartBuddy could not measure the bed and the walls beside the line, so no knuckle got a web: those that start above the bed need support.${sags}`; + if (!hangs(spec, support.bed)) return sags; + const count = { fixed: (spec.knuckles + 1) / 2, moving: (spec.knuckles - 1) / 2 }; + const bare = (['fixed', 'moving'] as const).filter((key) => !support[key]); + const webs = `stand on webs at 45° that run from under the barrel down to their own wall`; + if (bare.length === 0) return ` All ${String(spec.knuckles)} knuckles start above the bed and ${webs}, and the pin is flat underneath where it bridges from knuckle to knuckle${sags ? '.' : ', so the hinge prints as it stands, without support.'}${sags}`; + const hung = bare.map((key) => `the ${String(count[key])} of ${spec[key]}`).join(' and '); + const held = bare.length === 1 ? ` The ${String(count[bare[0] === 'fixed' ? 'moving' : 'fixed'])} knuckles of ${spec[bare[0] === 'fixed' ? 'moving' : 'fixed']} ${webs}.` : ''; + return ` The knuckles start above the bed, and ${hung} got no web: a web meets its wall ${String(webFoot(spec.diameter, spec.diameter / 2 - 0.5))} mm below the line at the most, and there is no wall there. They need support, or a wall that reaches down that far before the hinge is built.${held}${sags}`; +} + /** * Builds the rail and the groove of a guide or a dovetail. The moving body is measured first: where it stands high * enough over the rail's face, the groove gets its roof; where not, the ceiling stays flat and `print` says so, with @@ -581,16 +652,11 @@ async function mechanism(site: Site, args: Record, nozzle: numb if (typeof spec === 'string') throw new Error(spec); const reach = String(Math.round((spec.diameter / 2 - 0.5) * 10) / 10); let depths: Awaited> = null; - let webs = 0; const least = String(Math.round(nozzle * 2 * 1.125 * 100) / 100); const rooms = () => depths ? ` The knuckles' rooms go ${String(depths.fixed)} mm into ${spec.fixed} and ${String(depths.moving)} mm into ${spec.moving}, measured from the face nearest the line: a wall there must be ${least} mm thicker than that, or thinner than it (the room then opens through the wall); anything between is left as a thin skin.` : ''; const warnings = await build(site, spec, // With the walls and the bed measured, knuckles that start in the air get their webs. - () => { - const steps = hingeSteps(depths?.support ? { ...spec, support: depths.support } : spec); - webs = steps.filter((step) => step.command === 'solid.extrude' && String(step.params.name).endsWith('◣')).length; - return steps; - }, + () => hingeSteps(depths?.support ? { ...spec, support: depths.support } : spec), `Put the hinge line within ${reach} mm of both bodies (the barrel is ${String(spec.diameter)} mm, so it must overlap each by at least 0.5 mm). PartBuddy cuts each knuckle's room in the other body itself.`, false, async () => { @@ -601,12 +667,13 @@ async function mechanism(site: Site, args: Record, nozzle: numb 'revolute', ); const range = await join(run, spec, 'revolute', [-180, 180], 1); - if (typeof range === 'string') return `Built the hinge "${spec.name}" between ${spec.fixed} (stays) and ${spec.moving} (turns) and joined them, but ${unswept(range)}${warnings}`; + const print = hingePrint(spec, depths); + if (typeof range === 'string') return `Built the hinge "${spec.name}" between ${spec.fixed} (stays) and ${spec.moving} (turns) and joined them, but ${unswept(range)}${print}${warnings}`; const [min, max] = range; return ( `Built the hinge "${spec.name}" between ${spec.fixed} (stays) and ${spec.moving} (turns): ${String(spec.knuckles)} knuckles, barrel ${String(spec.diameter)} mm, pin ${String(pinDiameter(spec.diameter))} mm, ` + `gap ${String(spec.gap)} mm everywhere. It turns from ${String(min)}° to ${String(max)}° before the bodies meet, and those are its limits now; 0° is as designed. ` + - `${MOVE} If it should open further, make room where the bodies meet and set joint.limits again.${webs > 0 ? ` ${String(webs)} knuckles start above the bed and stand on webs at 45° down to their own walls, so they print without support.` : ''}${rooms()}${warnings}` + `${MOVE} If it should open further, make room where the bodies meet and set joint.limits again.${print}${rooms()}${warnings}` ); } if (args.kind === 'slide') { diff --git a/apps/desktop/src/shared/mechanisms.ts b/apps/desktop/src/shared/mechanisms.ts index 1c280155..453b654a 100644 --- a/apps/desktop/src/shared/mechanisms.ts +++ b/apps/desktop/src/shared/mechanisms.ts @@ -27,10 +27,11 @@ export interface HingeSpec { /** The hinge's name, in the user's language; the features carry it. */ name: string; /** - * Where a lying hinge hangs: the height (z) of the bed, and for each body the level way from the line to its wall - * and how far that is. With it every knuckle that starts in the air gets a web at 45° down to its own wall. + * Where a lying hinge hangs: the height (z) of the bed, and for each body whose wall can carry a web (it reaches down + * to `webFoot`, or the web ends on the bed) the level way from the line to that wall and how far that is. With it + * every knuckle of such a body that starts in the air gets a web at 45° down to its own wall. */ - support?: { bed: number; fixed: HingeSide; moving: HingeSide }; + support?: { bed: number; fixed?: HingeSide; moving?: HingeSide }; } export interface HingeSide { @@ -105,40 +106,82 @@ export function hingeSteps(spec: HingeSpec): Step[] { cylinder(pin + 2 * spec.gap, -spec.gap, length + spec.gap, 'cut', spec.moving, `${spec.name} ○`), ...boreRoof(spec, axis, at(-spec.gap), length + 2 * spec.gap, pin / 2 + spec.gap), cylinder(pin, 0, length, 'join', spec.fixed, `${spec.name} ●`), + ...spans.flatMap((span) => (span.owner === spec.moving ? pinFlat(spec, axis, at, span, pin / 2) : [])), + ]; +} + +/** How far the pin of a lying hinge bridges inside a knuckle of the moving body, from one fixed knuckle to the next (mm). */ +export function pinBridge(spec: Pick): number { + const length = Math.hypot(...spec.to.map((value, i) => value - (spec.from[i] ?? 0))); + return round((length + (spec.knuckles + 1) * spec.gap) / spec.knuckles); +} + +/** + * The flat under the pin of a lying hinge, inside one knuckle of the moving body: there the pin has nothing below it + * but the gap, and its round underside starts in the air (audit 2026-11-12: 25 mm² of overhang per knuckle left on every + * hinge). Cut off where it leans 45°, what is left below is a level strip from one fixed knuckle to the next, a bridge. + */ +function pinFlat(spec: HingeSpec, axis: Vec3, at: (distance: number) => Vec3, span: { start: number; end: number; label: string }, radius: number): Step[] { + if (Math.abs(axis[2]) > 1e-6) return []; + const top = -radius / Math.SQRT2; + // Down to inside the bore's gap: below the pin the fixed body has nothing there. + const points: [number, number][] = [[-radius, top], [radius, top], [radius, -radius - spec.gap / 2], [-radius, -radius - spec.gap / 2]]; + const name = `${span.label} ▬`; + return [ + { command: 'sketch.create', params: { plane: { origin: at(span.start - spec.gap), x_dir: cross([0, 0, 1], axis).map(round), y_dir: [0, 0, 1] }, project_edges: false, name } }, + { command: 'sketch.line', params: { points: points.map(([x, y]) => [round(x), round(y)]), closed: true } }, + { command: 'solid.extrude', params: { distance: `${String(round(span.end - span.start + 2 * spec.gap))} mm`, operation: 'cut', targets: [spec.fixed], name } }, ]; } /** A web stands this far in its wall, and one lower than this is left out (mm). */ -const WEB = { into: 0.5, least: 0.3 }; +export const WEB = { into: 0.5, least: 0.3 }; + +/** How far below the hinge line the web of a knuckle meets its wall, `distance` from the line (mm): the wall must reach down there. */ +export function webFoot(diameter: number, distance: number): number { + return round((diameter / 2) * Math.SQRT2 + distance); +} + +/** Whether the knuckles of a lying hinge start above the bed, so that each needs a web under it. */ +export function hangs(spec: Pick, bed: number): boolean { + return Math.abs(spec.to[2] - spec.from[2]) < 1e-6 && spec.from[2] - spec.diameter / 2 - bed >= WEB.least; +} /** * The web under one knuckle of a lying hinge that starts above the bed: the round underside of a barrel begins in the * air (practice runs 2026-11-11 and -12: the assistant added such webs by hand every time). In the plane square to - * the line, x towards the knuckle's own wall and y up from the axis: from under the barrel a face at 45° runs down to - * the wall, or to the bed where that comes first. Towards the other body it ends a gap before that body's face, at - * most where the barrel itself leans 45°; what is left of the round there is a small overhang next to material. + * the line, x towards the knuckle's own wall and y up from the axis: from where the barrel leans 45° on the far side, + * over the other body, a face at 45° runs down under the barrel to the knuckle's own wall, or to the bed where that + * comes first. So nothing of the round is left hanging: a web that ended a gap before the other body left the far half + * of every knuckle as an overhang of up to 89° (audit 2026-11-12, N-mechanik-4). Where the web passes through the + * other body, that body is cut back by the same profile a gap further out, as it is for the barrel itself. + * shortcut: a wall that ends above the web's foot gets no web at all, though a shorter one from further under the + * barrel would carry most of it; add that when hinges on shallow walls turn up. */ -function web(spec: HingeSpec, axis: Vec3, at: (distance: number) => Vec3, span: { start: number; end: number; owner: string; label: string }): Step[] { +function web(spec: HingeSpec, axis: Vec3, at: (distance: number) => Vec3, span: { start: number; end: number; owner: string; other: string; label: string }): Step[] { const support = spec.support; - if (!support || Math.abs(axis[2]) > 1e-6) return []; - const [own, other] = span.owner === spec.fixed ? [support.fixed, support.moving] : [support.moving, support.fixed]; - const radius = spec.diameter / 2; - const x0 = Math.max(-radius / Math.SQRT2, spec.gap - other.distance); - if (x0 >= radius || own.distance - x0 < WEB.least) return []; - const under = -Math.sqrt(radius * radius - x0 * x0); + const own = span.owner === spec.fixed ? support?.fixed : support?.moving; + if (!support || !own || !hangs(spec, support.bed)) return []; const bed = support.bed - spec.from[2]; - // A barrel that lies on the bed is carried by it. - if (-radius - bed < WEB.least) return []; const wall = own.distance + WEB.into; - const foot = under - (own.distance - x0); - const points: [number, number][] = foot < bed ? [[x0, 0], [x0, under], [x0 + (under - bed), bed], [wall, bed], [wall, 0]] : [[x0, 0], [x0, under], [own.distance, foot], [wall, foot], [wall, 0]]; + const room = Math.max(0, own.distance - spec.gap); + // The face at 45° that touches a barrel of this radius, from where it touches down to x. + const slope = (radius: number, x: number): [number, number][] => [[-radius / Math.SQRT2, 0], [-radius / Math.SQRT2, -radius / Math.SQRT2], [x, -radius * Math.SQRT2 - x]]; + const radius = spec.diameter / 2; + const foot = -webFoot(spec.diameter, own.distance); + const points: [number, number][] = foot < bed ? [...slope(radius, -radius * Math.SQRT2 - bed), [wall, bed], [wall, 0]] : [...slope(radius, own.distance), [wall, foot], [wall, 0]]; // x × y of the sketch is the way the profile is extruded: start at the end of the knuckle from which that runs along it. const forward = cross(own.toward, [0, 0, 1]).reduce((sum, value, i) => sum + value * (axis[i] ?? 0), 0) > 0; - const name = `${span.label} ◣`; + const profile = (outline: [number, number][], start: number, end: number, operation: 'cut' | 'join', target: string, name: string): Step[] => [ + { command: 'sketch.create', params: { plane: { origin: at(forward ? start : end), x_dir: own.toward.map((value) => round(value) + 0), y_dir: [0, 0, 1] }, project_edges: false, name } }, + { command: 'sketch.line', params: { points: outline.map(([x, y]) => [round(x), round(y)]), closed: true } }, + { command: 'solid.extrude', params: { distance: `${String(round(end - start))} mm`, operation, targets: [target], name } }, + ]; return [ - { command: 'sketch.create', params: { plane: { origin: at(forward ? span.start : span.end), x_dir: own.toward.map((value) => round(value) + 0), y_dir: [0, 0, 1] }, project_edges: false, name } }, - { command: 'sketch.line', params: { points: points.map(([x, y]) => [round(x), round(y)]), closed: true } }, - { command: 'solid.extrude', params: { distance: `${String(round(span.end - span.start))} mm`, operation: 'join', targets: [span.owner], name } }, + // As long as the knuckle's room, and up to a gap before the knuckle's own wall: a line that lies inside the other + // body's wall has that wall on both sides of it. + ...profile([...slope(radius + spec.gap, room), [room, 0]], span.start - spec.gap, span.end + spec.gap, 'cut', span.other, `${span.label} ◤`), + ...profile(points, span.start, span.end, 'join', span.owner, `${span.label} ◣`), ]; } diff --git a/apps/desktop/src/shared/templates.ts b/apps/desktop/src/shared/templates.ts index bcb72fa5..e0c404d8 100644 --- a/apps/desktop/src/shared/templates.ts +++ b/apps/desktop/src/shared/templates.ts @@ -90,17 +90,21 @@ function hingedBox(): Step[] { name: 'Closure', }), // The hinge of the mechanism tool, built at the starting sizes, then tied to the parameters. - ...hingeSteps({ fixed: 'Box', moving: 'Lid', from: [start, between / 2, height], to: [start + hinge, between / 2, height], diameter, gap, knuckles, name: 'Hinge' }).map((step) => { - // The bore's roof is drawn in the world's own y and z and moved along x, so it follows the sizes as the cylinders do. - if (step.command === 'sketch.create') return { ...step, params: { ...step.params, plane: 'YZ', offset: `length / 2 + ${String(start - gap - length / 2)} mm` } }; - if (step.command === 'sketch.line') return { ...step, params: { ...step.params, points: (step.params.points as [number, number][]).map(([across, up]) => [`${String(between / 2 + across)} mm`, `height + ${String(up)} mm`]) } }; + ...hingeSteps({ fixed: 'Box', moving: 'Lid', from: [start, between / 2, height], to: [start + hinge, between / 2, height], diameter, gap, knuckles, name: 'Hinge' }).map((step, i, steps) => { + // The profiles (the bore's roof, the pin's flat, and webs where the hinge has them) are drawn in the world's own y and z and + // moved along x, so they follow the sizes as the cylinders do. One drawn looking back along x is turned round: + // it starts where its extrusion ended. + const drawn = (steps[step.command === 'sketch.create' ? i : i - 1]?.params.plane ?? {}) as { origin?: Vec3; x_dir?: Vec3 }; + const back = (drawn.x_dir?.[1] ?? 1) < 0; + if (step.command === 'sketch.create') return { ...step, params: { ...step.params, plane: 'YZ', offset: `length / 2 + ${String(Math.round(((drawn.origin?.[0] ?? 0) - (back ? parseFloat(String(steps[i + 2]?.params.distance)) : 0) - length / 2) * 1000) / 1000)} mm` } }; + if (step.command === 'sketch.line') return { ...step, params: { ...step.params, points: (step.params.points as [number, number][]).map(([across, up]) => [`${String(between / 2 + (back ? -across : across))} mm`, `height + ${String(up)} mm`]) } }; if (step.command !== 'solid.cylinder') return step; const [x, y] = step.params.base as Vec3; return { ...step, params: { ...step.params, base: [`length / 2 + ${String(x - length / 2)} mm`, y, 'height'] } }; }), components('Box', 'Lid'), - // It closes all the way: the bead pressing past the box's rim on the last degrees is the snap. The joint is kept under the pin and on the bore round it, inside the lid's first knuckle: faces no wall or lip cuts, whatever the sizes. - joint('revolute', 'Box', 'Lid', { fixed: { circle: [far, between / 2, height - pin / 2], x: [0, 0, 1] }, moving: { circle: [far, between / 2, height - pin / 2 - gap], x: [0, 0, 1] } }, { flip: true, offset: -gap, limits: [[0, 180]] }, 'Hinge'), + // It closes all the way: the bead pressing past the box's rim on the last degrees is the snap. The joint is kept on top of the pin (underneath it is flat) and on the bore under it, inside the lid's first knuckle: faces no wall or lip cuts, whatever the sizes. + joint('revolute', 'Box', 'Lid', { fixed: { circle: [far, between / 2, height + pin / 2], x: [0, 0, 1] }, moving: { circle: [far, between / 2, height - pin / 2 - gap], x: [0, 0, 1] } }, { flip: true, offset: -gap, limits: [[0, 180]] }, 'Hinge'), ]; } @@ -385,7 +389,7 @@ const GERMAN_FEATURES: Record = { /** "Ear slot 2 end 1" → "Laschenschlitz 2 Ende 1": the base from the list, numbers and a mechanism's mark (·, ○, ● …) kept. */ export function germanFeatureName(name: string): string { - const match = /^(.*?)((?: \d+)?)((?: end \d+)?)((?: [·○●◢◆◠△])?)$/.exec(name); + const match = /^(.*?)((?: \d+)?)((?: end \d+)?)((?: [·○●◢◆◠△◣◤▬])?)$/.exec(name); const base = match?.[1] ?? name; const german = GERMAN_FEATURES[base]; if (german === undefined) return name; diff --git a/apps/desktop/test/mechanism.test.ts b/apps/desktop/test/mechanism.test.ts index e7f199da..9c279426 100644 --- a/apps/desktop/test/mechanism.test.ts +++ b/apps/desktop/test/mechanism.test.ts @@ -4,7 +4,7 @@ import type { UpstreamTools } from '../src/main/agents/gateProxy'; import { activated, hingeOf, skinProblem, latchOf, lidOf, lipOf, occurrenceOf, pegOf, railTravel, screwOf, slideOf, snapOf, withMechanismTool } from '../src/main/agents/mechanismTool'; import { decide } from '../src/main/agents/policy'; import { describeToolCall } from '../src/shared/stepPhrases'; -import { hingeProblem, hingeSteps, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, screwLength, screwProblem, screwSteps, slideProblem, slideRoof, slideSteps, snapProblem, snapSteps, latchReach, latchSteps, LATCH, pegLength, pegSteps, PEG, SNAP, type HingeSpec, type LidSpec, type LipSpec, type ScrewSpec, type SlideSpec } from '../src/shared/mechanisms'; +import { hingeProblem, hingeSteps, pinBridge, webFoot, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, screwLength, screwProblem, screwSteps, slideProblem, slideRoof, slideSteps, snapProblem, snapSteps, latchReach, latchSteps, LATCH, pegLength, pegSteps, PEG, SNAP, type HingeSpec, type LidSpec, type LipSpec, type ScrewSpec, type SlideSpec } from '../src/shared/mechanisms'; const spec: HingeSpec = { fixed: 'Base', moving: 'Flap', from: [42, 0, 3], to: [42, 30, 3], diameter: 6, gap: 0.4, knuckles: 3, name: 'Hinge' }; @@ -14,11 +14,17 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { const steps = all.filter((step) => step.command === 'solid.cylinder'); // Lying, the bore gets a roof: flanks at 45° from the bore (radius 1.9 mm) and a flat 1.05 radii up, cut through the flap along the whole line. const roof = all.filter((step) => step.command !== 'solid.cylinder'); - expect(roof.map((step) => step.command)).toEqual(['sketch.create', 'sketch.line', 'solid.extrude']); + expect(roof.map((step) => step.command)).toEqual(['sketch.create', 'sketch.line', 'solid.extrude', 'sketch.create', 'sketch.line', 'solid.extrude']); expect(roof[0]?.params.plane).toEqual({ origin: [42, -0.4, 3], x_dir: [-1, 0, 0], y_dir: [0, 0, 1] }); expect(roof[1]?.params.points).toEqual([[-1.344, 1.344], [1.344, 1.344], [0.692, 1.995], [-0.692, 1.995]]); expect(roof[2]?.params).toMatchObject({ distance: '30.8 mm', operation: 'cut', targets: ['Flap'], name: 'Hinge △' }); - // Upright, a round bore prints as it is. + // Lying, the pin is flat underneath inside the flap's knuckle, where it has only the gap below it: cut off where it leans 45° + // (1.061 mm under the axis of a 3 mm pin), from one of the base's knuckles to the other, 10.533 mm. + expect(roof[3]?.params.plane).toEqual({ origin: [42, 9.733, 3], x_dir: [-1, 0, 0], y_dir: [0, 0, 1] }); + expect(roof[4]?.params.points).toEqual([[-1.5, -1.061], [1.5, -1.061], [1.5, -1.7], [-1.5, -1.7]]); + expect(roof[5]?.params).toMatchObject({ distance: '10.533 mm', operation: 'cut', targets: ['Base'], name: 'Hinge 2 ▬' }); + expect(pinBridge(spec)).toBe(10.533); + // Upright, a round bore prints as it is, and so does the pin. expect(hingeSteps({ ...spec, from: [0, 0, 0], to: [0, 0, 30] }).every((step) => step.command === 'solid.cylinder')).toBe(true); expect(steps.map((step) => [step.params.operation, (step.params.targets as string[])[0], step.params.diameter, step.params.height])).toEqual([ // Room for the knuckles: a gap wider all round and a gap longer at both ends, past the ends of the hinge line too. @@ -54,7 +60,11 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { const args = { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [0, 0, 3], to: [0, 60, 3] }; // 60 mm of line: seven knuckles, so none is longer than 10 mm. expect(hingeOf(args, 0.4)).toMatchObject({ diameter: 6, gap: 0.4, knuckles: 7, name: 'Hinge' }); - expect(hingeOf({ ...args, to: [0, 30, 3] }, 0.4)).toMatchObject({ knuckles: 3 }); + // 30 mm of line: three knuckles would have the pin bridge 10.5 mm inside the middle one (9.7 mm and a gap on each side), so five. + expect(hingeOf({ ...args, to: [0, 30, 3] }, 0.4)).toMatchObject({ knuckles: 5 }); + expect(hingeOf({ ...args, to: [0, 28, 3] }, 0.4)).toMatchObject({ knuckles: 3 }); + // Upright, the pin stands and bridges nothing: three knuckles of 9.7 mm. + expect(hingeOf({ ...args, to: [0, 0, 33] }, 0.4)).toMatchObject({ knuckles: 3 }); expect(hingeOf({ ...args, to: [0, 200, 3] }, 0.4)).toMatchObject({ knuckles: 9 }); expect(hingeOf(args, 0.2)).toMatchObject({ gap: 0.3 }); expect(hingeOf({ ...args, to: [0, 0, 3] }, 0.4)).toBe('from and to must be two points'); @@ -107,13 +117,13 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { const built = await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42, 0, 3], to: [42, 30, 3], name: 'Lid hinge' }); expect(built.isError).toBeUndefined(); expect(built.content[0]).toMatchObject({ - text: expect.stringContaining('Built the hinge "Lid hinge" between Base (stays) and Flap (turns): 3 knuckles, barrel 6 mm, pin 3 mm, gap 0.4 mm everywhere. It turns from -145° to 67° before the bodies meet') as string, + text: expect.stringContaining('Built the hinge "Lid hinge" between Base (stays) and Flap (turns): 5 knuckles, barrel 6 mm, pin 3 mm, gap 0.4 mm everywhere. It turns from -145° to 67° before the bodies meet') as string, }); expect(batches.map((batch) => batch.map((step) => step.command).join(' '))).toEqual([ 'component.list joint.list', // Where the line lies, measured on trial with a rod that is taken back. 'solid.cylinder inspect.clearance inspect.measure sketch.inspect', - [...Array.from({ length: 7 }, () => 'solid.cylinder'), 'sketch.create', 'sketch.line', 'solid.extrude', 'solid.cylinder'].join(' '), + [...Array.from({ length: 11 }, () => 'solid.cylinder'), 'sketch.create', 'sketch.line', 'solid.extrude', 'solid.cylinder', ...Array.from({ length: 2 }, () => ['sketch.create', 'sketch.line', 'solid.extrude']).flat()].join(' '), 'inspect.interference', 'component.list', 'component.from_bodies component.list', @@ -124,22 +134,33 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { expect(batches[6]?.[0]?.params).toMatchObject({ type: 'revolute', a: 'Base:1', b: 'Flap:1', at: { occurrence: 'Base:1', point: [42, 15, 3], z: [0, 30, 0] }, name: 'Lid hinge' }); expect(batches[8]?.[0]?.params).toEqual({ joint: 'Lid hinge', min: -145, max: 67 }); - // Webs: with the line 16 mm above the bed and 2.5 mm from either wall, every knuckle gets a profile from under the barrel (a gap - // short of the other wall) at 45° down to its own wall, 0.5 mm into it; on the bed (the line a radius above it) none is needed. + // Webs: with the line 16 mm above the bed and 2.5 mm from either wall, every knuckle gets a profile from where the barrel leans + // 45° on the far side (2.121 mm from the axis) at 45° down to its own wall, 0.5 mm into it; on the bed (the line a radius above it) none is needed. const sides = { fixed: { toward: [-1, 0, 0] as [number, number, number], distance: 2.5 }, moving: { toward: [1, 0, 0] as [number, number, number], distance: 2.5 } }; const hung = hingeSteps({ ...spec, from: [42, 0, 16], to: [42, 30, 16], support: { bed: 0, ...sides } }); const webs = hung.filter((step) => String(step.params.name).endsWith('◣')); expect(webs.map((step) => step.command)).toEqual(Array.from({ length: 3 }, () => ['sketch.create', 'solid.extrude']).flat()); const profiles = hung.filter((step) => step.command === 'sketch.line' && (step.params.points as number[][]).length === 5); expect(profiles).toHaveLength(3); - expect(profiles[0]?.params.points).toEqual([[-2.1, 0], [-2.1, -2.142], [2.5, -6.742], [3, -6.742], [3, 0]]); + expect(profiles[0]?.params.points).toEqual([[-2.121, 0], [-2.121, -2.121], [2.5, -6.743], [3, -6.743], [3, 0]]); + expect(webFoot(6, 2.5)).toBe(6.743); + // Audit 2026-11-12, N-mechanik-4: a web that ended a gap before the other body left the far half of each barrel hanging + // (90 mm² of overhang at 6 mm, 619 mm² at 40 mm). It now passes under the whole barrel, and the other body is cut back + // for it by the same profile a gap further out, up to a gap before the knuckle's own wall and as long as the knuckle's room. + const rooms = hung.filter((step) => String(step.params.name).endsWith('◤')); + expect(rooms.filter((step) => step.command === 'solid.extrude').map((step) => [step.params.operation, (step.params.targets as string[])[0], step.params.distance])).toEqual([['cut', 'Flap', '10.533 mm'], ['cut', 'Base', '10.533 mm'], ['cut', 'Flap', '10.533 mm']]); + expect(hung[hung.findIndex((step) => step.params.name === 'Hinge 1 ◤') + 1]?.params.points).toEqual([[-2.404, 0], [-2.404, -2.404], [2.1, -6.908], [2.1, 0]]); + expect(hung.findIndex((step) => step.params.name === 'Hinge 1 ◤')).toBeLessThan(hung.findIndex((step) => step.params.name === 'Hinge 1 ◣')); + // N-mechanik-5: a body whose wall does not reach down to the web's foot is left out of `support`, and gets neither web nor room for one. + const half = hingeSteps({ ...spec, from: [42, 0, 16], to: [42, 30, 16], support: { bed: 0, fixed: sides.fixed } }); + expect(half.filter((step) => step.command === 'solid.extrude' && /[◣◤]$/.test(String(step.params.name))).map((step) => step.params.name)).toEqual(['Hinge 1 ◤', 'Hinge 1 ◣', 'Hinge 3 ◤', 'Hinge 3 ◣']); expect(webs.filter((step) => step.command === 'solid.extrude').map((step) => (step.params.targets as string[])[0])).toEqual(['Base', 'Flap', 'Base']); // The fixed body lies towards -x: its profile is drawn from the far end of the knuckle, so it is extruded back along the line. expect(webs[0]?.params.plane).toMatchObject({ x_dir: [-1, 0, 0], y_dir: [0, 0, 1] }); // The webs come before the bore, which goes through those of the flap. expect(hung.findIndex((step) => step.params.name === 'Hinge ○')).toBeGreaterThan(hung.findIndex((step) => step.params.name === 'Hinge 2 ◣')); // Near the bed the web ends on it instead of going below. - expect(hingeSteps({ ...spec, from: [42, 0, 6], to: [42, 30, 6], support: { bed: 0, ...sides } }).find((step) => step.command === 'sketch.line' && (step.params.points as number[][]).length === 5)?.params.points).toEqual([[-2.1, 0], [-2.1, -2.142], [1.758, -6], [3, -6], [3, 0]]); + expect(hingeSteps({ ...spec, from: [42, 0, 6], to: [42, 30, 6], support: { bed: 0, ...sides } }).find((step) => step.command === 'sketch.line' && (step.params.points as number[][]).length === 5)?.params.points).toEqual([[-2.121, 0], [-2.121, -2.121], [1.757, -6], [3, -6], [3, 0]]); expect(hingeSteps({ ...spec, support: { bed: 0, ...sides } }).some((step) => String(step.params.name).endsWith('◣'))).toBe(false); // The rooms of the knuckles: 3.4 mm about a line 2.5 mm from a face go 0.9 mm into it. A 1.8 mm wall keeps two lines, a 1.6 mm wall // would keep a skin of 0.7 mm and is refused with what to change; a wall the room goes through is a window, and fine. @@ -162,7 +183,7 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { listTools: () => Promise.resolve([]), callTool: (name, args) => { calls.push(name); - // Before: three steps to undo. Of the cylinders the five knuckles (48 mm of hinge line) do not touch their body, as SolidCraft 0.40.0-rc.3 warns. + // Before: three steps to undo. Of the cylinders the seven knuckles (48 mm of hinge line) do not touch their body, as SolidCraft 0.40.0-rc.3 warns. if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Box' }, { name: 'Lid' }], undo: 3 }) }] }); const commands = args.commands as { command: string; params: { operation?: string; diameter?: string } }[]; calls.push(...commands.filter((step) => step.command === 'edit.undo').map((step) => step.command)); @@ -179,7 +200,7 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { const built = await withMechanismTool(upstream, () => 0.4).callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Box', moving: 'Lid', from: [0, 43.4, 3], to: [48, 43.4, 3] }); expect(built.isError).toBe(true); expect(built.content[0]).toMatchObject({ - text: 'Not built: 5 piece(s) did not touch their body and stand apart from it. Put the hinge line within 2.5 mm of both bodies (the barrel is 6 mm, so it must overlap each by at least 0.5 mm). ' + + text: 'Not built: 7 piece(s) did not touch their body and stand apart from it. Put the hinge line within 2.5 mm of both bodies (the barrel is 6 mm, so it must overlap each by at least 0.5 mm). ' + "PartBuddy cuts each knuckle's room in the other body itself. Everything was taken back.", }); // Every step SolidCraft counted since (11 − 3) is undone in one batch, and no joint was made. @@ -212,7 +233,7 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { }; const taken = tools((depth) => depth); expect(await taken.call()).toEqual({ isError: true, content: [{ type: 'text', text: 'Not built: SolidCraft refused joint.as_built: invalid parameters for `joint.create`: a joint needs two different occurrences. Everything was taken back.' }] }); - expect(taken.sent.at(-1)).toBe(Array.from({ length: 9 }, () => 'edit.undo').join(' ')); + expect(taken.sent.at(-1)).toBe(Array.from({ length: 10 }, () => 'edit.undo').join(' ')); expect(taken.depth()).toBe(2); // A second call starts from the same design: it does not build on top of the first. await taken.call(); @@ -259,13 +280,61 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { expect(guide.content[0]).toMatchObject({ text: expect.not.stringContaining('already joins') as string }); }); + // Audit 2026-11-12, N-mechanik-4 and -5: "stand on webs …, so they print without support" stood over 90 to 619 mm² of + // overhang the print check still found, and over a web that hung 4.6 mm under a lid plate with no wall below it. + it('gives a web only to a body whose wall reaches the web\'s foot, and says what prints without support only where it holds', async () => { + // Two blocks 16 mm high, 5 mm apart, the line on their rims: the rod on the line is 2.45 mm from either wall. + const answer = async (walls: string[], args: Record = {}) => { + const sent: { command: string; params: Record }[] = []; + const upstream: UpstreamTools = { + instructions: () => undefined, + listTools: () => Promise.resolve([]), + callTool: (name, call) => { + if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Base' }, { name: 'Flap' }], undo: 2 }) }] }); + const commands = call.commands as { command: string; params: Record }[]; + sent.push(...commands); + const rods = commands.filter((step) => step.command === 'solid.cylinder' && String(step.params.body_name).startsWith('PartBuddy probe ')); + const results = commands.map((step) => { + if (step.command === 'inspect.measure') return { bodies: [{ bbox: { min: [0, 0, 0] } }, { bbox: { min: [45, 0, 0] } }] }; + if (step.command === 'component.list') return { components: [{ id: 3, parent: 0, bodies: ['Base'], occurrences: [{ id: 4, name: 'Base:1' }] }, { id: 5, parent: 0, bodies: ['Flap'], occurrences: [{ id: 6, name: 'Flap:1' }] }] }; + if (step.command !== 'inspect.clearance') return {}; + // The second trial: each rod lies in its body where that body has a wall under the web's foot (a rod 0.2 mm thick and 30 mm long is 0.942 mm³). + if (rods.length > 0) return { overlaps: rods.flatMap((rod) => (walls.some((body) => String(rod.params.body_name).endsWith(body === 'Base' ? 'fixed' : 'moving')) ? [{ a: rod.params.body_name, b: String(rod.params.body_name).endsWith('fixed') ? 'Base' : 'Flap', volume_mm3: 0.94 }] : [])), near: [] }; + return { near: [{ a: 'PartBuddy probe', b: 'Base', distance_mm: 2.45, from: [42.5, 15, 16], to: [40, 15, 16] }, { a: 'PartBuddy probe', b: 'Flap', distance_mm: 2.45, from: [42.5, 15, 16], to: [45, 15, 16] }] }; + }); + return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }] }); + }, + }; + const built = await withMechanismTool(upstream, () => 0.4).callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42.5, 0, 16], to: [42.5, 30, 16], ...args }); + return { text: String(built.content[0]?.text), sent, webs: sent.filter((step) => step.command === 'solid.extrude' && String(step.params.name).endsWith('◣')).map((step) => (step.params.targets as string[])[0]) }; + }; + const both = await answer(['Base', 'Flap']); + expect(both.webs).toEqual(['Base', 'Flap', 'Base', 'Flap', 'Base']); + expect(both.text).toContain(' All 5 knuckles start above the bed and stand on webs at 45° that run from under the barrel down to their own wall, and the pin is flat underneath where it bridges from knuckle to knuckle, so the hinge prints as it stands, without support. '); + // Each rod lies 0.25 mm inside its wall and 0.2 mm under the web's foot, 6.743 mm below the line: at z = 9.057. + const rods = both.sent.filter((step) => String(step.params.body_name).startsWith('PartBuddy probe ')); + expect(rods.map((step) => (step.params.base as number[]).map((value) => Math.round(value * 1000) / 1000))).toEqual([[39.75, 0, 9.057], [45.25, 0, 9.057]]); + // The flap's wall ends above that: no web is drawn into the air under it, and the answer names what is left. + const one = await answer(['Base']); + expect(one.webs).toEqual(['Base', 'Base', 'Base']); + expect(one.text).not.toContain('without support'); + expect(one.text).toContain(' The knuckles start above the bed, and the 2 of Flap got no web: a web meets its wall 6.743 mm below the line at the most, and there is no wall there. They need support, or a wall that reaches down that far before the hinge is built. The 3 knuckles of Base stand on webs at 45° that run from under the barrel down to their own wall. '); + const none = await answer([]); + expect(none.webs).toEqual([]); + expect(none.text).toContain('the 3 of Base and the 2 of Flap got no web'); + // Three knuckles asked for on 30 mm: the pin bridges 10.5 mm inside the middle one, and the answer does not call that clean. + const wide = await answer(['Base', 'Flap'], { knuckles: 3 }); + expect(wide.text).not.toContain('without support'); + expect(wide.text).toContain('where it bridges from knuckle to knuckle. The pin bridges 10.5 mm inside each knuckle of Flap, more than prints clean (10 mm): use more knuckles.'); + }); + it('builds onto a body that is a component with that component active, so the knuckles move with it', () => { // The flap is a component already (a second mechanism, or the assistant made components first); the base is not. const listing = { active: 0, components: [{ id: 0, parent: null, bodies: ['Base'] }, { id: 5, parent: 0, bodies: ['Flap'], occurrences: [{ name: 'Flap:1' }] }] }; const steps = activated(hingeSteps(spec), listing); const target = (step: { command: string; params: Record }) => (step.command === 'component.activate' ? `activate ${String(step.params.component)}` : ((step.params.targets as string[] | undefined)?.[0] ?? step.command)); // The roof's sketch is drawn with the flap active, as the bore before it and the cut after it. - expect(steps.map(target)).toEqual(['activate 5', 'Flap', 'activate 0', 'Base', 'activate 5', 'Flap', 'activate 0', 'Base', 'activate 5', 'Flap', 'activate 0', 'Base', 'activate 5', 'Flap', 'sketch.create', 'sketch.line', 'Flap', 'activate 0', 'Base']); + expect(steps.map(target)).toEqual(['activate 5', 'Flap', 'activate 0', 'Base', 'activate 5', 'Flap', 'activate 0', 'Base', 'activate 5', 'Flap', 'activate 0', 'Base', 'activate 5', 'Flap', 'sketch.create', 'sketch.line', 'Flap', 'activate 0', 'Base', 'sketch.create', 'sketch.line', 'Base']); // Nothing is a component: the steps as they are. expect(activated(hingeSteps(spec), { components: [{ id: 0, parent: null, bodies: ['Base', 'Flap'] }] })).toEqual(hingeSteps(spec)); // A guide's sketch belongs to the component of the extrusion that uses it, and the design level is active again at the end. From f37a9c7355d2a505ffd51a23c6e8c525daef9a2b Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:27:23 +0100 Subject: [PATCH 14/24] fix(desktop): the hinged box template carries its knuckles on the tool's webs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The template built the hinge without the webs of the mechanism tool: its knuckles began 12 mm above the bed in the air, under a file that says every template prints without support (audit 2026-11-12, T3). It now names the walls of its two halves and the bed, as the tool measures them, and the webs follow length and height like the rest of the hinge. Measured, print check of this branch, PLA 0.4: warn, overhang 189 mm² up to 87° -> ready; after length 80, height 24: no overhang finding either. Gap 0.400 mm, no shared volume, one shell per body, and the lid closes to the bead's snap at 175.6° as before. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/shared/templates.ts | 15 +++++++++------ apps/desktop/test/templates.test.ts | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/shared/templates.ts b/apps/desktop/src/shared/templates.ts index e0c404d8..29f1bb67 100644 --- a/apps/desktop/src/shared/templates.ts +++ b/apps/desktop/src/shared/templates.ts @@ -2,7 +2,8 @@ * Templates (plan step F3): parametric starting points built with SolidCraft's own commands. Every * size is a user parameter (`parameters.add`) and the geometry refers to it by expression, so the * parameter panel changes a template without the assistant. Each was built and changed with - * `solidcraft-cli eval` and stays printable as designed, Z up, without support. + * `solidcraft-cli eval` and stays printable as designed, Z up, without support (the stop of the slide stop on its top: + * its print face is set). * * `solid.hole` keeps a position given as expressions but does not move with it (0.40.0-rc.2: the * hole stays where it was placed and warns), so plain holes are cut cylinders, which follow, and @@ -43,7 +44,8 @@ const joint = (type: 'revolute' | 'slider', fixed: string, moving: string, on: { /** * Two halves of one height side by side, open side up, with a print-in-place hinge on the rims * between them: the lid turns half a turn onto the box. The hinge keeps its length and stays in the - * middle of the long side and on the rim when the sizes change. + * middle of the long side and on the rim when the sizes change. Its knuckles start 12 mm above the + * bed and stand on the webs of the mechanism tool, down to the walls of their halves. */ function hingedBox(): Step[] { // The room between the halves; the hinge line runs in its middle, within reach of both walls. @@ -69,7 +71,7 @@ function hingedBox(): Step[] { return [ parameter('length', '60 mm', 'Outer length of box and lid; at least 54 mm, the hinge is 40 mm long'), parameter('width', '40 mm', 'Outer width of box and lid'), - parameter('height', '16 mm', 'Height of each half; closed, the box is twice as high'), + parameter('height', '16 mm', 'Height of each half; closed, the box is twice as high. At least 8 mm: the webs under the hinge reach 6.2 mm below the rim'), parameter('wall', '2.4 mm', 'Wall and floor thickness'), parameter('corner', '5 mm', 'Radius of the outer corners of box and lid; at least 2 mm more than the wall'), ...shell('Box', '-width', -width, (name) => `Box ${name}`), @@ -90,8 +92,9 @@ function hingedBox(): Step[] { name: 'Closure', }), // The hinge of the mechanism tool, built at the starting sizes, then tied to the parameters. - ...hingeSteps({ fixed: 'Box', moving: 'Lid', from: [start, between / 2, height], to: [start + hinge, between / 2, height], diameter, gap, knuckles, name: 'Hinge' }).map((step, i, steps) => { - // The profiles (the bore's roof, the pin's flat, and webs where the hinge has them) are drawn in the world's own y and z and + // The walls of both halves stand half the room between them from the line, down to the bed: every knuckle gets its web. + ...hingeSteps({ fixed: 'Box', moving: 'Lid', from: [start, between / 2, height], to: [start + hinge, between / 2, height], diameter, gap, knuckles, name: 'Hinge', support: { bed: 0, fixed: { toward: [0, -1, 0], distance: between / 2 }, moving: { toward: [0, 1, 0], distance: between / 2 } } }).map((step, i, steps) => { + // The profiles (the bore's roof, the webs and their rooms, the pin's flat) are drawn in the world's own y and z and // moved along x, so they follow the sizes as the cylinders do. One drawn looking back along x is turned round: // it starts where its extrusion ended. const drawn = (steps[step.command === 'sketch.create' ? i : i - 1]?.params.plane ?? {}) as { origin?: Vec3; x_dir?: Vec3 }; @@ -316,7 +319,7 @@ const GERMAN: Record = { 'Thickness of the plate': 'Stärke der Platte', 'Height of the capital letters': 'Höhe der Großbuchstaben', 'Holes for M3 screws (ISO 273, normal)': 'Löcher für M3-Schrauben (ISO 273, mittel)', - 'Height of each half; closed, the box is twice as high': 'Höhe jeder Hälfte; geschlossen ist die Box doppelt so hoch', + 'Height of each half; closed, the box is twice as high. At least 8 mm: the webs under the hinge reach 6.2 mm below the rim': 'Höhe jeder Hälfte; geschlossen ist die Box doppelt so hoch. Mindestens 8 mm: die Stege unter dem Scharnier reichen 6,2 mm unter den Rand', 'Wall and floor thickness': 'Stärke von Wand und Boden', 'Wall, floor and lid thickness': 'Stärke von Wand, Boden und Deckel', 'Outer length of housing and lid': 'Außenlänge von Gehäuse und Deckel', diff --git a/apps/desktop/test/templates.test.ts b/apps/desktop/test/templates.test.ts index cc253d1f..5b79b0a2 100644 --- a/apps/desktop/test/templates.test.ts +++ b/apps/desktop/test/templates.test.ts @@ -115,6 +115,21 @@ describe('templates with moving parts', () => { for (const step of knuckles) expect(step.params.base).toEqual([expect.stringMatching(/^length \/ 2 \+ -?[\d.]+ mm$/), 0.5, 'height']); } expect(germanFeatureName('Hinge 2 ·')).toBe('Scharnier 2 ·'); + // Audit 2026-11-12, T3: the template called the hinge without the webs of the tool, so its knuckles began 12 mm above the + // bed in the air (189 mm² of overhang, up to 87°) under a file that promises "without support". + const steps = templateSteps('hingedBox'); + const webs = steps.filter((step) => step.command === 'solid.extrude' && String(step.params.name).endsWith('◣')); + expect(webs.map((step) => [step.params.name, (step.params.targets as string[])[0], step.params.distance])).toEqual([['Hinge 1 ◣', 'Box', '7.68 mm'], ['Hinge 2 ◣', 'Lid', '7.68 mm'], ['Hinge 3 ◣', 'Box', '7.68 mm'], ['Hinge 4 ◣', 'Lid', '7.68 mm'], ['Hinge 5 ◣', 'Box', '7.68 mm']]); + // Each is drawn on the world's YZ plane at the knuckle's low end in x, from the rim down, so it follows length and height: + // the box's from the line (y 0.5) towards -y to 0.5 mm inside its wall, the lid's towards +y. + const drawn = (name: string) => steps.slice(steps.findIndex((step) => step.command === 'sketch.create' && step.params.name === name)).slice(0, 2).map((step) => step.params); + expect(drawn('Hinge 1 ◣')).toEqual([{ plane: 'YZ', offset: 'length / 2 + -20 mm', project_edges: false, name: 'Hinge 1 ◣' }, { points: [['3.328 mm', 'height + 0 mm'], ['3.328 mm', 'height + -2.828 mm'], ['0 mm', 'height + -6.157 mm'], ['-0.5 mm', 'height + -6.157 mm'], ['-0.5 mm', 'height + 0 mm']], closed: true }]); + expect(drawn('Hinge 2 ◣')[0]).toMatchObject({ plane: 'YZ', offset: 'length / 2 + -11.92 mm' }); + expect((drawn('Hinge 2 ◣')[1]?.points as string[][])[2]).toEqual(['1 mm', 'height + -6.157 mm']); + expect(germanFeatureName('Hinge 1 ◣')).toBe('Scharnier 1 ◣'); + expect(germanFeatureName('Hinge 2 ▬')).toBe('Scharnier 2 ▬'); + // The joint is kept on the pin's round top: underneath the pin is flat now. + expect((steps.find((step) => step.command === 'joint.create')?.params.a as { circle: number[] }).circle.map((value) => Math.round(value * 100) / 100)).toEqual([25.46, 0.5, 18]); }); it('joins the stop as a slider, by name in either language', () => { 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 15/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 16/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 29786b8403936d18a7257f6533111ab5013a9646 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:10:44 +0100 Subject: [PATCH 17/24] fix(desktop): a failed tool call is taken back by what the design holds, not by the undo depth SolidCraft's undo depth stops at 200 (0.40.1: 260 steps read undo 200). The difference of two depths then comes out too small or 0: with 170 steps before, 30 of a hinge's 54 steps were undone, with 200 none, both under "Everything was taken back" (review of PR #358). takeBack() keeps features, sketches, bodies and joints as the mark and undoes until the design reads like it again, never below the mark's number of features and never more than 400 steps; it says false where that cannot be reached, and always makes the design level active again, since activating a component is no undo step. A function of its own over run(commands), so the block tool can use it too. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/agents/takeBack.ts | 76 ++++++++++++++++ apps/desktop/test/takeBack.test.ts | 106 +++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 apps/desktop/src/main/agents/takeBack.ts create mode 100644 apps/desktop/test/takeBack.test.ts diff --git a/apps/desktop/src/main/agents/takeBack.ts b/apps/desktop/src/main/agents/takeBack.ts new file mode 100644 index 00000000..c9ae8533 --- /dev/null +++ b/apps/desktop/src/main/agents/takeBack.ts @@ -0,0 +1,76 @@ +/** + * Taking back what a tool built before it failed, shared by PartBuddy's own tools. The state before is kept as a mark: + * what `document.inspect` and `joint.list` name of the design (features, sketches, bodies, joints). Taking back undoes + * until the design reads like the mark again. + * + * Not by the difference of SolidCraft's undo depth alone: that depth stops at 200 (measured on 0.40.1: 260 steps read + * `undo: 200`), a hinge with webs costs over 50 steps, and from the fourth hinge on the difference came out too small + * or 0, so half a hinge or all of it stayed under "Everything was taken back" (review of PR #358). + */ + +type Step = { command: string; params: Record }; +/** Runs SolidCraft commands in turn and gives each one's result; throws when one fails. */ +export type Run = (commands: Step[]) => Promise; + +export interface Mark { + /** What the design consists of, as one string to compare. */ + state: string; + /** How many features its timeline has: undoing never goes below that, into what was there before. */ + features: number; + /** SolidCraft's undo depth then; only a first guess at how many steps came since. */ + undo: number | null; +} + +/** More single steps than any tool builds: the search for the mark ends there. */ +const MOST = 400; + +const READ: Step[] = [ + { command: 'document.inspect', params: { timeline: 'summary', bodies: 'names' } }, + { command: 'joint.list', params: {} }, +]; + +function markFrom(results: unknown[]): Mark | null { + const [design, joints] = results as [{ timeline?: unknown; sketches?: unknown; bodies?: unknown; undo?: unknown } | undefined, { joints?: unknown } | undefined]; + // Without the timeline there is nothing to know the state by. + if (!Array.isArray(design?.timeline)) return null; + const ids = (list: unknown): unknown[] => (Array.isArray(list) ? (list as unknown[]).map((entry) => (entry as { id?: unknown } | null)?.id ?? entry) : []); + return { features: design.timeline.length, state: JSON.stringify([ids(design.timeline), ids(design.sketches), design.bodies ?? null, ids(joints?.joints)]), undo: typeof design.undo === 'number' ? design.undo : null }; +} + +/** The design as it is now, to come back to; null when SolidCraft does not say enough to know it again. */ +export async function markOf(run: Run): Promise { + try { + return markFrom(await run(READ)); + } catch { + return null; + } +} + +/** + * Undoes until the design is at `mark` again, and makes the design level active (activating a component is no undo + * step: after the undos it stays active, and what is built next lands in it). True only when the mark was reached; + * false when it could not be (no mark, nothing left to undo, more than `MOST` steps), and the caller must say so. + */ +export async function takeBack(run: Run, mark: Mark | null): Promise { + const level = async () => { + await run([{ command: 'component.activate', params: { component: 0 } }]).catch(() => undefined); + }; + if (!mark) { + await level(); + return false; + } + try { + let now = markFrom(await run(READ)); + // Where the depth has not hit its ceiling the difference is the number of steps: all at once, then looked at. + if (now && now.state !== mark.state && mark.undo !== null && now.undo !== null && now.undo > mark.undo) { + now = markFrom((await run([...Array.from({ length: now.undo - mark.undo }, () => ({ command: 'edit.undo', params: {} })), ...READ])).slice(-READ.length)); + } + // One by one from there, as long as features built since are left: a step further would take what was there before. + for (let step = 0; now && now.state !== mark.state && now.features > mark.features && step < MOST; step += 1) now = markFrom((await run([{ command: 'edit.undo', params: {} }, ...READ])).slice(-READ.length)); + await level(); + return now?.state === mark.state; + } catch { + await level(); + return false; + } +} diff --git a/apps/desktop/test/takeBack.test.ts b/apps/desktop/test/takeBack.test.ts new file mode 100644 index 00000000..a3abde37 --- /dev/null +++ b/apps/desktop/test/takeBack.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +import { markOf, takeBack, type Run } from '../src/main/agents/takeBack'; + +/** + * A design as SolidCraft 0.40.1 answers for it: every built step is a feature and an undo step, a joint is an undo + * step that is no feature, the undo depth stops at 200, and activating a component is not undone. + */ +function design(features: number, { timeline = true } = {}) { + const steps: ('feature' | 'joint')[] = Array.from({ length: features }, () => 'feature'); + let next = features + 1; + const ids = Array.from({ length: features }, (_, i) => i + 1); + const joints: number[] = []; + const sent: string[][] = []; + let active = 0; + const run: Run = (commands) => { + sent.push(commands.map((step) => step.command)); + return Promise.resolve( + commands.map((step) => { + if (step.command === 'edit.undo') { + const last = steps.pop(); + if (last === undefined) throw new Error('nothing to undo'); + if (last === 'feature') ids.pop(); + else joints.pop(); + return { undone: last }; + } + if (step.command === 'component.activate') active = step.params.component as number; + if (step.command === 'document.inspect') return { ...(timeline ? { timeline: ids.map((id) => ({ id })) } : {}), sketches: [], bodies: ['Base', 'Flap'], undo: Math.min(steps.length, 200) }; + if (step.command === 'joint.list') return { joints: joints.map((id) => ({ id })) }; + return {}; + }), + ); + }; + return { + run, + sent, + build: (count: number, joint = false) => { + for (let i = 0; i < count; i += 1) { + steps.push('feature'); + ids.push(next++); + } + if (joint) { + steps.push('joint'); + joints.push(next++); + } + active = 5; + }, + state: () => ({ features: ids.length, joints: joints.length, active }), + undos: () => sent.flat().filter((command) => command === 'edit.undo').length, + }; +} + +describe('taking back what a tool built before it failed', () => { + it('undoes every step since the mark and leaves the design level active', async () => { + const d = design(12); + const mark = await markOf(d.run); + d.build(54, true); + expect(await takeBack(d.run, mark)).toBe(true); + expect(d.state()).toEqual({ features: 12, joints: 0, active: 0 }); + // Below the ceiling of the undo depth the difference is the number of steps: one batch, looked at afterwards. + expect(d.undos()).toBe(55); + expect(d.sent.at(-2)?.filter((command) => command === 'edit.undo')).toHaveLength(55); + expect(d.sent.at(-1)).toEqual(['component.activate']); + }); + + // Review of PR #358, measured on SolidCraft 0.40.1: 260 steps read `undo: 200`. With the depth at 200 before and + // after, the difference was 0 and nothing was undone; at 170 before, 30 of 54 steps were, and half a hinge stayed. + it('goes on past the ceiling of the undo depth until the design is as it was', async () => { + for (const before of [170, 200, 240]) { + const d = design(before); + const mark = await markOf(d.run); + expect(mark?.undo).toBe(Math.min(before, 200)); + d.build(54, true); + expect(await takeBack(d.run, mark), String(before)).toBe(true); + expect(d.state(), String(before)).toEqual({ features: before, joints: 0, active: 0 }); + expect(d.undos(), String(before)).toBe(55); + } + }); + + it('undoes nothing where nothing was built, and still leaves the design level active', async () => { + const d = design(7); + const mark = await markOf(d.run); + d.build(0); + expect(await takeBack(d.run, mark)).toBe(true); + expect(d.undos()).toBe(0); + expect(d.state().active).toBe(0); + }); + + it('says so when the design cannot be known again, and takes nothing that was there before', async () => { + // No timeline in SolidCraft's answer: no mark, nothing undone. + const blind = design(5, { timeline: false }); + expect(await markOf(blind.run)).toBeNull(); + blind.build(9); + expect(await takeBack(blind.run, null)).toBe(false); + expect(blind.undos()).toBe(0); + expect(blind.state().active).toBe(0); + // A mark the design never comes back to (another feature in its place): the undoing ends at the mark's number of + // features, not at the bottom of the timeline. + const other = design(5); + const mark = await markOf(other.run); + other.build(3); + expect(await takeBack(other.run, mark ? { ...mark, state: '[]', undo: 200 } : null)).toBe(false); + expect(other.state().features).toBe(5); + expect(other.undos()).toBe(3); + }); +}); From 66230818524a629e7541fdec8d31da42866633a0 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:10:51 +0100 Subject: [PATCH 18/24] fix(desktop): mechanisms cut no body apart, share a joint on one line, and promise by material MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the review of PR #358, each measured on SolidCraft 0.40.1: - The groove's roof went by the moving body's box: a plate 6 mm thick with a knob on it got the roof (ridge at 7.9 mm) and was cut in two along the rail, under "Neither needs support". Rods along the groove, over the ridge and both flanks, now say whether the body is solid there (plate with knob, drawer with a 5 mm floor: flat ceiling, two bodies, said so; block and slanted block: roof). - Any step that leaves a new body behind (created_bodies, no warning) fails the call, which is taken back, for every kind of mechanism. - A failed call is taken back with takeBack(): past the ceiling of the undo depth too (187 features before, a hinge, joint refused: 187 after, twice). - A second hinge on the line of the first is built and shares the first joint, which is swept again (120 mm flap: one joint, no conflict, one degree of freedom); only a hinge on another line is refused. At nine knuckles the advice is two shorter hinges on one line. - "Without support" is said only for a material that prints 45°; for one that stops at 40° the answer names what leans 45° (hinge, guide, dovetail, screw lid, latch). The angle comes from the part's print profile, like the nozzle. - A flat groove ceiling within the bridge span of the print check (2 x 1.5 mm rail: 6.9 mm) is called a bridge that prints, not "turn it over". - The hinged box's webs end on the bed at the latest (height 5 mm: lowest point -1.16 mm -> 0); the description names no height that holds for one nozzle only; the refusal ends with a full stop. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/agents/mechanismTool.ts | 251 ++++++++++-------- apps/desktop/src/main/index.ts | 2 +- apps/desktop/src/shared/templates.ts | 4 +- apps/desktop/test/mechanism.test.ts | 100 +++++-- apps/desktop/test/templates.test.ts | 9 +- 5 files changed, 224 insertions(+), 142 deletions(-) diff --git a/apps/desktop/src/main/agents/mechanismTool.ts b/apps/desktop/src/main/agents/mechanismTool.ts index 66a61a2e..f39c9ca3 100644 --- a/apps/desktop/src/main/agents/mechanismTool.ts +++ b/apps/desktop/src/main/agents/mechanismTool.ts @@ -1,6 +1,9 @@ import { LID, LIP, WEB, hangs, hingeProblem, hingeSteps, pinBridge, webFoot, lidProblem, lidSkirt, lidSteps, lidThread, lipProblem, lipSteps, pinDiameter, screwLength, screwProblem, screwSteps, slideProblem, slideRoof, slideSteps, snapProblem, snapSteps, latchProblem, latchReach, latchSteps, LATCH, pegLength, pegProblem, pegSteps, PEG, SNAP, type HingeSpec, type HingeSide, type LatchSpec, type LidSpec, type LipSpec, type PegSpec, type ScrewSpec, type SlideSpec, type SnapSpec } from '../../shared/mechanisms'; import { BLOCK_SIZES, INSERT, type BlockSize } from '../../shared/screwSizes'; +import { BRIDGE_SPAN } from '@partbuddy/geometry'; + import { componentOf } from '../solidcraft/actions'; +import { markOf, takeBack, type Mark, type Run } from './takeBack'; import type { ToolResult, UpstreamTool, UpstreamTools } from './gateProxy'; /** @@ -20,7 +23,7 @@ const mechanismTool: UpstreamTool = { 'Build a mechanism between two bodies that exist, with the same gap everywhere; the answer says how it prints. ' + 'hinge: knuckles in turn on both bodies and a pin through them, printed in place as one piece. Give the hinge line (from, to) in the gap between the bodies, level with the bed and half the barrel diameter above it when the part prints lying flat; ' + 'the line must lie closer to both bodies than the barrel radius less 0.5 mm, so the knuckles grow out of them; PartBuddy cuts each knuckle\'s room in the other body itself, a gap wider than the barrel, and carries knuckles that start above the bed on webs at 45° down to their own walls, where the wall reaches about one and a quarter barrel diameters below the line (the answer says where it does not, and what then needs support): add no supports of your own. Give wall, the thickness of the walls along the line: a room that would leave less than two lines of a wall behind it is refused, with how far to move the line. ' + - 'slide: a dovetail rail on the fixed body and its groove in the moving body, which lies over the rail a gap above the face. Give the middle line of the rail (from, to) on that face and the face normal (up). The two print apart and the moving body is pushed onto the rail from an end. Where the moving body stands high enough over that face (8.8 mm at the defaults; the answer names it) the groove gets a roof at 45° and prints as it stands; otherwise its ceiling is flat and the answer says how to lay the part. ' + + 'slide: a dovetail rail on the fixed body and its groove in the moving body, which lies over the rail a gap above the face. Give the middle line of the rail (from, to) on that face and the face normal (up). The two print apart and the moving body is pushed onto the rail from an end. Where the moving body stands high enough over that face (the answer names how high) the groove gets a roof at 45° and prints as it stands; otherwise its ceiling is flat and the answer says how to lay the part. ' + 'PartBuddy also joins the two with a joint, finds how far it moves before the bodies meet (a slide: while the moving body stays on its rail) and sets that as its limits; the user then moves it with a slider in the view. ' + 'closure: a snap closure between a lid and its box, nothing standing out: a lip inside the lid that goes into the box along its wall and centres the lid, a bead that snaps into a rille of the box\'s wall, and a notch for a fingernail. ' + 'Give from and to as opposite corners of the lid\'s inner room at its rim (both at the rim\'s height), up as the way the lip stands out of the lid (+Z when the lid lies open side up, -Z when it sits on the box), wall and radius of the lid, and the side with the catch (a long wall, opposite the hinge). ' + @@ -276,32 +279,23 @@ export function railTravel(measure: unknown, from: Vec3, to: Vec3): [number, num return [round(Math.min(0, -Math.min(...along))), round(Math.max(0, length - Math.max(...along)))]; } -/** How high a body stands over a face, along the face's normal `up` from a point on it: from the body's box (`inspect.measure`). */ -function heightOver(measure: unknown, from: Vec3, up: Vec3): number | null { - const box = ((measure as { bodies?: { bbox?: { min?: unknown; max?: unknown } }[] } | undefined)?.bodies ?? [])[0]?.bbox; - const min = vec3(box?.min); - const max = vec3(box?.max); - if (!min || !max) return null; - const length = Math.hypot(...up); - return Math.max(...[0, 1, 2, 3, 4, 5, 6, 7].map((corner) => [0, 1, 2].reduce((sum, i) => sum + ((((corner >> i) & 1 ? max[i] : min[i]) ?? 0) - (from[i] ?? 0)) * ((up[i] ?? 0) / length), 0))); -} - /** How far a sweep ran before two parts met, cut towards zero so the limit is clear of the contact; `to` when it ran through. */ function reach(sweep: unknown, to: number, step: number): number { const stopped = (sweep as { stopped_at?: unknown } | undefined)?.stopped_at; return typeof stopped === 'number' && Number.isFinite(stopped) ? Math.trunc(stopped / step) * step : to; } -type Run = (commands: Step[]) => Promise; /** - * One call of the tool: how it reaches SolidCraft, and SolidCraft's undo depth before the first step was built - * (null when it could not be read, unset while nothing is begun), so a call that fails later is taken back. + * One call of the tool: how it reaches SolidCraft, and the design as it was before the first step was built (null + * when it could not be read, unset while nothing is begun), so a call that fails later is taken back. `shares` is the + * joint a second hinge on the same line goes on turning about. */ interface Site { upstream: UpstreamTools; run: Run; callId: string | undefined; - mark: { undo?: number | null }; + mark: { state?: Mark | null }; + shares?: { name: string; limits: [number, number] }; } /** Keeps the face a mechanism laid its part on, by the face's name, for the plate view. */ type KeepFace = (body: string, face: string) => void; @@ -337,13 +331,18 @@ export function activated(steps: Step[], listing: unknown): Step[] { * hinge line and the walls, mended the loose knuckles with webs, and their warnings stayed in the * timeline), and `where` tells the model where the line goes. Volume the two still share is a warning. * - * With `joins` the mechanism ends in a joint of that type: a second one between the same two bodies is refused, and a + * A cut that leaves a new body behind has cut its body apart (a groove's roof through a plate thinner than it is high: + * SolidCraft answers `created_bodies` and no warning): that fails the call too, whatever the mechanism. + * + * With `joins` the mechanism ends in a joint of that type: a second one between the same two bodies is refused, unless + * it is a hinge on the line of the first (two short hinges on a long flap), which shares that joint. A * name another joint has gets a number, in `spec` itself, before the steps are made. SolidCraft takes a name twice, and * sweep and limits by that name then met the first joint (audit 2026-11-12, N-mechanik-3: the answer gave the first * hinge's limits, the second had none, and a third call built a third joint between the same parts). */ -async function build({ upstream, run, callId, mark }: Site, spec: { fixed: string; moving: string; name: string }, steps: Step[] | (() => Step[]), where: string, makes = false, ready: () => Promise = () => Promise.resolve(), joins?: 'revolute' | 'slider'): Promise { - const before = jsonOf(await upstream.callTool('inspect_design', {}, callId)) as { bodies?: { name?: unknown }[]; undo?: unknown }; +async function build(site: Site, spec: { fixed: string; moving: string; name: string; from?: Vec3; to?: Vec3 }, steps: Step[] | (() => Step[]), where: string, makes = false, ready: () => Promise = () => Promise.resolve(), joins?: 'revolute' | 'slider'): Promise { + const { upstream, run, callId, mark } = site; + const before = jsonOf(await upstream.callTool('inspect_design', {}, callId)) as { bodies?: { name?: unknown }[] }; const bodies = (before.bodies ?? []).map((body) => body.name); // A mechanism that makes its moving body (a lid) needs the name free. if (makes && bodies.includes(spec.moving)) throw new Error(`a body ${JSON.stringify(spec.moving)} exists already; give the lid another name, or delete that body first`); @@ -359,21 +358,38 @@ async function build({ upstream, run, callId, mark }: Site, spec: { fixed: strin .map((joint) => String(joint.name)); if (away.length > 0) throw new Error(`the joint(s) ${away.map((name) => JSON.stringify(name)).join(', ')} stand away from 0; drive them back with joint.drive first, so the bodies are where they were designed`); if (joins) { - const list = ((joints as { joints?: unknown } | undefined)?.joints ?? []) as { name?: unknown; type?: unknown; a?: unknown; b?: unknown }[]; + const list = ((joints as { joints?: unknown } | undefined)?.joints ?? []) as { name?: unknown; type?: unknown; a?: unknown; b?: unknown; frame_a?: { origin?: unknown; z?: unknown }; values?: { limits?: unknown }[] }[]; const [a, b] = [placed(listing, spec.fixed), placed(listing, spec.moving)]; const side = (occurrence: { id?: unknown; name: string }, value: unknown) => value === occurrence.id || value === occurrence.name; const same = a && b ? list.find((joint) => joint.type === joins && ((side(a, joint.a) && side(b, joint.b)) || (side(b, joint.a) && side(a, joint.b)))) : undefined; const kind = joins === 'revolute' ? 'hinge' : 'guide'; - if (same) throw new Error(`the joint ${JSON.stringify(same.name)} already joins ${spec.fixed} and ${spec.moving} as a ${kind}, and a second ${kind} between the same two bodies would bind it. Change that one instead: take it back with undo, or delete its features and its joint, then call again`); + if (same) { + // On the line of the first hinge the second turns about the same axis: it binds nothing and needs no joint of its own. + const [origin, z] = [vec3(same.frame_a?.origin), vec3(same.frame_a?.z)]; + const line = spec.from && spec.to ? spec.to.map((value, i) => value - (spec.from?.[i] ?? 0)) : null; + const off = (vector: number[], along: number[]) => Math.hypot(...[0, 1, 2].map((i) => (vector[(i + 1) % 3] ?? 0) * (along[(i + 2) % 3] ?? 0) - (vector[(i + 2) % 3] ?? 0) * (along[(i + 1) % 3] ?? 0))) / Math.hypot(...along); + const onLine = joins === 'revolute' && origin && z && line && spec.from && off(z, line) < 1e-3 * Math.hypot(...z) && off(origin.map((value, i) => value - (spec.from?.[i] ?? 0)), line) < 0.01; + if (!onLine) throw new Error(`the joint ${JSON.stringify(same.name)} already joins ${spec.fixed} and ${spec.moving} as a ${kind}${joins === 'revolute' ? ' on another line' : ''}, and a second ${joins === 'revolute' ? 'axis' : 'guide'} between the same two bodies would bind it. ${joins === 'revolute' ? 'Put this hinge on the line of the first, or change the first' : 'Change that one instead'}: take it back with undo, or delete its features and its joint, then call again.`); + const limits = same.values?.[0]?.limits; + site.shares = { name: String(same.name), limits: Array.isArray(limits) && typeof limits[0] === 'number' && typeof limits[1] === 'number' ? [limits[0], limits[1]] : [-180, 180] }; + } const taken = new Set(list.map((joint) => joint.name)); const asked = spec.name; for (let n = 2; taken.has(spec.name); n += 1) spec.name = `${asked} (${String(n)})`; } // The bodies are there and stand as designed: what must be measured on them before anything is built. await ready(); - // From here on the design changes: a call that fails later is taken back to this depth. - mark.undo = typeof before.undo === 'number' ? before.undo : null; - const built = await run(activated(typeof steps === 'function' ? steps() : steps, listing)); + // From here on the design changes: a call that fails later is taken back to this state. + mark.state = await markOf(run); + const sent = activated(typeof steps === 'function' ? steps() : steps, listing); + const built = await run(sent); + for (const [i, step] of sent.entries()) { + const result = built[i] as { created_bodies?: unknown; removed_bodies?: unknown } | undefined; + const made = [result?.created_bodies, result?.removed_bodies].flatMap((names) => (Array.isArray(names) ? (names as unknown[]) : [])); + // A step that makes its own body (a lid, a screw) says so; every other one works on a body that is there. + if (made.length === 0 || step.params.operation === 'new' || step.command === 'parts.insert') continue; + throw new Error(`the step ${JSON.stringify(step.params.name ?? step.command)} cut ${JSON.stringify((step.params.targets as unknown[] | undefined)?.[0] ?? step.params.body ?? 'a body')} apart (${made.map((name) => JSON.stringify(name)).join(', ')} came of it): the body is too thin or too small where the mechanism goes. ${where}`); + } const apart = built.filter((result) => { const warning = (result as { warning?: unknown } | undefined)?.warning; return typeof warning === 'string' && warning.includes('separate pieces'); @@ -385,7 +401,7 @@ async function build({ upstream, run, callId, mark }: Site, spec: { fixed: strin } /** Makes the two bodies components unless they are, joins them where they are and sets the limits: the range clear of contact within `range`. */ -async function join(run: Run, spec: { fixed: string; moving: string; from: Vec3; to: Vec3; name: string }, type: 'revolute' | 'slider', range: [number, number], step: number): Promise<[number, number] | string> { +async function join(run: Run, spec: { fixed: string; moving: string; from: Vec3; to: Vec3; name: string }, type: 'revolute' | 'slider', range: [number, number], step: number, shares?: string): Promise<[number, number] | string> { let [listing] = await run([{ command: 'component.list', params: {} }]); const loners = [spec.fixed, spec.moving].filter((name) => occurrenceOf(listing, name) === null); if (loners.length > 0) [, listing] = await run([{ command: 'component.from_bodies', params: { bodies: loners } }, { command: 'component.list', params: {} }]); @@ -394,8 +410,9 @@ async function join(run: Run, spec: { fixed: string; moving: string; from: Vec3; if (!a || !b) throw new Error(`${spec.fixed} and ${spec.moving} did not become components, so no joint could join them.`); const middle = spec.from.map((value, i) => (value + (spec.to[i] ?? value)) / 2); const axis = spec.to.map((value, i) => value - (spec.from[i] ?? 0)); - const sweeps = range.filter((to) => to !== 0).map((to) => ({ command: 'joint.sweep', params: { joint: spec.name, from: 0, to } })); - await run([{ command: 'joint.as_built', params: { type, a, b, at: { occurrence: a, point: middle, z: axis }, name: spec.name, quiet: true } }]); + const joint = shares ?? spec.name; + const sweeps = range.filter((to) => to !== 0).map((to) => ({ command: 'joint.sweep', params: { joint, from: 0, to } })); + if (!shares) await run([{ command: 'joint.as_built', params: { type, a, b, at: { occurrence: a, point: middle, z: axis }, name: spec.name, quiet: true } }]); // SolidCraft refuses a sweep it cannot afford, such as parts that lie against each other along the way: the joint stays, without limits. let results: unknown[]; try { @@ -405,7 +422,7 @@ async function join(run: Run, spec: { fixed: string; moving: string; from: Vec3; } const limit = (to: number) => (to === 0 ? 0 : reach(results[sweeps.findIndex((sweep) => sweep.params.to === to)], to, step)); const limits: [number, number] = [limit(range[0]), limit(range[1])]; - await run([{ command: 'joint.limits', params: { joint: spec.name, min: limits[0], max: limits[1] } }]); + await run([{ command: 'joint.limits', params: { joint, min: limits[0], max: limits[1] } }]); return limits; } @@ -477,47 +494,55 @@ export async function roomDepths(upstream: UpstreamTools, spec: HingeSpec, callI } } +/** How thick a trial rod is (mm). */ +const ROD = 0.2; + +/** + * Thin rods along a line, tried in the design and taken back like the rod of `roomDepths`: for each, the share of it + * (0 to 1) that lies inside `body`. A rod wholly in the body says there is material all along that line. + */ +async function rodsInside(upstream: UpstreamTools, rods: { name: string; base: number[]; body: string }[], axis: number[], length: number, callId: string | undefined): Promise> { + const shares = new Map(); + if (rods.length === 0 || length <= 0) return shares; + const answer = await upstream.callTool( + 'batch', + { + commands: [ + ...rods.map((rod) => ({ command: 'solid.cylinder', params: { diameter: ROD, height: length, base: rod.base, axis, operation: 'new', body_name: rod.name } })), + { command: 'inspect.clearance', params: { bodies: rods.map((rod) => rod.name), against: [...new Set(rods.map((rod) => rod.body))], min_volume: 0 } }, + { command: 'sketch.inspect', params: { sketch: 2147483647 } }, + ], + rollback: true, + }, + callId, + ); + const text = answer.content.find((part) => part.type === 'text')?.text; + const found = ((JSON.parse(typeof text === 'string' ? text : '') as { results?: unknown[] }).results?.[rods.length] as { overlaps?: unknown } | undefined)?.overlaps; + const whole = (Math.PI * ROD * ROD * length) / 4; + for (const rod of rods) { + const inside = (Array.isArray(found) ? (found as { a?: unknown; b?: unknown; volume_mm3?: unknown }[]) : []).find((pair) => [pair.a, pair.b].includes(rod.name) && [pair.a, pair.b].includes(rod.body))?.volume_mm3; + shares.set(rod.name, typeof inside === 'number' ? inside / whole : 0); + } + return shares; +} + /** * Of the walls beside a hinge line, those that can carry a web: the web ends on the bed, or a rod inside the wall just - * under the web's foot lies in the body along the whole line. Measured on trial like the line itself. + * under the web's foot lies in the body along the whole line. */ async function carried(upstream: UpstreamTools, spec: HingeSpec, bed: number, sides: { fixed: HingeSide | null; moving: HingeSide | null }, callId: string | undefined): Promise<{ fixed?: HingeSide; moving?: HingeSide }> { const axis = spec.to.map((value, i) => value - (spec.from[i] ?? 0)); - const length = Math.hypot(...axis); - const rod = 0.2; const tried = (['fixed', 'moving'] as const).flatMap((key) => { const wall = sides[key]; if (!wall) return []; - const drop = webFoot(spec.diameter, wall.distance) + rod; + const drop = webFoot(spec.diameter, wall.distance) + ROD; // null: the web ends on the bed before it comes to the wall's foot. const base = spec.from[2] - drop <= bed ? null : spec.from.map((value, i) => value + (wall.toward[i] ?? 0) * (wall.distance + WEB.into / 2) - (i === 2 ? drop : 0)); return [{ key, wall, base, name: `${PROBE} ${key}` }]; }); - const rods = tried.filter((entry) => entry.base !== null); - let overlaps: { a?: unknown; b?: unknown; volume_mm3?: unknown }[] = []; - if (rods.length > 0) { - const answer = await upstream.callTool( - 'batch', - { - commands: [ - ...rods.map((entry) => ({ command: 'solid.cylinder', params: { diameter: rod, height: length, base: entry.base, axis, operation: 'new', body_name: entry.name } })), - { command: 'inspect.clearance', params: { bodies: rods.map((entry) => entry.name), against: [spec.fixed, spec.moving], min_volume: 0 } }, - { command: 'sketch.inspect', params: { sketch: 2147483647 } }, - ], - rollback: true, - }, - callId, - ); - const text = answer.content.find((part) => part.type === 'text')?.text; - const found = ((JSON.parse(typeof text === 'string' ? text : '') as { results?: unknown[] }).results?.[rods.length] as { overlaps?: unknown } | undefined)?.overlaps; - if (Array.isArray(found)) overlaps = found as typeof overlaps; - } - const whole = (Math.PI * rod * rod * length) / 4; + const shares = await rodsInside(upstream, tried.flatMap((entry) => (entry.base ? [{ name: entry.name, base: entry.base, body: spec[entry.key] }] : [])), axis, Math.hypot(...axis), callId); const out: { fixed?: HingeSide; moving?: HingeSide } = {}; - for (const entry of tried) { - const inside = overlaps.find((pair) => [pair.a, pair.b].includes(entry.name) && [pair.a, pair.b].includes(spec[entry.key]))?.volume_mm3; - if (entry.base === null || (typeof inside === 'number' && inside >= 0.9 * whole)) out[entry.key] = entry.wall; - } + for (const entry of tried) if (entry.base === null || (shares.get(entry.name) ?? 0) >= 0.9) out[entry.key] = entry.wall; return out; } @@ -536,40 +561,52 @@ export function skinProblem(depths: { fixed: number; moving: number }, spec: { f const MOVE = 'The user moves it with the slider "Moving parts" in the view.'; +/** What the model hears when the joint is made but its range could not be found. */ +const unswept = (reason: string) => + `SolidCraft could not run it through its range (${reason.slice(0, 160)}), so no limits are set. The two bodies probably lie against each other somewhere: look with inspect.clearance, make room, then set joint.limits yourself after joint.sweep.`; + +/** What leans 45° prints without support only in a material that allows it: PETG and the carbon-filled ones stop at 40°. */ +const leans = (what: string, overhang: number) => `${what} lean 45°, more than the ${String(overhang)}° the material in use prints without support: they need support there, or a material that prints 45°.`; + /** * What a lying hinge that hangs above the bed needs to print, from what was measured and built: said only as far as it * holds. "Stand on webs …, so they print without support" stood over 90 to 619 mm² the print check still found * (audit 2026-11-12, N-mechanik-4), and over webs that had no wall under them (N-mechanik-5). */ -function hingePrint(spec: HingeSpec, depths: Awaited>): string { +function hingePrint(spec: HingeSpec, depths: Awaited>, overhang: number): string { if (Math.abs(spec.to[2] - spec.from[2]) > 1e-6) return ''; const bridge = pinBridge(spec); - const sags = bridge > 10 ? ` The pin bridges ${String(Math.round(bridge * 10) / 10)} mm inside each knuckle of ${spec.moving}, more than prints clean (10 mm): use more knuckles.` : ''; + // Nine knuckles is the most one hinge has: a longer line takes two hinges, which share their joint. + const sags = bridge > BRIDGE_SPAN ? ` The pin bridges ${String(Math.round(bridge * 10) / 10)} mm inside each knuckle of ${spec.moving}, more than prints clean (${String(BRIDGE_SPAN)} mm): ${spec.knuckles < 9 ? 'use more knuckles' : 'build two shorter hinges on the same line instead of this one'}.` : ''; const support = depths?.support; if (!support) return ` PartBuddy could not measure the bed and the walls beside the line, so no knuckle got a web: those that start above the bed need support.${sags}`; if (!hangs(spec, support.bed)) return sags; const count = { fixed: (spec.knuckles + 1) / 2, moving: (spec.knuckles - 1) / 2 }; const bare = (['fixed', 'moving'] as const).filter((key) => !support[key]); const webs = `stand on webs at 45° that run from under the barrel down to their own wall`; - if (bare.length === 0) return ` All ${String(spec.knuckles)} knuckles start above the bed and ${webs}, and the pin is flat underneath where it bridges from knuckle to knuckle${sags ? '.' : ', so the hinge prints as it stands, without support.'}${sags}`; + if (bare.length === 0) { + const all = ` All ${String(spec.knuckles)} knuckles start above the bed and ${webs}, and the pin is flat underneath where it bridges from knuckle to knuckle`; + if (overhang < 45) return `${all}. ${leans('Webs, barrels and the bore', overhang)}${sags}`; + return `${all}${sags ? '.' : ', so the hinge prints as it stands, without support.'}${sags}`; + } const hung = bare.map((key) => `the ${String(count[key])} of ${spec[key]}`).join(' and '); const held = bare.length === 1 ? ` The ${String(count[bare[0] === 'fixed' ? 'moving' : 'fixed'])} knuckles of ${spec[bare[0] === 'fixed' ? 'moving' : 'fixed']} ${webs}.` : ''; return ` The knuckles start above the bed, and ${hung} got no web: a web meets its wall ${String(webFoot(spec.diameter, spec.diameter / 2 - 0.5))} mm below the line at the most, and there is no wall there. They need support, or a wall that reaches down that far before the hinge is built.${held}${sags}`; } /** - * Builds the rail and the groove of a guide or a dovetail. The moving body is measured first: where it stands high - * enough over the rail's face, the groove gets its roof; where not, the ceiling stays flat and `print` says so, with - * how to lay the part instead (audit 2026-11-12, T5: "printed in one piece without support" over a ceiling of 13.9 mm). - * shortcut: the height is the body's box, so a body lower over the rail than elsewhere gets a roof that comes out of - * its top; measure along the line if such bodies turn up. + * Builds the rail and the groove of a guide or a dovetail. Whether the moving body is solid where the groove's roof + * goes is tried first, with rods along the groove over the ridge and over both flanks, two lines of plastic above + * them: only then does the groove get its roof. The body's box does not say it: a plate with a knob on it is high + * enough by its box, and the roof cut it in two along the rail under "Neither needs support" (review of PR #358). + * Without a roof the ceiling stays flat and `print` says what that means (audit 2026-11-12, T5: "printed in one piece + * without support" over a ceiling of 13.9 mm). */ -async function buildGuide(site: Site, spec: SlideSpec, nozzle: number, joins?: 'slider'): Promise<{ warnings: string; measure: unknown; print: string }> { +async function buildGuide(site: Site, spec: SlideSpec, nozzle: number, overhang: number, joins?: 'slider'): Promise<{ warnings: string; measure: unknown; print: string }> { const roof = slideRoof(spec); - // Two lines of plastic stay over the ridge. - const needed = Math.round((roof.top + nozzle * 2 * 1.125) * 10) / 10; + const skin = nozzle * 2 * 1.125; + const needed = Math.round((roof.top + skin) * 10) / 10; let measure: unknown; - let high = 0; const warnings = await build( site, spec, @@ -578,48 +615,43 @@ async function buildGuide(site: Site, spec: SlideSpec, nozzle: number, joins?: ' false, async () => { [measure] = await site.run([{ command: 'inspect.measure', params: { bodies: [spec.moving] } }]); - const over = heightOver(measure, spec.from, spec.up); - if (over === null) throw new Error(`${spec.moving} could not be measured, so neither the room for the groove's roof nor how far the guide may travel is known`); - high = Math.round(over * 10) / 10; - spec.roof = over >= needed; + const travel = railTravel(measure, spec.from, spec.to); + if (!travel) throw new Error(`${spec.moving} could not be measured, so neither the room for the groove's roof nor how far the guide may travel is known`); + const axis = spec.to.map((value, i) => value - (spec.from[i] ?? 0)) as Vec3; + const length = Math.hypot(...axis); + const unit = (vector: number[]) => vector.map((value) => value / Math.hypot(...vector)); + const [along, up] = [unit(axis), unit(spec.up)]; + const across = [0, 1, 2].map((i) => (up[(i + 1) % 3] ?? 0) * (along[(i + 2) % 3] ?? 0) - (up[(i + 2) % 3] ?? 0) * (along[(i + 1) % 3] ?? 0)); + // The stretch of the line the body lies over: its travel either way is what of the rail it leaves free. + const [start, covered] = [-travel[0], length + travel[0] - travel[1]]; + // Over the ridge and its two edges, and over the middle of each flank: each rod's top where the skin over the roof ends. + const flank = (roof.ceiling + 2 * roof.ridge) / 8; + const [ridge, slope] = [roof.top + skin - ROD / 2, (spec.height + spec.gap + roof.top) / 2 + skin * Math.SQRT2 - ROD / 2]; + const over: [number, number][] = [[0, ridge], [roof.ridge / 2, ridge], [-roof.ridge / 2, ridge], [flank, slope], [-flank, slope]]; + const rods = over.map(([x, y], n) => ({ name: `${PROBE} ${String(n + 1)}`, body: spec.moving, base: spec.from.map((value, i) => value + (along[i] ?? 0) * start + (across[i] ?? 0) * x + (up[i] ?? 0) * y) })); + const shares = await rodsInside(site.upstream, rods, axis, covered, site.callId).catch(() => new Map()); + spec.roof = rods.every((rod) => (shares.get(rod.name) ?? 0) >= 0.9); }, joins, ); const apart = `The two print apart, each with up pointing up, and ${spec.moving} is then pushed onto the rail from an end.`; + const wide = String(Math.round(roof.ceiling * 10) / 10); + const flanks = spec.roof ? "The rail's flanks and the roof over the groove" : "The rail's flanks"; + const steep = overhang < 45 ? leans(flanks, overhang) : null; const print = spec.roof - ? `${apart} Neither needs support: the rail's flanks and the roof over the groove lean 45°, and the roof's ridge is a bridge of ${String(roof.ridge)} mm.` - : `${apart} The groove's ceiling is flat and ${String(Math.round(roof.ceiling * 10) / 10)} mm wide, more than prints as a bridge (10 mm): a roof at 45° over it needs ${spec.moving} to stand ${String(needed)} mm high over the face the rail is on, and it stands ${String(high)} mm. Print ${spec.moving} turned over instead, lying on its top with the groove open upwards (set that face as its print face), or make it higher and build this again.`; + ? `${apart} ${steep ?? `Neither needs support: the rail's flanks and the roof over the groove lean 45°, and the roof's ridge is a bridge of ${String(roof.ridge)} mm.`}` + : roof.ceiling <= BRIDGE_SPAN + ? `${apart} The groove's ceiling is flat and ${wide} mm wide, which prints as a bridge (up to ${String(BRIDGE_SPAN)} mm)${steep ? `. ${steep}` : ", and the rail's flanks lean 45°: neither needs support."} ${spec.moving} is not solid up to ${String(needed)} mm over the face the rail is on all along the groove, so the groove got no roof.` + : `${apart} The groove's ceiling is flat and ${wide} mm wide, more than prints as a bridge (${String(BRIDGE_SPAN)} mm): a roof at 45° over it needs ${spec.moving} to be solid up to ${String(needed)} mm over the face the rail is on, all along the groove, and it is not. Print ${spec.moving} turned over instead, lying on its top with the groove open upwards (set that face as its print face), or make it that high there and build this again.${steep ? ` ${steep}` : ''}`; return { warnings, measure, print }; } -/** What the model hears when the joint is made but its range could not be found. */ -const unswept = (reason: string) => - `SolidCraft could not run it through its range (${reason.slice(0, 160)}), so no limits are set. The two bodies probably lie against each other somewhere: look with inspect.clearance, make room, then set joint.limits yourself after joint.sweep.`; - -/** - * Takes back what a call built before it failed: every step SolidCraft counted since `before`, in one batch. (A batch - * with rollback around the build itself, or a checkpoint from an earlier batch, does not do it: measured on 0.40.1.) - * False when the design could not be put back. - */ -async function takeBack(run: Run, before: number | null): Promise { - if (before === null) return false; - try { - const [now] = await run([{ command: 'document.inspect', params: {} }]); - const depth = (now as { undo?: unknown } | undefined)?.undo; - if (typeof depth !== 'number') return false; - if (depth > before) await run(Array.from({ length: depth - before }, () => ({ command: 'edit.undo', params: {} }))); - return true; - } catch { - return false; - } -} - /** * A call ends in one of two states: built, with what is missing said in the answer, or not built and the design as it * was. A failure after the first step (the joint refused, the travel not measurable) left knuckles and cuts behind under * "Not built", and the next call built them twice (audit 2026-11-12, H-02). */ -async function buildMechanism(upstream: UpstreamTools, args: Record, nozzle: number, callId: string | undefined, keepFace: KeepFace): Promise { +async function buildMechanism(upstream: UpstreamTools, args: Record, nozzle: number, callId: string | undefined, keepFace: KeepFace, overhang: number): Promise { const run: Run = async (commands) => { const result = await upstream.callTool('batch', { commands }, callId); const text = result.content.find((part) => part.type === 'text')?.text; @@ -636,16 +668,16 @@ async function buildMechanism(upstream: UpstreamTools, args: Record, nozzle: number, keepFace: KeepFace): Promise { +async function mechanism(site: Site, args: Record, nozzle: number, keepFace: KeepFace, overhang: number): Promise { const { upstream, run, callId } = site; if (args.kind === 'hinge') { const spec = hingeOf(args, nozzle); @@ -666,8 +698,9 @@ async function mechanism(site: Site, args: Record, nozzle: numb }, 'revolute', ); - const range = await join(run, spec, 'revolute', [-180, 180], 1); - const print = hingePrint(spec, depths); + // A hinge on the line of an earlier one only narrows what that joint allows: it is run through its limits again. + const range = await join(run, spec, 'revolute', site.shares?.limits ?? [-180, 180], 1, site.shares?.name); + const print = `${site.shares ? ` It lies on the line of the hinge "${site.shares.name}" and turns about that hinge's joint, which carries both now.` : ''}${hingePrint(spec, depths, overhang)}`; if (typeof range === 'string') return `Built the hinge "${spec.name}" between ${spec.fixed} (stays) and ${spec.moving} (turns) and joined them, but ${unswept(range)}${print}${warnings}`; const [min, max] = range; return ( @@ -680,7 +713,7 @@ async function mechanism(site: Site, args: Record, nozzle: numb const spec = slideOf(args, nozzle); if (typeof spec === 'string') throw new Error(spec); // The box along the line is the same before and after the groove is cut. - const { warnings, measure, print } = await buildGuide(site, spec, nozzle, 'slider'); + const { warnings, measure, print } = await buildGuide(site, spec, nozzle, overhang, 'slider'); const travel = railTravel(measure, spec.from, spec.to); if (!travel) throw new Error(`${spec.moving} could not be measured, so how far the guide may travel is not known.`); const range = await join(run, spec, 'slider', travel, 0.1); @@ -718,7 +751,7 @@ async function mechanism(site: Site, args: Record, nozzle: numb return ( `Built the screw lid "${spec.moving}" for ${spec.fixed}: a thread with 45° flanks on the neck, pitch ${String(thread.pitch)} mm, ${String(thread.depth)} mm deep, ${String(spec.starts)} start${spec.starts > 1 ? 's' : ''}, ` + `and the same inside the lid, ${String(spec.gap)} mm clear; it closes in ${String(round2(thread.turns))} turn${thread.turns > 1 ? 's' : ''} and stops with its inner top on the rim. ` + - `The lid is ${String(round2(spec.diameter + 2 * (thread.depth + spec.gap + LID.wall)))} mm wide and covers the top ${String(skirt)} mm of the neck, has flutes to grip and prints standing on its top (its print face is set); both parts print without support. ` + + `The lid is ${String(round2(spec.diameter + 2 * (thread.depth + spec.gap + LID.wall)))} mm wide and covers the top ${String(skirt)} mm of the neck, has flutes to grip and prints standing on its top (its print face is set); ${overhang < 45 ? leans("the threads' flanks on both parts", overhang).replace(/\.$/, '') : 'both parts print without support'}. ` + `Keep the neck a plain cylinder of ${String(spec.diameter)} mm there, with a wall of at least 2 mm.${warnings}` ); } @@ -757,14 +790,14 @@ async function mechanism(site: Site, args: Record, nozzle: numb return ( `Built the latch "${spec.name}": an arm ${String(spec.width)} mm wide and ${String(LATCH.thick)} mm thick grows out of the wall of ${spec.moving} (the lid), hangs ${String(LATCH.off)} mm off the wall over the joint and snaps with its hook under a bead ${String(LATCH.bead)} mm high on ${spec.fixed} (the box); the two overlap by ${String(LATCH.bead - LATCH.clear)} mm on 45° faces. ` + `Pressing the lid down snaps it shut (about 1.3 % strain at the arm's root); pulling the arm's lower end away from the box opens it, and so does a strong pull on the lid. It makes no joint. ` + - `It needs ${String(reach.above)} mm of flat wall on the lid above the joint and ${String(reach.below)} mm on the box below it. Print the lid on its top: the arm then grows out of it without support; the box prints standing. The arm stands upright that way and bends across its layers, the weak way: for a lid opened often use PETG or ABS rather than PLA. Not printed yet: how firmly it holds shows on the first print.${warnings}` + `It needs ${String(reach.above)} mm of flat wall on the lid above the joint and ${String(reach.below)} mm on the box below it. Print the lid on its top: ${overhang < 45 ? leans("the arm's foot and the bead's faces", overhang).replace(/\.$/, '') : 'the arm then grows out of it without support'}; the box prints standing. The arm stands upright that way and bends across its layers, the weak way: for a lid opened often use PETG or ABS rather than PLA. Not printed yet: how firmly it holds shows on the first print.${warnings}` ); } if (args.kind === 'plug' && args.shape === 'dovetail') { // The guide's own dovetail as a close fit: 0.2 mm, where guides for printed dovetails run from 0.1 to 0.3. const spec = slideOf({ ...args, gap: typeof args.gap === 'number' ? args.gap : 0.2, name: typeof args.name === 'string' && args.name.trim() !== '' ? args.name : 'Dovetail' }, nozzle); if (typeof spec === 'string') throw new Error(spec); - const { warnings, print } = await buildGuide(site, spec, nozzle); + const { warnings, print } = await buildGuide(site, spec, nozzle, overhang); return ( `Built the dovetail "${spec.name}" between ${spec.fixed} (carries the rail) and ${spec.moving} (has the groove): ${String(spec.width)} mm wide at its foot and ${String(spec.height)} mm high with 45° flanks, ${String(spec.gap)} mm clear all round. ` + `It makes no joint: ${spec.moving} is pushed on along the line and stops where the groove ends inside it; where the groove runs through, nothing stops it. ${print} Not printed yet: when it binds or rattles, build it again with another gap.${warnings}` @@ -786,15 +819,15 @@ async function mechanism(site: Site, args: Record, nozzle: numb throw new Error('kind must be hinge, slide, closure, screw, lid, snap, plug or latch'); } -/** The upstream tools plus `partbuddy_mechanism`; `nozzle` is the printer's nozzle in use, for the default gap. */ -export function withMechanismTool(upstream: UpstreamTools, nozzle: () => number, keepFace: KeepFace = () => undefined): UpstreamTools { +/** The upstream tools plus `partbuddy_mechanism`; `nozzle` is the printer's nozzle in use, for the default gap, and `overhang` how far the material in use leans without support (degrees), for what the answers promise. */ +export function withMechanismTool(upstream: UpstreamTools, nozzle: () => number, keepFace: KeepFace = () => undefined, overhang: () => number = () => 45): UpstreamTools { return { instructions: () => upstream.instructions(), listTools: async () => [...(await upstream.listTools()), mechanismTool], callTool: async (name, args, callId): Promise => { if (name !== MECHANISM_TOOL) return upstream.callTool(name, args, callId); try { - return { content: [{ type: 'text', text: await buildMechanism(upstream, args, nozzle(), callId, keepFace) }] }; + return { content: [{ type: 'text', text: await buildMechanism(upstream, args, nozzle(), callId, keepFace, overhang()) }] }; } catch (cause) { return { content: [{ type: 'text', text: `Not built: ${(cause as Error).message}` }], isError: true }; } diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 16239e3e..e8fd15d4 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -585,7 +585,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, () => printProfiles.part().material.overhang), keepMechanismFace), keepMechanismFace)), (trial) => supervisor.trialSteps(trial)), () => viewState, () => notesService.forView(), checkForView), () => viewState?.bodies.map((body) => body.name) ?? [], saveAssembly, ), diff --git a/apps/desktop/src/shared/templates.ts b/apps/desktop/src/shared/templates.ts index 29f1bb67..3659c0ab 100644 --- a/apps/desktop/src/shared/templates.ts +++ b/apps/desktop/src/shared/templates.ts @@ -96,11 +96,11 @@ function hingedBox(): Step[] { ...hingeSteps({ fixed: 'Box', moving: 'Lid', from: [start, between / 2, height], to: [start + hinge, between / 2, height], diameter, gap, knuckles, name: 'Hinge', support: { bed: 0, fixed: { toward: [0, -1, 0], distance: between / 2 }, moving: { toward: [0, 1, 0], distance: between / 2 } } }).map((step, i, steps) => { // The profiles (the bore's roof, the webs and their rooms, the pin's flat) are drawn in the world's own y and z and // moved along x, so they follow the sizes as the cylinders do. One drawn looking back along x is turned round: - // it starts where its extrusion ended. + // it starts where its extrusion ended. No web is drawn below the bed, however low the halves are set. const drawn = (steps[step.command === 'sketch.create' ? i : i - 1]?.params.plane ?? {}) as { origin?: Vec3; x_dir?: Vec3 }; const back = (drawn.x_dir?.[1] ?? 1) < 0; if (step.command === 'sketch.create') return { ...step, params: { ...step.params, plane: 'YZ', offset: `length / 2 + ${String(Math.round(((drawn.origin?.[0] ?? 0) - (back ? parseFloat(String(steps[i + 2]?.params.distance)) : 0) - length / 2) * 1000) / 1000)} mm` } }; - if (step.command === 'sketch.line') return { ...step, params: { ...step.params, points: (step.params.points as [number, number][]).map(([across, up]) => [`${String(between / 2 + (back ? -across : across))} mm`, `height + ${String(up)} mm`]) } }; + if (step.command === 'sketch.line') return { ...step, params: { ...step.params, points: (step.params.points as [number, number][]).map(([across, up]) => [`${String(between / 2 + (back ? -across : across))} mm`, up < 0 && steps[i + 1]?.params.operation === 'join' ? `max(height + ${String(up)} mm, 0 mm)` : `height + ${String(up)} mm`]) } }; if (step.command !== 'solid.cylinder') return step; const [x, y] = step.params.base as Vec3; return { ...step, params: { ...step.params, base: [`length / 2 + ${String(x - length / 2)} mm`, y, 'height'] } }; diff --git a/apps/desktop/test/mechanism.test.ts b/apps/desktop/test/mechanism.test.ts index 9c279426..037fbec2 100644 --- a/apps/desktop/test/mechanism.test.ts +++ b/apps/desktop/test/mechanism.test.ts @@ -123,6 +123,8 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { 'component.list joint.list', // Where the line lies, measured on trial with a rod that is taken back. 'solid.cylinder inspect.clearance inspect.measure sketch.inspect', + // The design as it is, to come back to should a later step fail. + 'document.inspect joint.list', [...Array.from({ length: 11 }, () => 'solid.cylinder'), 'sketch.create', 'sketch.line', 'solid.extrude', 'solid.cylinder', ...Array.from({ length: 2 }, () => ['sketch.create', 'sketch.line', 'solid.extrude']).flat()].join(' '), 'inspect.interference', 'component.list', @@ -131,8 +133,8 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { 'joint.sweep joint.sweep', 'joint.limits', ]); - expect(batches[6]?.[0]?.params).toMatchObject({ type: 'revolute', a: 'Base:1', b: 'Flap:1', at: { occurrence: 'Base:1', point: [42, 15, 3], z: [0, 30, 0] }, name: 'Lid hinge' }); - expect(batches[8]?.[0]?.params).toEqual({ joint: 'Lid hinge', min: -145, max: 67 }); + expect(batches[7]?.[0]?.params).toMatchObject({ type: 'revolute', a: 'Base:1', b: 'Flap:1', at: { occurrence: 'Base:1', point: [42, 15, 3], z: [0, 30, 0] }, name: 'Lid hinge' }); + expect(batches[9]?.[0]?.params).toEqual({ joint: 'Lid hinge', min: -145, max: 67 }); // Webs: with the line 16 mm above the bed and 2.5 mm from either wall, every knuckle gets a profile from where the barrel leans // 45° on the far side (2.121 mm from the axis) at 45° down to its own wall, 0.5 mm into it; on the bed (the line a radius above it) none is needed. @@ -178,6 +180,7 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { it('takes everything back when a knuckle does not touch its body, and says where the line goes', async () => { const calls: string[] = []; + let depth = 3; const upstream: UpstreamTools = { instructions: () => undefined, listTools: () => Promise.resolve([]), @@ -186,10 +189,12 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { // Before: three steps to undo. Of the cylinders the seven knuckles (48 mm of hinge line) do not touch their body, as SolidCraft 0.40.0-rc.3 warns. if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Box' }, { name: 'Lid' }], undo: 3 }) }] }); const commands = args.commands as { command: string; params: { operation?: string; diameter?: string } }[]; - calls.push(...commands.filter((step) => step.command === 'edit.undo').map((step) => step.command)); + calls.push(...commands.filter((step) => step.command === 'edit.undo' || step.command === 'component.activate').map((step) => step.command)); + if (commands.some((step) => step.params.operation === 'join')) depth = 11; + depth -= commands.filter((step) => step.command === 'edit.undo').length; const results = commands.map((step) => step.command === 'document.inspect' - ? { bodies: [{ name: 'Box' }, { name: 'Lid' }], undo: 11 } + ? { bodies: ['Box', 'Lid'], timeline: Array.from({ length: depth }, (_, i) => ({ id: i + 1 })), undo: depth } : step.params.operation === 'join' && step.params.diameter === '6 mm' ? { warning: "the joined piece doesn't touch `Box`, so `Box` is now in 2 separate pieces (New Body makes it a body of its own)" } : { count: 0, warning: null }, @@ -203,43 +208,44 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { text: 'Not built: 7 piece(s) did not touch their body and stand apart from it. Put the hinge line within 2.5 mm of both bodies (the barrel is 6 mm, so it must overlap each by at least 0.5 mm). ' + "PartBuddy cuts each knuckle's room in the other body itself. Everything was taken back.", }); - // Every step SolidCraft counted since (11 − 3) is undone in one batch, and no joint was made. - expect(calls).toEqual(['inspect_design', 'batch', 'batch', 'batch', 'batch', 'batch', ...Array.from({ length: 8 }, () => 'edit.undo')]); + // Every step SolidCraft counted since (11 − 3) is undone in one batch, the design level is made active again, and no joint was made. + expect(calls.filter((call) => call !== 'batch')).toEqual(['inspect_design', ...Array.from({ length: 8 }, () => 'edit.undo'), 'component.activate']); + expect(depth).toBe(3); }); // Audit 2026-11-12, H-02: the joint was refused after the knuckles were built, the answer said "Not built" with // SolidCraft's JSON, and the knuckles stayed (2 → 11 features; the next call built them again). it('takes the knuckles back when the joint cannot be made, and passes on SolidCraft\'s reason in one sentence', async () => { const refused = { applied: [], completed: 0, failed: { command: 'joint.as_built', error: 'invalid parameters for `joint.create`: a joint needs two different occurrences', index: 0 }, results: [] }; - const tools = (undo: (depth: number) => unknown) => { + const tools = (readable: boolean) => { const sent: string[] = []; let depth = 2; const upstream: UpstreamTools = { instructions: () => undefined, listTools: () => Promise.resolve([]), callTool: (name, args) => { - if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Base' }, { name: 'Flap' }], undo: undo(depth) }) }] }); + if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Base' }, { name: 'Flap' }] }) }] }); const commands = args.commands as { command: string; params: { operation?: string } }[]; sent.push(commands.map((step) => step.command).join(' ')); // As SolidCraft 0.40.1 answers a batch whose step fails: marked as an error, the reason inside its JSON. if (commands[0]?.command === 'joint.as_built') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify(refused, null, 2) }], isError: true }); // The probe's batch fails on purpose and SolidCraft rolls it back. if (!commands.some((step) => step.command === 'sketch.inspect')) depth += commands.filter((step) => step.params.operation !== undefined).length - commands.filter((step) => step.command === 'edit.undo').length; - const results = commands.map((step) => (step.command === 'document.inspect' ? { undo: undo(depth) } : step.command === 'component.list' ? { components: [{ id: 3, parent: 0, bodies: ['Base', 'Flap'], occurrences: [{ id: 4, name: 'Both:1' }] }] } : {})); + const results = commands.map((step) => (step.command === 'document.inspect' ? (readable ? { timeline: Array.from({ length: depth }, (_, i) => ({ id: i + 1 })), undo: depth } : {}) : step.command === 'component.list' ? { components: [{ id: 3, parent: 0, bodies: ['Base', 'Flap'], occurrences: [{ id: 4, name: 'Both:1' }] }] } : {})); return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }] }); }, }; return { sent, depth: () => depth, call: () => withMechanismTool(upstream, () => 0.4).callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42, 0, 3], to: [42, 30, 3], knuckles: 3 }) }; }; - const taken = tools((depth) => depth); + const taken = tools(true); expect(await taken.call()).toEqual({ isError: true, content: [{ type: 'text', text: 'Not built: SolidCraft refused joint.as_built: invalid parameters for `joint.create`: a joint needs two different occurrences. Everything was taken back.' }] }); - expect(taken.sent.at(-1)).toBe(Array.from({ length: 10 }, () => 'edit.undo').join(' ')); + expect(taken.sent.slice(-2)).toEqual([`${Array.from({ length: 10 }, () => 'edit.undo').join(' ')} document.inspect joint.list`, 'component.activate']); expect(taken.depth()).toBe(2); // A second call starts from the same design: it does not build on top of the first. await taken.call(); expect(taken.depth()).toBe(2); - // Where SolidCraft's undo depth cannot be read nothing is taken back, and the answer says so instead of "as it was". - const stuck = tools(() => undefined); + // Where SolidCraft does not say what the design consists of nothing is taken back, and the answer says so instead of "as it was". + const stuck = tools(false); expect((await stuck.call()).content[0]).toMatchObject({ text: expect.stringMatching(/^Not built: SolidCraft refused joint\.as_built: .* What was begun could not be taken back: undo it before you call again\.$/) as string }); expect(stuck.sent.join(' ')).not.toContain('edit.undo'); }); @@ -257,24 +263,36 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { batches.push(commands); const results = commands.map((step) => { // As SolidCraft 0.40.1 lists them: a joint's sides by the ids of their occurrences. - if (step.command === 'joint.list') return { joints: [{ name: 'Hinge', type: 'revolute', a: 15, b: 17, values: [{ value: 0 }] }] }; + if (step.command === 'joint.list') return { joints: [{ name: 'Hinge', type: 'revolute', a: 15, b: 17, frame_a: { origin: [42, 15, 3], z: [0, 1, 0] }, values: [{ value: 0, limits: [-145, 67] }] }] }; if (step.command === 'component.list') return { components: [{ id: 14, parent: 0, bodies: ['Base'], occurrences: [{ id: 15, name: 'Base:1' }] }, { id: 16, parent: 0, bodies: ['Flap'], occurrences: [{ id: 17, name: 'Flap:1' }] }, { id: 31, parent: 0, bodies: ['Flap2'], occurrences: [{ id: 32, name: 'Flap2:1' }] }] }; - return step.command === 'joint.sweep' ? { stopped_at: step.params.to === 180 ? 67.4 : -145.8 } : {}; + return step.command === 'joint.sweep' ? { stopped_at: Number(step.params.to) > 0 ? 67.4 : -145.8 } : {}; }); return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }] }); }, }; const tools = withMechanismTool(upstream, () => 0.4); - const again = await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Flap', moving: 'Base', from: [42, 0, 3], to: [42, 30, 3], name: 'Another' }); - expect(again).toEqual({ isError: true, content: [{ type: 'text', text: 'Not built: the joint "Hinge" already joins Flap and Base as a hinge, and a second hinge between the same two bodies would bind it. Change that one instead: take it back with undo, or delete its features and its joint, then call again' }] }); + const again = await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Flap', moving: 'Base', from: [42, 40, 3.5], to: [42, 70, 3.5], name: 'Another' }); + // Half a millimetre off the first hinge's line it is a second axis, which would bind the first. + expect(again).toEqual({ isError: true, content: [{ type: 'text', text: 'Not built: the joint "Hinge" already joins Flap and Base as a hinge on another line, and a second axis between the same two bodies would bind it. Put this hinge on the line of the first, or change the first: take it back with undo, or delete its features and its joint, then call again.' }] }); expect(batches.map((batch) => batch.map((step) => step.command).join(' '))).toEqual(['component.list joint.list']); + // Review of PR #358: on the line of the first it is the usual second hinge of a long flap. It gets its knuckles and no joint of + // its own: the first joint is run through its limits again, since more knuckles can only narrow them. + const before = batches.length; + const coaxial = await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42, 90, 3], to: [42, 120, 3] }); + expect(coaxial.isError).toBeUndefined(); + expect(String(coaxial.content[0]?.text)).toContain('Built the hinge "Hinge (2)" between Base (stays) and Flap (turns): 5 knuckles, barrel 6 mm, pin 3 mm, gap 0.4 mm everywhere. It turns from -145° to 67°'); + expect(String(coaxial.content[0]?.text)).toContain(' It lies on the line of the hinge "Hinge" and turns about that hinge\'s joint, which carries both now.'); + const shared = batches.slice(before).flat(); + expect(shared.some((step) => step.command === 'joint.as_built')).toBe(false); + expect(shared.filter((step) => step.command === 'joint.sweep').map((step) => [step.params.joint, step.params.to])).toEqual([['Hinge', -145], ['Hinge', 67]]); + expect(shared.find((step) => step.command === 'joint.limits')?.params).toEqual({ joint: 'Hinge', min: -145, max: 67 }); // Another pair of bodies: built, under a name of its own, which the joint, its sweeps and its limits all carry. const second = await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap2', from: [-2, 0, 3], to: [-2, 30, 3] }); expect(second.isError).toBeUndefined(); expect(second.content[0]).toMatchObject({ text: expect.stringContaining('Built the hinge "Hinge (2)" between Base (stays) and Flap2 (turns)') as string }); const sent = batches.flat(); - expect(sent.find((step) => step.command === 'joint.as_built')?.params).toMatchObject({ a: 'Base:1', b: 'Flap2:1', name: 'Hinge (2)' }); - expect(sent.filter((step) => step.command === 'joint.sweep' || step.command === 'joint.limits').map((step) => step.params.joint)).toEqual(['Hinge (2)', 'Hinge (2)', 'Hinge (2)']); + expect(sent.filter((step) => step.command === 'joint.as_built').map((step) => step.params)).toMatchObject([{ a: 'Base:1', b: 'Flap2:1', name: 'Hinge (2)' }]); + expect(sent.filter((step) => step.command === 'joint.sweep' || step.command === 'joint.limits').map((step) => step.params.joint).slice(-3)).toEqual(['Hinge (2)', 'Hinge (2)', 'Hinge (2)']); // A guide between the two bodies of the hinge is another kind of joint: it is not refused for the hinge. const guide = await tools.callTool('partbuddy_mechanism', { kind: 'slide', fixed: 'Base', moving: 'Flap', from: [0, 15, 4], to: [40, 15, 4] }); expect(guide.content[0]).toMatchObject({ text: expect.not.stringContaining('already joins') as string }); @@ -284,7 +302,7 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { // overhang the print check still found, and over a web that hung 4.6 mm under a lid plate with no wall below it. it('gives a web only to a body whose wall reaches the web\'s foot, and says what prints without support only where it holds', async () => { // Two blocks 16 mm high, 5 mm apart, the line on their rims: the rod on the line is 2.45 mm from either wall. - const answer = async (walls: string[], args: Record = {}) => { + const answer = async (walls: string[], args: Record = {}, overhang = 45) => { const sent: { command: string; params: Record }[] = []; const upstream: UpstreamTools = { instructions: () => undefined, @@ -305,7 +323,7 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }] }); }, }; - const built = await withMechanismTool(upstream, () => 0.4).callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42.5, 0, 16], to: [42.5, 30, 16], ...args }); + const built = await withMechanismTool(upstream, () => 0.4, undefined, () => overhang).callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42.5, 0, 16], to: [42.5, 30, 16], ...args }); return { text: String(built.content[0]?.text), sent, webs: sent.filter((step) => step.command === 'solid.extrude' && String(step.params.name).endsWith('◣')).map((step) => (step.params.targets as string[])[0]) }; }; const both = await answer(['Base', 'Flap']); @@ -326,6 +344,14 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { const wide = await answer(['Base', 'Flap'], { knuckles: 3 }); expect(wide.text).not.toContain('without support'); expect(wide.text).toContain('where it bridges from knuckle to knuckle. The pin bridges 10.5 mm inside each knuckle of Flap, more than prints clean (10 mm): use more knuckles.'); + // Nine knuckles is the most: from there the way on is a second hinge on the same line, which is not refused. + const long = await answer(['Base', 'Flap'], { to: [42.5, 100, 16] }); + expect(long.text).toContain('The pin bridges 11.6 mm inside each knuckle of Flap, more than prints clean (10 mm): build two shorter hinges on the same line instead of this one.'); + // A material that prints 40° (PETG): the webs are there, but they lean 45°, and the answer does not promise them to it. + const petg = await answer(['Base', 'Flap'], {}, 40); + expect(petg.webs).toHaveLength(5); + expect(petg.text).not.toContain('without support.'); + expect(petg.text).toContain('where it bridges from knuckle to knuckle. Webs, barrels and the bore lean 45°, more than the 40° the material in use prints without support: they need support there, or a material that prints 45°.'); }); it('builds onto a body that is a component with that component active, so the knuckles move with it', () => { @@ -426,7 +452,7 @@ describe('the dovetail guide (ADR 0027, G3)', () => { // The rail is the same. expect(roofed.slice(3)).toEqual(slideSteps(guide).slice(3)); // The tool measures the moving body first: its top lies `top` over the base's face at z = 4. - const answer = async (top: number, args: Record = {}) => { + const answer = async (top: number, args: Record = {}, { solid = top, overhang = 45, split = false } = {}) => { const sent: { command: string; params: Record }[] = []; const upstream: UpstreamTools = { instructions: () => undefined, @@ -435,11 +461,14 @@ describe('the dovetail guide (ADR 0027, G3)', () => { if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Base' }, { name: 'Carriage' }], undo: 2 }) }] }); const commands = call.commands as { command: string; params: Record }[]; sent.push(...commands); - const results = commands.map((step) => (step.command === 'inspect.measure' ? { bodies: [{ name: 'Carriage', bbox: { min: [20, 0, 4.4], max: [40, 30, 4 + top] } }] } : step.command === 'component.list' ? { components: [{ id: 3, parent: 0, bodies: ['Base'], occurrences: [{ id: 4, name: 'Base:1' }] }, { id: 5, parent: 0, bodies: ['Carriage'], occurrences: [{ id: 6, name: 'Carriage:1' }] }] } : {})); + // The trial rods lie in the carriage where it is solid: up to `solid` over the base's face, along its 20 mm of the line (0.628 mm³ each). + const rods = commands.filter((step) => step.command === 'solid.cylinder' && String(step.params.body_name).startsWith('PartBuddy probe ')); + if (rods.length > 0) expect(rods.map((rod) => [(rod.params.base as number[])[0], rod.params.height])).toEqual(Array.from({ length: 5 }, () => [20, 20])); + const results = commands.map((step) => (step.command === 'inspect.clearance' ? { overlaps: rods.flatMap((rod) => (((rod.params.base as number[])[2] ?? 0) + 0.1 <= 4 + solid ? [{ a: rod.params.body_name, b: 'Carriage', volume_mm3: 0.628 }] : [])) } : step.command === 'solid.extrude' && step.params.operation === 'cut' && split ? { created_bodies: ['Carriage (1)'], warning: null } : step.command === 'document.inspect' ? { timeline: [{ id: 1 }, { id: 2 }], undo: 2 } : step.command === 'inspect.measure' ? { bodies: [{ name: 'Carriage', bbox: { min: [20, 0, 4.4], max: [40, 30, 4 + top] } }] } : step.command === 'component.list' ? { components: [{ id: 3, parent: 0, bodies: ['Base'], occurrences: [{ id: 4, name: 'Base:1' }] }, { id: 5, parent: 0, bodies: ['Carriage'], occurrences: [{ id: 6, name: 'Carriage:1' }] }] } : {})); return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }] }); }, }; - const built = await withMechanismTool(upstream, () => 0.4).callTool('partbuddy_mechanism', { kind: 'slide', fixed: 'Base', moving: 'Carriage', from: [0, 15, 4], to: [60, 15, 4], ...args }); + const built = await withMechanismTool(upstream, () => 0.4, undefined, () => overhang).callTool('partbuddy_mechanism', { kind: 'slide', fixed: 'Base', moving: 'Carriage', from: [0, 15, 4], to: [60, 15, 4], ...args }); return { text: String(built.content[0]?.text), groove: sent.find((step) => step.command === 'sketch.line')?.params.points as number[][], sent: sent.map((step) => step.command) }; }; // 7.866 mm of roof and two lines over its ridge: 8.8 mm. @@ -449,10 +478,27 @@ describe('the dovetail guide (ADR 0027, G3)', () => { // The body is measured before the first step is built. expect(high.sent.indexOf('inspect.measure')).toBeLessThan(high.sent.indexOf('sketch.create')); // Lower than that the ceiling stays flat, and the answer says what that means instead of "without support". - const low = await answer(8.7); + const low = await answer(8.5); expect(low.groove).toHaveLength(6); expect(low.text).not.toContain('Neither needs support'); - expect(low.text).toContain("The groove's ceiling is flat and 13.9 mm wide, more than prints as a bridge (10 mm): a roof at 45° over it needs Carriage to stand 8.8 mm high over the face the rail is on, and it stands 8.7 mm. Print Carriage turned over instead, lying on its top with the groove open upwards (set that face as its print face), or make it higher and build this again."); + expect(low.text).toContain("The groove's ceiling is flat and 13.9 mm wide, more than prints as a bridge (10 mm): a roof at 45° over it needs Carriage to be solid up to 8.8 mm over the face the rail is on, all along the groove, and it is not. Print Carriage turned over instead, lying on its top with the groove open upwards (set that face as its print face), or make it that high there and build this again."); + // Review of PR #358: a plate 6 mm thick with a knob on it is 16.4 mm high by its box, and the roof (ridge at 7.9 mm) cut it in + // two along the rail under "Neither needs support". The rods find the plate, not the box. + const knob = await answer(16.4, {}, { solid: 6 }); + expect(knob.groove).toHaveLength(6); + expect(knob.text).not.toContain('Neither needs support'); + // Whatever cuts a body apart, SolidCraft says so only in created_bodies: that fails the call and takes it back. + const cut = await answer(12, {}, { split: true }); + expect(cut.text).toBe('Not built: the step "Guide ·" cut "Carriage" apart ("Carriage (1)" came of it): the body is too thin or too small where the mechanism goes. Put the line from–to on a face of Base, with up as that face\'s normal, so the rail stands on it. Everything was taken back.'); + // A small rail under a low body: its flat ceiling of 6.9 mm is a bridge that prints, and the answer says that, not "turn it over". + const small = await answer(2.9, { width: 2, height: 1.5 }); + expect(small.groove).toHaveLength(6); + expect(small.text).toContain("The groove's ceiling is flat and 6.9 mm wide, which prints as a bridge (up to 10 mm), and the rail's flanks lean 45°: neither needs support."); + // A material that prints 40° (PETG): flanks and roof lean 45°, so nothing is promised for it. + const petg = await answer(12, {}, { overhang: 40 }); + expect(petg.groove).toHaveLength(8); + expect(petg.text).not.toContain('Neither needs support'); + expect(petg.text).toContain("The rail's flanks and the roof over the groove lean 45°, more than the 40° the material in use prints without support: they need support there, or a material that prints 45°."); // The dovetail to plug together is the same groove: the same roof, the same words. const plug = await answer(12, { kind: 'plug', shape: 'dovetail' }); expect(plug.groove).toHaveLength(8); @@ -559,7 +605,7 @@ describe('the snap closure of a lid (G3)', () => { const built = await withMechanismTool(upstream, () => 0.4).callTool('partbuddy_mechanism', { kind: 'closure', fixed: 'Box', moving: 'Lid', from: [2, 2, 20], to: [58, 38, 20], up: [0, 0, -1], side: 'y-' }); expect(built.isError).toBeUndefined(); expect(built.content[0]).toMatchObject({ text: expect.stringContaining('Built the closure "Closure" between Box (the box) and Lid (the lid): a lip 1.2 mm thick and 4 mm high inside the lid, 0.25 mm clear') as string }); - expect(batches).toHaveLength(3); + expect(batches).toHaveLength(4); expect(batches.join(' ')).not.toContain('joint.as_built'); }); }); diff --git a/apps/desktop/test/templates.test.ts b/apps/desktop/test/templates.test.ts index 5b79b0a2..382c9f22 100644 --- a/apps/desktop/test/templates.test.ts +++ b/apps/desktop/test/templates.test.ts @@ -26,7 +26,7 @@ describe('templates (F3)', () => { for (const step of steps.slice(declared.length)) { for (const [key, value] of Object.entries(step.params)) { if (!EXPRESSION_FIELDS.has(key)) continue; - for (const text of expressions(value)) for (const word of text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) if (word !== 'mm') used.add(word); + for (const text of expressions(value)) for (const word of text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) if (word !== 'mm' && word !== 'max') used.add(word); } } expect([...used].filter((word) => !names.has(word)), id).toEqual([]); @@ -123,9 +123,12 @@ describe('templates with moving parts', () => { // Each is drawn on the world's YZ plane at the knuckle's low end in x, from the rim down, so it follows length and height: // the box's from the line (y 0.5) towards -y to 0.5 mm inside its wall, the lid's towards +y. const drawn = (name: string) => steps.slice(steps.findIndex((step) => step.command === 'sketch.create' && step.params.name === name)).slice(0, 2).map((step) => step.params); - expect(drawn('Hinge 1 ◣')).toEqual([{ plane: 'YZ', offset: 'length / 2 + -20 mm', project_edges: false, name: 'Hinge 1 ◣' }, { points: [['3.328 mm', 'height + 0 mm'], ['3.328 mm', 'height + -2.828 mm'], ['0 mm', 'height + -6.157 mm'], ['-0.5 mm', 'height + -6.157 mm'], ['-0.5 mm', 'height + 0 mm']], closed: true }]); + expect(drawn('Hinge 1 ◣')).toEqual([{ plane: 'YZ', offset: 'length / 2 + -20 mm', project_edges: false, name: 'Hinge 1 ◣' }, { points: [['3.328 mm', 'height + 0 mm'], ['3.328 mm', 'max(height + -2.828 mm, 0 mm)'], ['0 mm', 'max(height + -6.157 mm, 0 mm)'], ['-0.5 mm', 'max(height + -6.157 mm, 0 mm)'], ['-0.5 mm', 'height + 0 mm']], closed: true }]); + // Review of PR #358: "at least 8 mm" stood only in the parameter's comment, and at 5 mm the webs reached 1.16 mm below the + // floor. A web ends on the bed at the latest; its room in the other half is a cut and may go on. + expect(JSON.stringify(drawn('Hinge 1 ◤')[1]?.points)).not.toContain('max('); expect(drawn('Hinge 2 ◣')[0]).toMatchObject({ plane: 'YZ', offset: 'length / 2 + -11.92 mm' }); - expect((drawn('Hinge 2 ◣')[1]?.points as string[][])[2]).toEqual(['1 mm', 'height + -6.157 mm']); + expect((drawn('Hinge 2 ◣')[1]?.points as string[][])[2]).toEqual(['1 mm', 'max(height + -6.157 mm, 0 mm)']); expect(germanFeatureName('Hinge 1 ◣')).toBe('Scharnier 1 ◣'); expect(germanFeatureName('Hinge 2 ▬')).toBe('Scharnier 2 ▬'); // The joint is kept on the pin's round top: underneath the pin is flat now. From 4a6a0eae4b5fd031b1d34c9b4942a043aba04aa0 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:57:33 +0100 Subject: [PATCH 19/24] fix(desktop): the groove's roof is tried over its flanks, and a hinge is not built twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review of PR #358. - The two flank rods lay in the groove beside the ridge, not over the middle of each flank: a plate with a rib over the rail got a roof that cut it along both sides of the rib. The test now fixes where all five rods lie. - A second hinge call on the stretch of the first is refused again (H-02). - A rod may lie outside the body by its own width at the most, not by a tenth of the groove; a trial that failed or went by the box of a body on a line askew to the axes is said as that, not as "it is not solid". - A shared joint whose sweep fails keeps its old limits, and the answer says so. - The message for a body cut apart no longer adds where to put the line. - Hinged box: a web cut short by the bed keeps its face at 45°. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/agents/mechanismTool.ts | 31 +++++++++---- apps/desktop/src/shared/templates.ts | 5 +- apps/desktop/test/mechanism.test.ts | 46 +++++++++++++++++-- apps/desktop/test/templates.test.ts | 8 ++-- 4 files changed, 72 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/main/agents/mechanismTool.ts b/apps/desktop/src/main/agents/mechanismTool.ts index f39c9ca3..720a9fc3 100644 --- a/apps/desktop/src/main/agents/mechanismTool.ts +++ b/apps/desktop/src/main/agents/mechanismTool.ts @@ -369,6 +369,10 @@ async function build(site: Site, spec: { fixed: string; moving: string; name: st const line = spec.from && spec.to ? spec.to.map((value, i) => value - (spec.from?.[i] ?? 0)) : null; const off = (vector: number[], along: number[]) => Math.hypot(...[0, 1, 2].map((i) => (vector[(i + 1) % 3] ?? 0) * (along[(i + 2) % 3] ?? 0) - (vector[(i + 2) % 3] ?? 0) * (along[(i + 1) % 3] ?? 0))) / Math.hypot(...along); const onLine = joins === 'revolute' && origin && z && line && spec.from && off(z, line) < 1e-3 * Math.hypot(...z) && off(origin.map((value, i) => value - (spec.from?.[i] ?? 0)), line) < 0.01; + // On the stretch of the first itself the new knuckles would be cut into the old ones (audit 2026-11-12, H-02): the + // first joint lies in the middle of its hinge, so a line that holds that point lies over it. + const at = onLine ? origin.reduce((sum, value, i) => sum + (value - (spec.from?.[i] ?? 0)) * (line[i] ?? 0), 0) / line.reduce((sum, value) => sum + value * value, 0) : -1; + if (at >= 0 && at <= 1) throw new Error(`the hinge ${JSON.stringify(same.name)} already joins ${spec.fixed} and ${spec.moving} on this stretch of the line, and new knuckles would be cut into its own. Change that one instead: take it back with undo, or delete its features and its joint, then call again. A second hinge goes on the same line beside the first.`); if (!onLine) throw new Error(`the joint ${JSON.stringify(same.name)} already joins ${spec.fixed} and ${spec.moving} as a ${kind}${joins === 'revolute' ? ' on another line' : ''}, and a second ${joins === 'revolute' ? 'axis' : 'guide'} between the same two bodies would bind it. ${joins === 'revolute' ? 'Put this hinge on the line of the first, or change the first' : 'Change that one instead'}: take it back with undo, or delete its features and its joint, then call again.`); const limits = same.values?.[0]?.limits; site.shares = { name: String(same.name), limits: Array.isArray(limits) && typeof limits[0] === 'number' && typeof limits[1] === 'number' ? [limits[0], limits[1]] : [-180, 180] }; @@ -388,7 +392,7 @@ async function build(site: Site, spec: { fixed: string; moving: string; name: st const made = [result?.created_bodies, result?.removed_bodies].flatMap((names) => (Array.isArray(names) ? (names as unknown[]) : [])); // A step that makes its own body (a lid, a screw) says so; every other one works on a body that is there. if (made.length === 0 || step.params.operation === 'new' || step.command === 'parts.insert') continue; - throw new Error(`the step ${JSON.stringify(step.params.name ?? step.command)} cut ${JSON.stringify((step.params.targets as unknown[] | undefined)?.[0] ?? step.params.body ?? 'a body')} apart (${made.map((name) => JSON.stringify(name)).join(', ')} came of it): the body is too thin or too small where the mechanism goes. ${where}`); + throw new Error(`the step ${JSON.stringify(step.params.name ?? step.command)} cut ${JSON.stringify((step.params.targets as unknown[] | undefined)?.[0] ?? step.params.body ?? 'a body')} apart (${made.map((name) => JSON.stringify(name)).join(', ')} came of it): the body is too thin or too small where the mechanism goes.`); } const apart = built.filter((result) => { const warning = (result as { warning?: unknown } | undefined)?.warning; @@ -562,8 +566,8 @@ export function skinProblem(depths: { fixed: number; moving: number }, spec: { f const MOVE = 'The user moves it with the slider "Moving parts" in the view.'; /** What the model hears when the joint is made but its range could not be found. */ -const unswept = (reason: string) => - `SolidCraft could not run it through its range (${reason.slice(0, 160)}), so no limits are set. The two bodies probably lie against each other somewhere: look with inspect.clearance, make room, then set joint.limits yourself after joint.sweep.`; +const unswept = (reason: string, shares?: string) => + `SolidCraft could not run it through its range (${reason.slice(0, 160)}), so ${shares ? `the limits of "${shares}" stay as they were, untried with this hinge` : 'no limits are set'}. The two bodies probably lie against each other somewhere: look with inspect.clearance, make room, then set joint.limits yourself after joint.sweep.`; /** What leans 45° prints without support only in a material that allows it: PETG and the carbon-filled ones stop at 40°. */ const leans = (what: string, overhang: number) => `${what} lean 45°, more than the ${String(overhang)}° the material in use prints without support: they need support there, or a material that prints 45°.`; @@ -607,6 +611,8 @@ async function buildGuide(site: Site, spec: SlideSpec, nozzle: number, overhang: const skin = nozzle * 2 * 1.125; const needed = Math.round((roof.top + skin) * 10) / 10; let measure: unknown; + // Why the groove got no roof, when it is not that the body was found short of material there. + const roofless: { why: 'failed' | 'askew' | null } = { why: null }; const warnings = await build( site, spec, @@ -625,24 +631,31 @@ async function buildGuide(site: Site, spec: SlideSpec, nozzle: number, overhang: // The stretch of the line the body lies over: its travel either way is what of the rail it leaves free. const [start, covered] = [-travel[0], length + travel[0] - travel[1]]; // Over the ridge and its two edges, and over the middle of each flank: each rod's top where the skin over the roof ends. - const flank = (roof.ceiling + 2 * roof.ridge) / 8; + const flank = (roof.ceiling + roof.ridge) / 4; const [ridge, slope] = [roof.top + skin - ROD / 2, (spec.height + spec.gap + roof.top) / 2 + skin * Math.SQRT2 - ROD / 2]; const over: [number, number][] = [[0, ridge], [roof.ridge / 2, ridge], [-roof.ridge / 2, ridge], [flank, slope], [-flank, slope]]; const rods = over.map(([x, y], n) => ({ name: `${PROBE} ${String(n + 1)}`, body: spec.moving, base: spec.from.map((value, i) => value + (along[i] ?? 0) * start + (across[i] ?? 0) * x + (up[i] ?? 0) * y) })); - const shares = await rodsInside(site.upstream, rods, axis, covered, site.callId).catch(() => new Map()); - spec.roof = rods.every((rod) => (shares.get(rod.name) ?? 0) >= 0.9); + const shares = await rodsInside(site.upstream, rods, axis, covered, site.callId).catch(() => null); + // No more than a rod's width of any rod may lie outside the body: through a cross groove the roof would break out. + spec.roof = shares !== null && covered > 0 && rods.every((rod) => (1 - (shares.get(rod.name) ?? 0)) * covered <= ROD); + // shortcut: the rods are as long as the body's box along the line, so over a line askew to the axes (or a body + // turned on its face) they reach past the body and no roof is built; lay them by the body itself when that turns up. + roofless.why = spec.roof ? null : shares === null ? 'failed' : along.filter((value) => Math.abs(value) > 1e-6).length > 1 ? 'askew' : null; }, joins, ); + const untried = roofless.why; const apart = `The two print apart, each with up pointing up, and ${spec.moving} is then pushed onto the rail from an end.`; const wide = String(Math.round(roof.ceiling * 10) / 10); const flanks = spec.roof ? "The rail's flanks and the roof over the groove" : "The rail's flanks"; const steep = overhang < 45 ? leans(flanks, overhang) : null; + const solid = `solid up to ${String(needed)} mm over the face the rail is on`; + const box = "the line runs askew to the axes, where the trial goes by the body's box and can reach past the body"; const print = spec.roof ? `${apart} ${steep ?? `Neither needs support: the rail's flanks and the roof over the groove lean 45°, and the roof's ridge is a bridge of ${String(roof.ridge)} mm.`}` : roof.ceiling <= BRIDGE_SPAN - ? `${apart} The groove's ceiling is flat and ${wide} mm wide, which prints as a bridge (up to ${String(BRIDGE_SPAN)} mm)${steep ? `. ${steep}` : ", and the rail's flanks lean 45°: neither needs support."} ${spec.moving} is not solid up to ${String(needed)} mm over the face the rail is on all along the groove, so the groove got no roof.` - : `${apart} The groove's ceiling is flat and ${wide} mm wide, more than prints as a bridge (${String(BRIDGE_SPAN)} mm): a roof at 45° over it needs ${spec.moving} to be solid up to ${String(needed)} mm over the face the rail is on, all along the groove, and it is not. Print ${spec.moving} turned over instead, lying on its top with the groove open upwards (set that face as its print face), or make it that high there and build this again.${steep ? ` ${steep}` : ''}`; + ? `${apart} The groove's ceiling is flat and ${wide} mm wide, which prints as a bridge (up to ${String(BRIDGE_SPAN)} mm)${steep ? `. ${steep}` : ", and the rail's flanks lean 45°: neither needs support."} ${untried === 'failed' ? `Whether ${spec.moving} is ${solid} could not be tried (SolidCraft's trial failed)` : untried === 'askew' ? `The trial did not find ${spec.moving} ${solid} all along the groove (${box})` : `${spec.moving} is not ${solid} all along the groove`}, so the groove got no roof.` + : `${apart} The groove's ceiling is flat and ${wide} mm wide, more than prints as a bridge (${String(BRIDGE_SPAN)} mm): a roof at 45° over it needs ${spec.moving} to be ${solid}, all along the groove, and ${untried === 'failed' ? "whether it is could not be tried (SolidCraft's trial failed)" : untried === 'askew' ? `the trial did not find it so (${box})` : 'it is not'}. Print ${spec.moving} turned over instead, lying on its top with the groove open upwards (set that face as its print face), or ${untried ? 'make sure it is' : 'make it'} that high there and build this again.${steep ? ` ${steep}` : ''}`; return { warnings, measure, print }; } @@ -701,7 +714,7 @@ async function mechanism(site: Site, args: Record, nozzle: numb // A hinge on the line of an earlier one only narrows what that joint allows: it is run through its limits again. const range = await join(run, spec, 'revolute', site.shares?.limits ?? [-180, 180], 1, site.shares?.name); const print = `${site.shares ? ` It lies on the line of the hinge "${site.shares.name}" and turns about that hinge's joint, which carries both now.` : ''}${hingePrint(spec, depths, overhang)}`; - if (typeof range === 'string') return `Built the hinge "${spec.name}" between ${spec.fixed} (stays) and ${spec.moving} (turns) and joined them, but ${unswept(range)}${print}${warnings}`; + if (typeof range === 'string') return `Built the hinge "${spec.name}" between ${spec.fixed} (stays) and ${spec.moving} (turns) and joined them, but ${unswept(range, site.shares?.name)}${print}${warnings}`; const [min, max] = range; return ( `Built the hinge "${spec.name}" between ${spec.fixed} (stays) and ${spec.moving} (turns): ${String(spec.knuckles)} knuckles, barrel ${String(spec.diameter)} mm, pin ${String(pinDiameter(spec.diameter))} mm, ` + diff --git a/apps/desktop/src/shared/templates.ts b/apps/desktop/src/shared/templates.ts index 3659c0ab..1f9fa002 100644 --- a/apps/desktop/src/shared/templates.ts +++ b/apps/desktop/src/shared/templates.ts @@ -100,7 +100,10 @@ function hingedBox(): Step[] { const drawn = (steps[step.command === 'sketch.create' ? i : i - 1]?.params.plane ?? {}) as { origin?: Vec3; x_dir?: Vec3 }; const back = (drawn.x_dir?.[1] ?? 1) < 0; if (step.command === 'sketch.create') return { ...step, params: { ...step.params, plane: 'YZ', offset: `length / 2 + ${String(Math.round(((drawn.origin?.[0] ?? 0) - (back ? parseFloat(String(steps[i + 2]?.params.distance)) : 0) - length / 2) * 1000) / 1000)} mm` } }; - if (step.command === 'sketch.line') return { ...step, params: { ...step.params, points: (step.params.points as [number, number][]).map(([across, up]) => [`${String(between / 2 + (back ? -across : across))} mm`, up < 0 && steps[i + 1]?.params.operation === 'join' ? `max(height + ${String(up)} mm, 0 mm)` : `height + ${String(up)} mm`]) } }; + // A web's third point is the foot of its face at 45°: where the bed cuts the web short, the foot comes in along + // that face by as much, so the face keeps its 45° (at height 5 mm it leant 57° with the foot only lifted). + const web = String(steps[i + 1]?.params.name).endsWith('◣'); + if (step.command === 'sketch.line') return { ...step, params: { ...step.params, points: (step.params.points as [number, number][]).map(([across, up], n) => [web && n === 2 ? `${String(between / 2)} mm ${back ? '-' : '+'} min(${String(across)} mm, height + ${String(Math.round((across + up) * 1000) / 1000)} mm)` : `${String(between / 2 + (back ? -across : across))} mm`, up < 0 && steps[i + 1]?.params.operation === 'join' ? `max(height + ${String(up)} mm, 0 mm)` : `height + ${String(up)} mm`]) } }; if (step.command !== 'solid.cylinder') return step; const [x, y] = step.params.base as Vec3; return { ...step, params: { ...step.params, base: [`length / 2 + ${String(x - length / 2)} mm`, y, 'height'] } }; diff --git a/apps/desktop/test/mechanism.test.ts b/apps/desktop/test/mechanism.test.ts index 037fbec2..23e9c3e9 100644 --- a/apps/desktop/test/mechanism.test.ts +++ b/apps/desktop/test/mechanism.test.ts @@ -254,6 +254,7 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { // had none itself; the same call again built a third joint between the same two parts. it('names a second hinge apart from the first, and refuses a second one between the same two bodies before it builds', async () => { const batches: { command: string; params: Record }[][] = []; + let stuck = false; const upstream: UpstreamTools = { instructions: () => undefined, listTools: () => Promise.resolve([]), @@ -261,6 +262,7 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { if (name === 'inspect_design') return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ bodies: [{ name: 'Base' }, { name: 'Flap' }, { name: 'Flap2' }], undo: 12 }) }] }); const commands = args.commands as { command: string; params: Record }[]; batches.push(commands); + if (stuck && commands.some((step) => step.command === 'joint.sweep')) return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results: [], failed: { command: 'joint.sweep', error: 'motion exceeds contact sweep collision budget' } }) }] }); const results = commands.map((step) => { // As SolidCraft 0.40.1 lists them: a joint's sides by the ids of their occurrences. if (step.command === 'joint.list') return { joints: [{ name: 'Hinge', type: 'revolute', a: 15, b: 17, frame_a: { origin: [42, 15, 3], z: [0, 1, 0] }, values: [{ value: 0, limits: [-145, 67] }] }] }; @@ -275,6 +277,11 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { // Half a millimetre off the first hinge's line it is a second axis, which would bind the first. expect(again).toEqual({ isError: true, content: [{ type: 'text', text: 'Not built: the joint "Hinge" already joins Flap and Base as a hinge on another line, and a second axis between the same two bodies would bind it. Put this hinge on the line of the first, or change the first: take it back with undo, or delete its features and its joint, then call again.' }] }); expect(batches.map((batch) => batch.map((step) => step.command).join(' '))).toEqual(['component.list joint.list']); + // Review 2 of PR #358: on the first hinge's own stretch (the joint lies in its middle, y 15) it is the first hinge called again, + // with seven knuckles for five: they would be cut into the old ones (H-02). Refused before anything is built. + const over = await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42, 0, 3], to: [42, 30, 3], knuckles: 7 }); + expect(over).toEqual({ isError: true, content: [{ type: 'text', text: 'Not built: the hinge "Hinge" already joins Base and Flap on this stretch of the line, and new knuckles would be cut into its own. Change that one instead: take it back with undo, or delete its features and its joint, then call again. A second hinge goes on the same line beside the first.' }] }); + expect(batches).toHaveLength(2); // Review of PR #358: on the line of the first it is the usual second hinge of a long flap. It gets its knuckles and no joint of // its own: the first joint is run through its limits again, since more knuckles can only narrow them. const before = batches.length; @@ -286,6 +293,12 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { expect(shared.some((step) => step.command === 'joint.as_built')).toBe(false); expect(shared.filter((step) => step.command === 'joint.sweep').map((step) => [step.params.joint, step.params.to])).toEqual([['Hinge', -145], ['Hinge', 67]]); expect(shared.find((step) => step.command === 'joint.limits')?.params).toEqual({ joint: 'Hinge', min: -145, max: 67 }); + // When that sweep cannot be run, the first hinge's limits are still set: the answer does not say there are none. + stuck = true; + const unrun = String((await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42, 130, 3], to: [42, 160, 3] })).content[0]?.text); + expect(unrun).toContain('so the limits of "Hinge" stay as they were, untried with this hinge.'); + expect(unrun).not.toContain('no limits are set'); + stuck = false; // Another pair of bodies: built, under a name of its own, which the joint, its sweeps and its limits all carry. const second = await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap2', from: [-2, 0, 3], to: [-2, 30, 3] }); expect(second.isError).toBeUndefined(); @@ -452,8 +465,9 @@ describe('the dovetail guide (ADR 0027, G3)', () => { // The rail is the same. expect(roofed.slice(3)).toEqual(slideSteps(guide).slice(3)); // The tool measures the moving body first: its top lies `top` over the base's face at z = 4. - const answer = async (top: number, args: Record = {}, { solid = top, overhang = 45, split = false } = {}) => { + const answer = async (top: number, args: Record = {}, { solid = top, wide = 0, hole = 0, broken = false, overhang = 45, split = false } = {}) => { const sent: { command: string; params: Record }[] = []; + const probes: number[][] = []; const upstream: UpstreamTools = { instructions: () => undefined, listTools: () => Promise.resolve([]), @@ -463,17 +477,24 @@ describe('the dovetail guide (ADR 0027, G3)', () => { sent.push(...commands); // The trial rods lie in the carriage where it is solid: up to `solid` over the base's face, along its 20 mm of the line (0.628 mm³ each). const rods = commands.filter((step) => step.command === 'solid.cylinder' && String(step.params.body_name).startsWith('PartBuddy probe ')); - if (rods.length > 0) expect(rods.map((rod) => [(rod.params.base as number[])[0], rod.params.height])).toEqual(Array.from({ length: 5 }, () => [20, 20])); - const results = commands.map((step) => (step.command === 'inspect.clearance' ? { overlaps: rods.flatMap((rod) => (((rod.params.base as number[])[2] ?? 0) + 0.1 <= 4 + solid ? [{ a: rod.params.body_name, b: 'Carriage', volume_mm3: 0.628 }] : [])) } : step.command === 'solid.extrude' && step.params.operation === 'cut' && split ? { created_bodies: ['Carriage (1)'], warning: null } : step.command === 'document.inspect' ? { timeline: [{ id: 1 }, { id: 2 }], undo: 2 } : step.command === 'inspect.measure' ? { bodies: [{ name: 'Carriage', bbox: { min: [20, 0, 4.4], max: [40, 30, 4 + top] } }] } : step.command === 'component.list' ? { components: [{ id: 3, parent: 0, bodies: ['Base'], occurrences: [{ id: 4, name: 'Base:1' }] }, { id: 5, parent: 0, bodies: ['Carriage'], occurrences: [{ id: 6, name: 'Carriage:1' }] }] } : {})); + // Not checked in here: what throws in the trial is taken as a trial that failed. + probes.push(...rods.map((rod) => [...(rod.params.base as number[]).map((value) => Math.round(value * 1000) / 1000), rod.params.height as number])); + if (rods.length > 0 && broken) return Promise.resolve({ content: [{ type: 'text', text: 'no answer' }], isError: true }); + // Above `solid` the carriage goes on as a rib `wide` over the line, up to its top; `hole` mm of each rod lie in a gap. + const inBody = (base: number[]) => (base[2] ?? 0) + 0.1 <= 4 + solid || (Math.abs((base[1] ?? 0) - 15) <= wide / 2 && (base[2] ?? 0) + 0.1 <= 4 + top); + const results = commands.map((step) => (step.command === 'inspect.clearance' ? { overlaps: rods.flatMap((rod) => (inBody(rod.params.base as number[]) ? [{ a: rod.params.body_name, b: 'Carriage', volume_mm3: 0.628 * (1 - hole / 20) }] : [])) } : step.command === 'solid.extrude' && step.params.operation === 'cut' && split ? { created_bodies: ['Carriage (1)'], warning: null } : step.command === 'document.inspect' ? { timeline: [{ id: 1 }, { id: 2 }], undo: 2 } : step.command === 'inspect.measure' ? { bodies: [{ name: 'Carriage', bbox: { min: [20, 0, 4.4], max: [40, 30, 4 + top] } }] } : step.command === 'component.list' ? { components: [{ id: 3, parent: 0, bodies: ['Base'], occurrences: [{ id: 4, name: 'Base:1' }] }, { id: 5, parent: 0, bodies: ['Carriage'], occurrences: [{ id: 6, name: 'Carriage:1' }] }] } : {})); return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }] }); }, }; const built = await withMechanismTool(upstream, () => 0.4, undefined, () => overhang).callTool('partbuddy_mechanism', { kind: 'slide', fixed: 'Base', moving: 'Carriage', from: [0, 15, 4], to: [60, 15, 4], ...args }); - return { text: String(built.content[0]?.text), groove: sent.find((step) => step.command === 'sketch.line')?.params.points as number[][], sent: sent.map((step) => step.command) }; + return { text: String(built.content[0]?.text), groove: sent.find((step) => step.command === 'sketch.line')?.params.points as number[][], sent: sent.map((step) => step.command), probes }; }; // 7.866 mm of roof and two lines over its ridge: 8.8 mm. const high = await answer(8.8); expect(high.groove).toHaveLength(8); + // The rods lie along the carriage's 20 mm of the line: over the ridge's middle and edges (±2.5 mm) with their tops two lines + // over it, and over the middle of each flank (±4.733 mm, where the roof is 5.633 mm high), not in the groove beside the ridge. + expect(high.probes).toEqual([[20, 15, 12.666, 20], [20, 17.5, 12.666, 20], [20, 12.5, 12.666, 20], [20, 19.733, 10.806, 20], [20, 10.267, 10.806, 20]]); expect(high.text).toContain("The two print apart, each with up pointing up, and Carriage is then pushed onto the rail from an end. Neither needs support: the rail's flanks and the roof over the groove lean 45°, and the roof's ridge is a bridge of 5 mm."); // The body is measured before the first step is built. expect(high.sent.indexOf('inspect.measure')).toBeLessThan(high.sent.indexOf('sketch.create')); @@ -487,9 +508,24 @@ describe('the dovetail guide (ADR 0027, G3)', () => { const knob = await answer(16.4, {}, { solid: 6 }); expect(knob.groove).toHaveLength(6); expect(knob.text).not.toContain('Neither needs support'); + // Review 2 of PR #358: a rib 6.4 mm wide on that plate holds the three rods over the ridge, and nothing lies over the flanks: + // with the flank rods in the groove beside the ridge the roof was built and cut the plate along both sides of the rib. + const rib = await answer(9, {}, { solid: 6, wide: 6.4 }); + expect(rib.groove).toHaveLength(6); + expect(rib.text).toContain('and it is not. Print Carriage turned over'); + // A cross groove 1.5 mm wide in the carriage (under the old tenth of its length): the roof would break out into it. + expect((await answer(12, {}, { hole: 1.5 })).groove).toHaveLength(6); + // A trial that fails says nothing about the body, and a line askew to the axes is tried by the body's box: the answer says which. + const failed = await answer(12, {}, { broken: true }); + expect(failed.groove).toHaveLength(6); + expect(failed.text).toContain("all along the groove, and whether it is could not be tried (SolidCraft's trial failed). Print Carriage turned over"); + expect(failed.text).toContain('or make sure it is that high there and build this again.'); + const askew = await answer(12, { from: [0, 0, 4], to: [60, 30, 4] }, { hole: 4 }); + expect(askew.groove).toHaveLength(6); + expect(askew.text).toContain("and the trial did not find it so (the line runs askew to the axes, where the trial goes by the body's box and can reach past the body). Print"); // Whatever cuts a body apart, SolidCraft says so only in created_bodies: that fails the call and takes it back. const cut = await answer(12, {}, { split: true }); - expect(cut.text).toBe('Not built: the step "Guide ·" cut "Carriage" apart ("Carriage (1)" came of it): the body is too thin or too small where the mechanism goes. Put the line from–to on a face of Base, with up as that face\'s normal, so the rail stands on it. Everything was taken back.'); + expect(cut.text).toBe('Not built: the step "Guide ·" cut "Carriage" apart ("Carriage (1)" came of it): the body is too thin or too small where the mechanism goes. Everything was taken back.'); // A small rail under a low body: its flat ceiling of 6.9 mm is a bridge that prints, and the answer says that, not "turn it over". const small = await answer(2.9, { width: 2, height: 1.5 }); expect(small.groove).toHaveLength(6); diff --git a/apps/desktop/test/templates.test.ts b/apps/desktop/test/templates.test.ts index 382c9f22..d00cc796 100644 --- a/apps/desktop/test/templates.test.ts +++ b/apps/desktop/test/templates.test.ts @@ -26,7 +26,7 @@ describe('templates (F3)', () => { for (const step of steps.slice(declared.length)) { for (const [key, value] of Object.entries(step.params)) { if (!EXPRESSION_FIELDS.has(key)) continue; - for (const text of expressions(value)) for (const word of text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) if (word !== 'mm' && word !== 'max') used.add(word); + for (const text of expressions(value)) for (const word of text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []) if (!['mm', 'max', 'min'].includes(word)) used.add(word); } } expect([...used].filter((word) => !names.has(word)), id).toEqual([]); @@ -123,12 +123,14 @@ describe('templates with moving parts', () => { // Each is drawn on the world's YZ plane at the knuckle's low end in x, from the rim down, so it follows length and height: // the box's from the line (y 0.5) towards -y to 0.5 mm inside its wall, the lid's towards +y. const drawn = (name: string) => steps.slice(steps.findIndex((step) => step.command === 'sketch.create' && step.params.name === name)).slice(0, 2).map((step) => step.params); - expect(drawn('Hinge 1 ◣')).toEqual([{ plane: 'YZ', offset: 'length / 2 + -20 mm', project_edges: false, name: 'Hinge 1 ◣' }, { points: [['3.328 mm', 'height + 0 mm'], ['3.328 mm', 'max(height + -2.828 mm, 0 mm)'], ['0 mm', 'max(height + -6.157 mm, 0 mm)'], ['-0.5 mm', 'max(height + -6.157 mm, 0 mm)'], ['-0.5 mm', 'height + 0 mm']], closed: true }]); + expect(drawn('Hinge 1 ◣')).toEqual([{ plane: 'YZ', offset: 'length / 2 + -20 mm', project_edges: false, name: 'Hinge 1 ◣' }, { points: [['3.328 mm', 'height + 0 mm'], ['3.328 mm', 'max(height + -2.828 mm, 0 mm)'], ['0.5 mm - min(0.5 mm, height + -5.657 mm)', 'max(height + -6.157 mm, 0 mm)'], ['-0.5 mm', 'max(height + -6.157 mm, 0 mm)'], ['-0.5 mm', 'height + 0 mm']], closed: true }]); // Review of PR #358: "at least 8 mm" stood only in the parameter's comment, and at 5 mm the webs reached 1.16 mm below the // floor. A web ends on the bed at the latest; its room in the other half is a cut and may go on. expect(JSON.stringify(drawn('Hinge 1 ◤')[1]?.points)).not.toContain('max('); expect(drawn('Hinge 2 ◣')[0]).toMatchObject({ plane: 'YZ', offset: 'length / 2 + -11.92 mm' }); - expect((drawn('Hinge 2 ◣')[1]?.points as string[][])[2]).toEqual(['1 mm', 'max(height + -6.157 mm, 0 mm)']); + // Review 2 of PR #358: with the foot only lifted to the bed the web's face leant 57° at 5 mm. The foot comes in along the face + // (to height - 5.657 mm from the line where that is less than the 0.5 mm to the wall), so the face keeps 45°. + expect((drawn('Hinge 2 ◣')[1]?.points as string[][])[2]).toEqual(['0.5 mm + min(0.5 mm, height + -5.657 mm)', 'max(height + -6.157 mm, 0 mm)']); expect(germanFeatureName('Hinge 1 ◣')).toBe('Scharnier 1 ◣'); expect(germanFeatureName('Hinge 2 ▬')).toBe('Scharnier 2 ▬'); // The joint is kept on the pin's round top: underneath the pin is flat now. From 8c8e1d299c34ade3593772f0ec12d3bc73e58b44 Mon Sep 17 00:00:00 2001 From: ManuelStaggl <17688572+ManuelStaggl@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:06:24 +0100 Subject: [PATCH 20/24] fix(desktop): a hinge over another is known by its pin, and a failed roof trial says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 3 of PR #358. - A hinge that reached only partly over the first, or over a second one without a joint of its own, was built into the old knuckles: only the first joint's point was tried. The pins on the line (" ●", read with timeline.inspect) now say where hinges are. - A rod or the comparison that failed in the roof trial read as rods outside the body ("is not solid"); it is a trial that failed. - A body with round or chamfered ends is full over the groove and still gets no roof; the answer now says where material is missing instead of "is not solid". - Measured on SolidCraft 0.40.1: the overlap volume of a rod is exact against the analytic volume at 30, 100 and 300 mm, so the roof's threshold stays as it is. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/src/main/agents/mechanismTool.ts | 57 ++++++++++++++++--- apps/desktop/test/mechanism.test.ts | 20 ++++++- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/agents/mechanismTool.ts b/apps/desktop/src/main/agents/mechanismTool.ts index 720a9fc3..ded5b32d 100644 --- a/apps/desktop/src/main/agents/mechanismTool.ts +++ b/apps/desktop/src/main/agents/mechanismTool.ts @@ -372,7 +372,10 @@ async function build(site: Site, spec: { fixed: string; moving: string; name: st // On the stretch of the first itself the new knuckles would be cut into the old ones (audit 2026-11-12, H-02): the // first joint lies in the middle of its hinge, so a line that holds that point lies over it. const at = onLine ? origin.reduce((sum, value, i) => sum + (value - (spec.from?.[i] ?? 0)) * (line[i] ?? 0), 0) / line.reduce((sum, value) => sum + value * value, 0) : -1; - if (at >= 0 && at <= 1) throw new Error(`the hinge ${JSON.stringify(same.name)} already joins ${spec.fixed} and ${spec.moving} on this stretch of the line, and new knuckles would be cut into its own. Change that one instead: take it back with undo, or delete its features and its joint, then call again. A second hinge goes on the same line beside the first.`); + // A hinge that reaches only partly over the first, or over a second one that has no joint of its own, holds no + // such point (review 3 of PR #358): those are known by their pins. + const over = at >= 0 && at <= 1 ? String(same.name) : onLine && spec.from ? await pinOver(run, spec.from, line) : null; + if (over !== null) throw new Error(`the hinge ${JSON.stringify(over)} already joins ${spec.fixed} and ${spec.moving} on this stretch of the line, and new knuckles would be cut into its own. Change that one instead: take it back with undo, or delete its features and its joint, then call again. A second hinge goes on the same line beside the first.`); if (!onLine) throw new Error(`the joint ${JSON.stringify(same.name)} already joins ${spec.fixed} and ${spec.moving} as a ${kind}${joins === 'revolute' ? ' on another line' : ''}, and a second ${joins === 'revolute' ? 'axis' : 'guide'} between the same two bodies would bind it. ${joins === 'revolute' ? 'Put this hinge on the line of the first, or change the first' : 'Change that one instead'}: take it back with undo, or delete its features and its joint, then call again.`); const limits = same.values?.[0]?.limits; site.shares = { name: String(same.name), limits: Array.isArray(limits) && typeof limits[0] === 'number' && typeof limits[1] === 'number' ? [limits[0], limits[1]] : [-180, 180] }; @@ -404,6 +407,36 @@ async function build(site: Site, spec: { fixed: string; moving: string; name: st return overlaps > 0 ? ' Warning: the two bodies still share volume away from the mechanism; make room between them.' : ''; } +/** + * The name of a hinge whose pin lies on the stretch `from`–`from + line`, or null. Every hinge keeps its pin as the + * feature " ●", and `timeline.inspect` says where that cylinder starts and how long it is (SolidCraft 0.40.1). + * shortcut: a pin that was renamed, or whose body was moved since, is not found; then only the first joint's point is known. + */ +async function pinOver(run: Run, from: Vec3, line: number[]): Promise { + try { + const [design] = await run([{ command: 'document.inspect', params: { timeline: 'summary' } }]); + const timeline = (design as { timeline?: unknown } | undefined)?.timeline; + const pins = (Array.isArray(timeline) ? (timeline as { id?: unknown; name?: unknown }[]) : []).filter((entry) => typeof entry.name === 'string' && entry.name.endsWith(' ●')); + if (pins.length === 0) return null; + const read = (await run(pins.map((pin) => ({ command: 'timeline.inspect', params: { feature: pin.id } })))) as ({ suppressed?: unknown; definition?: { base?: unknown; axis?: unknown; height?: unknown } } | undefined)[]; + const length = Math.hypot(...line); + const along = (vector: number[]) => vector.reduce((sum, value, i) => sum + value * ((line[i] ?? 0) / length), 0); + for (const [n, pin] of read.entries()) { + const [base, axis, height] = [vec3(pin?.definition?.base), vec3(pin?.definition?.axis), Number.parseFloat(String(pin?.definition?.height))]; + if (!base || !axis || !(height > 0) || pin?.suppressed === true) continue; + const rel = base.map((value, i) => value - (from[i] ?? 0)); + const [start, turn] = [along(rel), along(axis) / Math.hypot(...axis)]; + // Beside the line, or across it: another hinge. + if (Math.sqrt(Math.max(0, rel.reduce((sum, value) => sum + value * value, 0) - start * start)) > 0.01 || Math.abs(turn) < 0.999) continue; + const [low, high] = turn > 0 ? [start, start + height] : [start - height, start]; + if (low < length - 1e-6 && high > 1e-6) return String(pins[n]?.name).slice(0, -2); + } + } catch { + // Not readable: the first joint's point was tried already. + } + return null; +} + /** Makes the two bodies components unless they are, joins them where they are and sets the limits: the range clear of contact within `range`. */ async function join(run: Run, spec: { fixed: string; moving: string; from: Vec3; to: Vec3; name: string }, type: 'revolute' | 'slider', range: [number, number], step: number, shares?: string): Promise<[number, number] | string> { let [listing] = await run([{ command: 'component.list', params: {} }]); @@ -522,9 +555,13 @@ async function rodsInside(upstream: UpstreamTools, rods: { name: string; base: n ); const text = answer.content.find((part) => part.type === 'text')?.text; const found = ((JSON.parse(typeof text === 'string' ? text : '') as { results?: unknown[] }).results?.[rods.length] as { overlaps?: unknown } | undefined)?.overlaps; + // A rod or the comparison that failed ends the batch before its answer: that says nothing about the body. + if (!Array.isArray(found)) throw new Error('the trial gave no answer'); + // Measured on SolidCraft 0.40.1 (2026-11-12): a rod wholly in a body of 30, 100 and 300 mm comes back with this + // volume to nine digits, and one through a 1.5 mm gap with 1.500 mm less. const whole = (Math.PI * ROD * ROD * length) / 4; for (const rod of rods) { - const inside = (Array.isArray(found) ? (found as { a?: unknown; b?: unknown; volume_mm3?: unknown }[]) : []).find((pair) => [pair.a, pair.b].includes(rod.name) && [pair.a, pair.b].includes(rod.body))?.volume_mm3; + const inside = (found as { a?: unknown; b?: unknown; volume_mm3?: unknown }[]).find((pair) => [pair.a, pair.b].includes(rod.name) && [pair.a, pair.b].includes(rod.body))?.volume_mm3; shares.set(rod.name, typeof inside === 'number' ? inside / whole : 0); } return shares; @@ -544,7 +581,7 @@ async function carried(upstream: UpstreamTools, spec: HingeSpec, bed: number, si const base = spec.from[2] - drop <= bed ? null : spec.from.map((value, i) => value + (wall.toward[i] ?? 0) * (wall.distance + WEB.into / 2) - (i === 2 ? drop : 0)); return [{ key, wall, base, name: `${PROBE} ${key}` }]; }); - const shares = await rodsInside(upstream, tried.flatMap((entry) => (entry.base ? [{ name: entry.name, base: entry.base, body: spec[entry.key] }] : [])), axis, Math.hypot(...axis), callId); + const shares = await rodsInside(upstream, tried.flatMap((entry) => (entry.base ? [{ name: entry.name, base: entry.base, body: spec[entry.key] }] : [])), axis, Math.hypot(...axis), callId).catch(() => new Map()); const out: { fixed?: HingeSide; moving?: HingeSide } = {}; for (const entry of tried) if (entry.base === null || (shares.get(entry.name) ?? 0) >= 0.9) out[entry.key] = entry.wall; return out; @@ -612,7 +649,8 @@ async function buildGuide(site: Site, spec: SlideSpec, nozzle: number, overhang: const needed = Math.round((roof.top + skin) * 10) / 10; let measure: unknown; // Why the groove got no roof, when it is not that the body was found short of material there. - const roofless: { why: 'failed' | 'askew' | null } = { why: null }; + // 'partly': every rod lies mostly in the body, and `short` mm of the worst one do not. + const roofless: { why: 'failed' | 'askew' | 'partly' | null; short: number } = { why: null, short: 0 }; const warnings = await build( site, spec, @@ -640,7 +678,11 @@ async function buildGuide(site: Site, spec: SlideSpec, nozzle: number, overhang: spec.roof = shares !== null && covered > 0 && rods.every((rod) => (1 - (shares.get(rod.name) ?? 0)) * covered <= ROD); // shortcut: the rods are as long as the body's box along the line, so over a line askew to the axes (or a body // turned on its face) they reach past the body and no roof is built; lay them by the body itself when that turns up. - roofless.why = spec.roof ? null : shares === null ? 'failed' : along.filter((value) => Math.abs(value) > 1e-6).length > 1 ? 'askew' : null; + // shortcut: ends that are not flat (a round or chamfered body) leave the rods short there, and the groove gets + // no roof though the body is full over it; the answer says so. Try only the stretch each rod can lie in when it turns up. + const least = shares === null ? 0 : Math.min(...rods.map((rod) => shares.get(rod.name) ?? 0)); + roofless.short = Math.round((1 - least) * covered * 10) / 10; + roofless.why = spec.roof ? null : shares === null ? 'failed' : along.filter((value) => Math.abs(value) > 1e-6).length > 1 ? 'askew' : least >= 0.5 ? 'partly' : null; }, joins, ); @@ -651,11 +693,12 @@ async function buildGuide(site: Site, spec: SlideSpec, nozzle: number, overhang: const steep = overhang < 45 ? leans(flanks, overhang) : null; const solid = `solid up to ${String(needed)} mm over the face the rail is on`; const box = "the line runs askew to the axes, where the trial goes by the body's box and can reach past the body"; + const ends = `material is missing at that height along up to ${String(roofless.short)} mm of it: at ends that are round, chamfered or turned, or in a gap across the groove`; const print = spec.roof ? `${apart} ${steep ?? `Neither needs support: the rail's flanks and the roof over the groove lean 45°, and the roof's ridge is a bridge of ${String(roof.ridge)} mm.`}` : roof.ceiling <= BRIDGE_SPAN - ? `${apart} The groove's ceiling is flat and ${wide} mm wide, which prints as a bridge (up to ${String(BRIDGE_SPAN)} mm)${steep ? `. ${steep}` : ", and the rail's flanks lean 45°: neither needs support."} ${untried === 'failed' ? `Whether ${spec.moving} is ${solid} could not be tried (SolidCraft's trial failed)` : untried === 'askew' ? `The trial did not find ${spec.moving} ${solid} all along the groove (${box})` : `${spec.moving} is not ${solid} all along the groove`}, so the groove got no roof.` - : `${apart} The groove's ceiling is flat and ${wide} mm wide, more than prints as a bridge (${String(BRIDGE_SPAN)} mm): a roof at 45° over it needs ${spec.moving} to be ${solid}, all along the groove, and ${untried === 'failed' ? "whether it is could not be tried (SolidCraft's trial failed)" : untried === 'askew' ? `the trial did not find it so (${box})` : 'it is not'}. Print ${spec.moving} turned over instead, lying on its top with the groove open upwards (set that face as its print face), or ${untried ? 'make sure it is' : 'make it'} that high there and build this again.${steep ? ` ${steep}` : ''}`; + ? `${apart} The groove's ceiling is flat and ${wide} mm wide, which prints as a bridge (up to ${String(BRIDGE_SPAN)} mm)${steep ? `. ${steep}` : ", and the rail's flanks lean 45°: neither needs support."} ${untried === 'failed' ? `Whether ${spec.moving} is ${solid} could not be tried (SolidCraft's trial failed)` : untried === 'askew' ? `The trial did not find ${spec.moving} ${solid} all along the groove (${box})` : untried === 'partly' ? `${spec.moving} is ${solid} along most of the groove only (${ends})` : `${spec.moving} is not ${solid} all along the groove`}, so the groove got no roof.` + : `${apart} The groove's ceiling is flat and ${wide} mm wide, more than prints as a bridge (${String(BRIDGE_SPAN)} mm): a roof at 45° over it needs ${spec.moving} to be ${solid}, all along the groove, and ${untried === 'failed' ? "whether it is could not be tried (SolidCraft's trial failed)" : untried === 'askew' ? `the trial did not find it so (${box})` : untried === 'partly' ? `it is along most of it only (${ends})` : 'it is not'}. Print ${spec.moving} turned over instead, lying on its top with the groove open upwards (set that face as its print face), or ${untried ? 'make sure it is' : 'make it'} that high there and build this again.${steep ? ` ${steep}` : ''}`; return { warnings, measure, print }; } diff --git a/apps/desktop/test/mechanism.test.ts b/apps/desktop/test/mechanism.test.ts index 23e9c3e9..e774c0d0 100644 --- a/apps/desktop/test/mechanism.test.ts +++ b/apps/desktop/test/mechanism.test.ts @@ -267,6 +267,9 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { // As SolidCraft 0.40.1 lists them: a joint's sides by the ids of their occurrences. if (step.command === 'joint.list') return { joints: [{ name: 'Hinge', type: 'revolute', a: 15, b: 17, frame_a: { origin: [42, 15, 3], z: [0, 1, 0] }, values: [{ value: 0, limits: [-145, 67] }] }] }; if (step.command === 'component.list') return { components: [{ id: 14, parent: 0, bodies: ['Base'], occurrences: [{ id: 15, name: 'Base:1' }] }, { id: 16, parent: 0, bodies: ['Flap'], occurrences: [{ id: 17, name: 'Flap:1' }] }, { id: 31, parent: 0, bodies: ['Flap2'], occurrences: [{ id: 32, name: 'Flap2:1' }] }] }; + // As SolidCraft 0.40.1 answers: the first hinge's pin from y 0 to 30, and that of a second one, which has no joint, from 40 to 70. + if (step.command === 'document.inspect') return { timeline: [{ id: 9, name: 'Hinge ●', type: 'CylinderFeature' }, { id: 30, name: 'Hinge (2) ●', type: 'CylinderFeature' }], undo: 12 }; + if (step.command === 'timeline.inspect') return { definition: { axis: [0, 1, 0], base: [42, step.params.feature === 9 ? 0 : 40, 3], height: '30 mm', operation: 'join', participants: ['Base'], radius: '(3 mm) / 2' }, suppressed: false }; return step.command === 'joint.sweep' ? { stopped_at: Number(step.params.to) > 0 ? 67.4 : -145.8 } : {}; }); return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }] }); @@ -282,6 +285,12 @@ describe('the print-in-place hinge (ADR 0027, G3)', () => { const over = await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42, 0, 3], to: [42, 30, 3], knuckles: 7 }); expect(over).toEqual({ isError: true, content: [{ type: 'text', text: 'Not built: the hinge "Hinge" already joins Base and Flap on this stretch of the line, and new knuckles would be cut into its own. Change that one instead: take it back with undo, or delete its features and its joint, then call again. A second hinge goes on the same line beside the first.' }] }); expect(batches).toHaveLength(2); + // Review 3 of PR #358: half over the first (y 16 to 46 holds no joint's point), and over the second, which has no joint: + // both are known by their pins, and nothing is built. Beside both (y 90 to 120, below) it is built. + const refusal = async (from: number, to: number) => String((await tools.callTool('partbuddy_mechanism', { kind: 'hinge', fixed: 'Base', moving: 'Flap', from: [42, from, 3], to: [42, to, 3] })).content[0]?.text); + expect(await refusal(16, 46)).toContain('Not built: the hinge "Hinge" already joins Base and Flap on this stretch of the line'); + expect(await refusal(60, 90)).toContain('Not built: the hinge "Hinge (2)" already joins Base and Flap on this stretch of the line'); + expect(batches.slice(2).map((batch) => batch.map((step) => step.command).join(' '))).toEqual(['component.list joint.list', 'document.inspect', 'timeline.inspect timeline.inspect', 'component.list joint.list', 'document.inspect', 'timeline.inspect timeline.inspect']); // Review of PR #358: on the line of the first it is the usual second hinge of a long flap. It gets its knuckles and no joint of // its own: the first joint is run through its limits again, since more knuckles can only narrow them. const before = batches.length; @@ -465,7 +474,7 @@ describe('the dovetail guide (ADR 0027, G3)', () => { // The rail is the same. expect(roofed.slice(3)).toEqual(slideSteps(guide).slice(3)); // The tool measures the moving body first: its top lies `top` over the base's face at z = 4. - const answer = async (top: number, args: Record = {}, { solid = top, wide = 0, hole = 0, broken = false, overhang = 45, split = false } = {}) => { + const answer = async (top: number, args: Record = {}, { solid = top, wide = 0, hole = 0, broken = false, refused = false, overhang = 45, split = false } = {}) => { const sent: { command: string; params: Record }[] = []; const probes: number[][] = []; const upstream: UpstreamTools = { @@ -483,6 +492,8 @@ describe('the dovetail guide (ADR 0027, G3)', () => { // Above `solid` the carriage goes on as a rib `wide` over the line, up to its top; `hole` mm of each rod lie in a gap. const inBody = (base: number[]) => (base[2] ?? 0) + 0.1 <= 4 + solid || (Math.abs((base[1] ?? 0) - 15) <= wide / 2 && (base[2] ?? 0) + 0.1 <= 4 + top); const results = commands.map((step) => (step.command === 'inspect.clearance' ? { overlaps: rods.flatMap((rod) => (inBody(rod.params.base as number[]) ? [{ a: rod.params.body_name, b: 'Carriage', volume_mm3: 0.628 * (1 - hole / 20) }] : [])) } : step.command === 'solid.extrude' && step.params.operation === 'cut' && split ? { created_bodies: ['Carriage (1)'], warning: null } : step.command === 'document.inspect' ? { timeline: [{ id: 1 }, { id: 2 }], undo: 2 } : step.command === 'inspect.measure' ? { bodies: [{ name: 'Carriage', bbox: { min: [20, 0, 4.4], max: [40, 30, 4 + top] } }] } : step.command === 'component.list' ? { components: [{ id: 3, parent: 0, bodies: ['Base'], occurrences: [{ id: 4, name: 'Base:1' }] }, { id: 5, parent: 0, bodies: ['Carriage'], occurrences: [{ id: 6, name: 'Carriage:1' }] }] } : {})); + // SolidCraft refuses the third rod: the batch ends there, with the two results before it and no comparison. + if (rods.length > 0 && refused) return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results: results.slice(0, 2), failed: { command: 'solid.cylinder', error: 'refused', index: 2 } }) }], isError: true }); return Promise.resolve({ content: [{ type: 'text', text: JSON.stringify({ results }) }] }); }, }; @@ -514,7 +525,12 @@ describe('the dovetail guide (ADR 0027, G3)', () => { expect(rib.groove).toHaveLength(6); expect(rib.text).toContain('and it is not. Print Carriage turned over'); // A cross groove 1.5 mm wide in the carriage (under the old tenth of its length): the roof would break out into it. - expect((await answer(12, {}, { hole: 1.5 })).groove).toHaveLength(6); + const gap = await answer(12, {}, { hole: 1.5 }); + expect(gap.groove).toHaveLength(6); + // Review 3 of PR #358: so do the ends of a round or chamfered body, which is full over the groove: the answer does not call it "not solid". + expect(gap.text).toContain('all along the groove, and it is along most of it only (material is missing at that height along up to 1.5 mm of it: at ends that are round, chamfered or turned, or in a gap across the groove). Print Carriage turned over'); + // Review 3 of PR #358: a rod SolidCraft refuses ends the batch before the comparison, which read as rods outside the body ("it is not"). + expect((await answer(12, {}, { refused: true })).text).toContain("whether it is could not be tried (SolidCraft's trial failed)"); // A trial that fails says nothing about the body, and a line askew to the axes is tried by the body's box: the answer says which. const failed = await answer(12, {}, { broken: true }); expect(failed.groove).toHaveLength(6); 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 21/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 22/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 23/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 24/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();