fix: harden mesh check against bad indices and tidy review follow-ups

- Reject triangle indices outside the vertex list instead of stalling the queue
- Leave degenerate triangles out of body bounding boxes
- Skip the quality fetch without a print id and never show a previous print's result
- Disable the per-check switches while a save is in flight
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-21 08:55:58 +02:00
1 parent f913710cc4
commit 8b4653e415
5 files changed
+28 -7

No files matched your search

+8
View File
@@ -112,11 +112,17 @@ export function analyzeMesh(vertices: ArrayLike<number>, triangles?: ArrayLike<n
const firstVertex = new Uint32Array(vertexCount);
for (let v = ids.length - 1; v >= 0; v--) firstVertex[ids[v]] = v;
const position = (id: number, axis: number) => vertices[firstVertex[id] * 3 + axis];
// Degenerate faces join no body, so they mustn't make a bounding box of their own either.
const skipped = new Uint8Array(triangleCount);
for (let t = 0; t < triangleCount; t++) {
const a = corner(t * 3);
const b = corner(t * 3 + 1);
const c = corner(t * 3 + 2);
// An index past the vertex list would make NaN edge keys, which never group and stall the loop below.
if (a === undefined || b === undefined || c === undefined) {
throw new RangeError("Triangle index outside the vertex list");
}
const ax = position(a, 0);
const ay = position(a, 1);
const az = position(a, 2);
@@ -131,6 +137,7 @@ export function analyzeMesh(vertices: ArrayLike<number>, triangles?: ArrayLike<n
const nz = ux * vy - uy * vx;
if (a === b || b === c || a === c || nx * nx + ny * ny + nz * nz <= degenerateArea * degenerateArea) {
degenerate++;
skipped[t] = 1;
continue;
}
// The tetrahedron to the origin; the sum over a closed surface is its volume.
@@ -172,6 +179,7 @@ export function analyzeMesh(vertices: ArrayLike<number>, triangles?: ArrayLike<n
// Each body's bounding box, keyed by its root vertex.
const boxes = new Map<number, number[]>();
for (let t = 0; t < triangleCount; t++) {
if (skipped[t]) continue;
for (let k = 0; k < 3; k++) {
const id = corner(t * 3 + k);
const root = findRoot(parent, id);
+5 -1
View File
@@ -75,7 +75,11 @@ describe("analyzeMesh", () => {
it("counts degenerate triangles without treating them as holes", () => {
const sliver = [0, 0, 0, 5, 0, 0, 10, 0, 0];
const stats = analyzeMesh(soup([...box(), sliver]));
expect(stats).toMatchObject({ degenerateTriangles: 1, openEdges: 0 });
expect(stats).toMatchObject({ degenerateTriangles: 1, openEdges: 0, shells: 1, fragments: 0 });
});
it("rejects a triangle index outside the vertex list", () => {
expect(() => analyzeMesh([0, 0, 0, 1, 0, 0, 0, 1, 0], [0, 1, 3])).toThrow(RangeError);
});
it("tells a stray fragment from a second part", () => {
@@ -14,8 +14,9 @@ export type QualityOutcome = "good" | "issues" | null;
export function useQualityCheck(printId: string, onUnauthorized?: () => void) {
const { t } = useTranslation("models");
const showToast = useToast();
const [quality, setQuality] = useState<PrintQuality | null>(null);
const [outcome, setOutcome] = useState<QualityOutcome>(null);
// Tagged with their print, so a newly opened model never shows the last one's result for a render.
const [quality, setQuality] = useState<{ printId: string; value: PrintQuality } | null>(null);
const [outcome, setOutcome] = useState<{ printId: string; value: QualityOutcome } | null>(null);
// Kept out of the effect's deps: a new toast function or translation mustn't restart a running check.
const notify = useRef({ showToast, t, onUnauthorized });
useEffect(() => {
@@ -23,19 +24,20 @@ export function useQualityCheck(printId: string, onUnauthorized?: () => void) {
}, [showToast, t, onUnauthorized]);
useEffect(() => {
if (!printId) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
setQuality(null);
setOutcome(null);
const settle = (next: PrintQuality, watched: boolean) => {
setQuality(next);
setQuality({ printId, value: next });
if (next.status === "checking") {
timer = setTimeout(() => void poll(), POLL_MS);
return;
}
if (!watched || (next.status !== "good" && next.status !== "issues")) return;
setOutcome(next.status);
setOutcome({ printId, value: next.status });
if (next.status === "issues") {
// Only minor problems, which slicers usually cope with: a note rather than a warning.
const minor = worstSeverity(next) === "minor";
@@ -78,5 +80,8 @@ export function useQualityCheck(printId: string, onUnauthorized?: () => void) {
};
}, [printId]);
return { quality, outcome };
return {
quality: quality?.printId === printId ? quality.value : null,
outcome: outcome?.printId === printId ? outcome.value : null,
};
}
@@ -18,10 +18,12 @@ type Props = {
checksOff: QualityCheckKey[];
onChange: (checksOff: QualityCheckKey[]) => void;
onClose: () => void;
/** While a save is in flight, so overlapping saves can't land out of order. */
disabled?: boolean;
};
/** One switch per kind of print problem. Each saves as soon as it's switched. */
export default function QualityChecksDialog({ open, checksOff, onChange, onClose }: Props) {
export default function QualityChecksDialog({ open, checksOff, onChange, onClose, disabled }: Props) {
const { t } = useTranslation(["app", "common"]);
const toggle = (key: QualityCheckKey, on: boolean) =>
onChange(on ? checksOff.filter((off) => off !== key) : [...checksOff, key]);
@@ -52,6 +54,7 @@ export default function QualityChecksDialog({ open, checksOff, onChange, onClose
</Box>
<Switch
checked={!checksOff.includes(key)}
disabled={disabled}
onChange={(e) => toggle(key, e.target.checked)}
slotProps={{ input: { "aria-labelledby": titleId } }}
/>
@@ -149,6 +149,7 @@ export default function QualityChecksSection({ onUnauthorized }: Props) {
<QualityChecksDialog
open={checksOpen}
checksOff={settings.checks_off}
disabled={saving}
onChange={(checksOff) => void save({ checks_off: checksOff })}
onClose={() => setChecksOpen(false)}
/>