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..4963ca1e 100644 --- a/apps/desktop/src/main/print/printCheckNote.ts +++ b/apps/desktop/src/main/print/printCheckNote.ts @@ -5,32 +5,50 @@ 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 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)}`; + 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 (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 { 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': + // 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${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': + 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))}.` + @@ -61,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/main/print/printProfiles.ts b/apps/desktop/src/main/print/printProfiles.ts index 1f862689..9bc4c0e1 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 { 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 @@ -228,8 +229,8 @@ 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)}°.`, - '- 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.', + `- 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.', ]; } @@ -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 b0036ba4..51f499fa 100644 --- a/apps/desktop/src/renderer/src/i18n/de.json +++ b/apps/desktop/src/renderer/src/i18n/de.json @@ -1113,14 +1113,18 @@ "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: {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)", + "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{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² 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}", "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 9ddb47b9..6d087fdf 100644 --- a/apps/desktop/src/renderer/src/i18n/en.json +++ b/apps/desktop/src/renderer/src/i18n/en.json @@ -1113,14 +1113,18 @@ "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: {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)", + "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{layers, select, none {} other { ({layers} layers)}}", "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": "{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}", "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 112769e0..66b783b7 100644 --- a/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx +++ b/apps/desktop/src/renderer/src/print/PrintCheckChip.tsx @@ -21,17 +21,27 @@ 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(' × '), }); 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), 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) }); + { + // 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': 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 b701a4f3..392b0d94 100644 --- a/apps/desktop/src/renderer/src/print/report.ts +++ b/apps/desktop/src/renderer/src/print/report.ts @@ -8,21 +8,22 @@ 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: [] }; // 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': - 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, ...(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/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 77f6acf3..6cf207d0 100644 --- a/apps/desktop/src/shared/contract.ts +++ b/apps/desktop/src/shared/contract.ts @@ -1143,12 +1143,13 @@ 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('thinWall'), severity: findingSeveritySchema, thickness: mmValue, minimum: mmValue, area: finite.nonnegative(), places: findingPlacesSchema }).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, 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(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(), 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(), @@ -1158,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 f08ba7f5..68032295 100644 --- a/apps/desktop/test/printCheckNote.test.ts +++ b/apps/desktop/test/printCheckNote.test.ts @@ -14,8 +14,8 @@ 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: '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: 'size', severity: 'block', body: 'Rod', size: [300, 20, 20], bed: [256, 256, 256], lying: true }, + { 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' }, ], @@ -29,15 +29,56 @@ 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"'); }); + it('tells a thin floor from a thin wall: layers, not lines', () => { + 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 (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'); + }); + + 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, 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)'); + // 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 }); + 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/bridges.test.ts b/packages/geometry/src/bridges.test.ts new file mode 100644 index 00000000..179fc518 --- /dev/null +++ b/packages/geometry/src/bridges.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; + +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 }; + +/** 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', () => { + 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(arm).area).toBe(1200); + }); + + 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); + // 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', () => { + // 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, 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', () => { + 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'); + 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/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); diff --git a/packages/geometry/src/index.ts b/packages/geometry/src/index.ts index 30dc06d6..4e1c8d2b 100644 --- a/packages/geometry/src/index.ts +++ b/packages/geometry/src/index.ts @@ -1,5 +1,7 @@ export { meshMetrics, overhangFindings, type MeshMetrics, type OverhangFinding } from './analysis'; export { + BRIDGE_SPAN, + LYING_HOLE, Overhang, OVERHANG_LIMITS, overhangLimits, @@ -21,6 +23,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..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 }; } /** @@ -72,98 +74,492 @@ 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; /** - * Overhang triangles that are small: each connected patch whose every corner 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 - * builds, upgrade to a spatial grid if a check of a large mesh gets slow. + * 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; + +/** + * 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; +/** 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, 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, 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}`; - }; - // Every edge of the body with the triangles on it and their third corner. - const edges = new Map(); - for (let t = 0; t < index.length / 3; t += 1) { + 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 }]); + if (list) list.push(t, at(t, (k + 2) % 3)); + else edges.set(id, [t, at(t, (k + 2) % 3)]); } } - 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 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][] = []; + 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 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]); + 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 = (v: number) => { - const px = positions[v * 3] ?? 0; - const py = positions[v * 3 + 1] ?? 0; - 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]); + } + } + } + // 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]); + } + } + } + /** 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; }; - const holds = patch.every((t) => near(index[t * 3] ?? 0) && near(index[t * 3 + 1] ?? 0) && near(index[t * 3 + 2] ?? 0)); - if (holds) for (const t of patch) small.add(t); + }; + 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)]); } - return small; + 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 (!allNear) lostNear += plan; + } + // 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)); } /** @@ -190,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; @@ -205,10 +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] : [])) : []; - 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 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/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/overhangSpeed.test.ts b/packages/geometry/src/overhangSpeed.test.ts new file mode 100644 index 00000000..fa939c82 --- /dev/null +++ b/packages/geometry/src/overhangSpeed.test.ts @@ -0,0 +1,107 @@ +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); +} + +/** 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) { + 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. + 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(small.ms * GROWTH); + }); + + 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(short.ms * GROWTH); + }); + + 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(short.ms * GROWTH); + const floating = timed(stem(250, 200, 1), 0); + expect(floating).toMatchObject({ support: 54, small: 0 }); + expect(floating.ms).toBeLessThan(timed(stem(62, 200, 1), 0).ms * GROWTH); + }); +}); diff --git a/packages/geometry/src/printCheck.test.ts b/packages/geometry/src/printCheck.test.ts index 9fd5b6b3..61649e5c 100644 --- a/packages/geometry/src/printCheck.test.ts +++ b/packages/geometry/src/printCheck.test.ts @@ -4,16 +4,16 @@ 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 { +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), @@ -45,6 +50,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: [] }); @@ -60,17 +67,71 @@ 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); - 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 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 }]); + }); - 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 }); + 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']); + // 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. + 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', () => { @@ -103,6 +164,43 @@ 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('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, 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: [] }); + }); + 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); @@ -131,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) => { @@ -162,6 +268,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..dcfa4ac1 100644 --- a/packages/geometry/src/printCheck.ts +++ b/packages/geometry/src/printCheck.ts @@ -1,16 +1,17 @@ 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'; /** * 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, 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 @@ -21,6 +22,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; } @@ -61,11 +64,15 @@ interface Located { } export type PrintFinding = - | { kind: 'size'; severity: FindingSeverity; size: [number, number, number]; bed: [number, number, number]; lying: boolean } - | ({ kind: 'thinWall'; severity: FindingSeverity; thickness: number; minimum: number; area: number } & Located) + | { 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; /** 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[] } | { 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) @@ -94,6 +101,20 @@ 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, 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²). */ const MIN_AREA = 1; /** Walls under the nozzle width over at least this area make the part unprintable (mm²). */ @@ -163,35 +184,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 ? layerOf(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 +266,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); @@ -257,35 +321,76 @@ 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. */ -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); @@ -307,16 +412,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 }; @@ -334,20 +438,31 @@ 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) }); + // 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); - const size = sizeFinding(meshes, 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); - 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 }); + // 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. 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); + 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. @@ -367,9 +482,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: { body: PrintCheckBody; tally: FaceTally; area: number }[] = []; + parts.forEach((body, b) => { + const { model } = body; const geometry = model.geometry; const mesh = meshes[b]; if (!mesh) return; @@ -386,7 +501,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); @@ -401,15 +515,19 @@ 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) 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({ kind: 'overhang', severity: 'warn', area: round(steep.area, 0), limit: limits.support, 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 }); + // 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 }); } } 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/testShapes.ts b/packages/geometry/src/testShapes.ts index 71ca25e4..f273d766 100644 --- a/packages/geometry/src/testShapes.ts +++ b/packages/geometry/src/testShapes.ts @@ -125,11 +125,147 @@ 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); +} + +/** 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); +} 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, }; }