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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 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 09/12] 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 10/12] 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 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 11/12] 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 12/12] 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); }); });