Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/e2e/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
39 changes: 30 additions & 9 deletions apps/desktop/src/main/print/printCheckNote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,50 @@ type Places = Extract<Finding, { places: unknown }>['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))}.` +
Expand Down Expand Up @@ -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');
}
7 changes: 4 additions & 3 deletions apps/desktop/src/main/print/printProfiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.',
];
}
Expand Down Expand Up @@ -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.`);
Expand Down
10 changes: 7 additions & 3 deletions apps/desktop/src/renderer/src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 7 additions & 3 deletions apps/desktop/src/renderer/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/renderer/src/print/PrintCheckChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/renderer/src/print/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
}),
};
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/src/print/usePrintCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading