= { ok: CheckCircle2, warn: AlertTr
function buildRows(mesh: MeshReport, dims?: { x: number; y: number; z: number } | null,
bed?: { x: number; y: number; z: number } | null,
- mode: "simple" | "advanced" = "simple"): Row[] {
+ mode: "simple" | "advanced" = "simple", objects?: ObjectSize[] | null,
+ unmeasured = 0): Row[] {
const rows: Row[] = [];
const integ = mesh.integrity;
const fitBed = bed ?? U1_BED;
@@ -71,33 +73,36 @@ function buildRows(mesh: MeshReport, dims?: { x: number; y: number; z: number }
: "Few steep overhangs — this should print without supports." });
}
- // 5. Bed fit (from dimensions vs the real connected printer bed, else the U1 default)
- if (dims && unsure) {
+ // 5. Bed fit BY SIZE: each object against the real connected printer bed, else the U1 default.
+ // A size says nothing about position; placement is its own card. When the file does not establish
+ // its physical size, the numbers are not judged against the bed at all.
+ if (unsure) {
rows.push({ key: "bedfit", label: "Bed fit", icon: Ruler, level: "warn", status: "Not checked",
- detail: `${basis!.summary} The ${dims.x} × ${dims.y} × ${dims.z} numbers stored in the file are not judged against the bed.` });
- } else if (dims) {
- const fits = dims.x <= fitBed.x && dims.y <= fitBed.y && dims.z <= fitBed.z;
- const bedStr = `${fitBed.x} × ${fitBed.y} × ${fitBed.z} mm`;
- rows.push({ key: "bedfit", label: "Bed fit", icon: Ruler, level: fits ? "ok" : "risk",
- status: fits ? "Fits" : "Too big",
- detail: fits
- ? `${dims.x} × ${dims.y} × ${dims.z} mm fits ${bedSource} ${bedStr} bed.`
- : `${dims.x} × ${dims.y} × ${dims.z} mm is larger than ${bedSource} ${bedStr} bed — scale it down or split it.` });
+ detail: dims
+ ? `${basis!.summary} The ${dims.x} × ${dims.y} × ${dims.z} numbers stored in the file are not judged against the bed.`
+ : `${basis!.summary} The sizes stored in the file are not judged against the bed.` });
+ } else {
+ const fit = sizeRow(objects, dims, fitBed, bedSource, unmeasured);
+ if (fit) {
+ rows.push({ key: "bedfit", label: "Bed fit (by size)", icon: Ruler, level: fit.level,
+ status: fit.status, detail: fit.detail });
+ }
}
return rows;
}
/** Design Health — at-a-glance geometry verdicts with plain-language what/why/do.
* `mode` "advanced" appends the raw metric footer. */
-export function DesignHealth({ mesh, dims, bed, mode = "simple" }: {
+export function DesignHealth({ mesh, dims, objects, unmeasured, bed, mode = "simple" }: {
mesh?: MeshReport; dims?: { x: number; y: number; z: number } | null;
+ objects?: ObjectSize[] | null; unmeasured?: number;
bed?: { x: number; y: number; z: number } | null; mode?: "simple" | "advanced";
}) {
if (!mesh) return null;
if (!mesh.available) {
return Geometry analysis isn’t available for this file (it may be too large).
;
}
- const rows = buildRows(mesh, dims, bed, mode);
+ const rows = buildRows(mesh, dims, bed, mode, objects, unmeasured);
const unsure = sizeNotEstablished(mesh.geometry_basis);
if (!rows.length) return null;
diff --git a/desktop/src/components/SizeNotChecked.test.tsx b/desktop/src/components/SizeNotChecked.test.tsx
index 7182d4b2..b5214047 100644
--- a/desktop/src/components/SizeNotChecked.test.tsx
+++ b/desktop/src/components/SizeNotChecked.test.tsx
@@ -57,6 +57,9 @@ describe("a not-checked verdict names its scope and gives one shared reason", ()
});
});
+const objectsOf = (d: { x: number; y: number; z: number }) =>
+ [{ object_id: "1", part: "3D/3dmodel.model", instance_count: 1, dimensions_mm: d }];
+
describe("Design Health does not judge the bed or vouch for a raw size", () => {
it("not established: bed fit is Not checked with the reason, and never Fits or Too big", () => {
render();
@@ -75,20 +78,34 @@ describe("Design Health does not judge the bed or vouch for a raw size", () => {
expect(document.body.textContent).toMatch(/Tall and narrow[^]*not a confirmed physical size/);
});
+ it("not established with no overall dims but per-object sizes: still Not checked, never Fits or Too big", () => {
+ render();
+ const text = document.body.textContent ?? "";
+ expect(text).toContain("Not checked");
+ expect(text).toContain(REASON);
+ expect(text).not.toMatch(/Fits|Too big|10 × 20 × 30/);
+ });
+
it("control: established size keeps the normal Fits verdict", () => {
- render();
+ render();
const text = document.body.textContent ?? "";
expect(text).toMatch(/Fits/);
expect(text).not.toMatch(/Not checked|not a confirmed physical size/);
});
- it("control: a mesh without a basis (older engine) behaves as before", () => {
- render();
+ it("control: a mesh without a basis judges the per-object sizes", () => {
+ render();
expect(document.body.textContent).toMatch(/Fits/);
});
+ it("control: no per-object sizes at all reads Unknown, not Fits", () => {
+ render();
+ expect(document.body.textContent).toMatch(/Unknown/);
+ expect(document.body.textContent).not.toMatch(/Fits/);
+ });
+
it("a too-large established model is still Too big (the guard does not soften real verdicts)", () => {
- render();
+ render();
expect(document.body.textContent).toMatch(/Too big/);
});
});
diff --git a/desktop/src/lib/sizeFit.test.ts b/desktop/src/lib/sizeFit.test.ts
new file mode 100644
index 00000000..1aa82e0e
--- /dev/null
+++ b/desktop/src/lib/sizeFit.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vitest";
+import type { ObjectSize } from "@/api";
+import { UNMEASURED, sizeRow } from "./sizeFit";
+
+const BED = { x: 270, y: 270, z: 270 };
+const obj = (id: string, x: number, y = x, z = x): ObjectSize => ({
+ object_id: id,
+ instance_count: 1,
+ dimensions_mm: { x, y, z },
+});
+
+describe("sizeRow", () => {
+ it("says it is a size statement and that position is a separate check", () => {
+ const row = sizeRow([obj("1", 30)], { x: 30, y: 30, z: 30 }, BED, "the U1’s");
+ expect(row?.level).toBe("ok");
+ expect(row?.detail.startsWith("By size, ")).toBe(true);
+ expect(row?.detail).toContain("Size only: where it sits on the plate is a separate check.");
+ });
+
+ it("judges each object, not the combined extents of separated ones", () => {
+ // the overall extents (520 mm) are not an object's size
+ const row = sizeRow([obj("1", 20), obj("2", 20)], { x: 520, y: 20, z: 20 }, BED, "the U1’s");
+ expect(row?.level).toBe("ok");
+ expect(row?.detail).toContain("each of the 2 objects fits");
+ expect(row?.detail).not.toMatch(/scale|split/);
+ });
+
+ it("names the object that is too big", () => {
+ const row = sizeRow([obj("1", 20), obj("2", 300, 20, 20)], null, BED, "the U1’s");
+ expect(row?.level).toBe("risk");
+ expect(row?.detail).toContain("By size, object 2 (300 × 20 × 20 mm) is larger than");
+ });
+
+ it("keeps an unknown size unknown and never falls back to the combined extents", () => {
+ for (const overall of [{ x: 300, y: 10, z: 10 }, { x: 20, y: 20, z: 20 }]) {
+ const row = sizeRow([], overall, BED, "your connected U1");
+ expect(row).toEqual({ level: "warn", status: "Unknown", detail: UNMEASURED });
+ }
+ expect(sizeRow(undefined, { x: 520, y: 20, z: 20 }, BED, "x")?.detail).not.toMatch(/scale|split|fit /);
+ expect(sizeRow(undefined, null, BED, "x")).toBeNull();
+ });
+
+ it("does not say everything fits while some build items could not be measured", () => {
+ const row = sizeRow([obj("1", 30)], null, BED, "the U1’s", 2);
+ expect(row?.level).toBe("warn");
+ expect(row?.status).toBe("Check");
+ expect(row?.detail).toContain("2 build items could not be measured");
+ });
+});
diff --git a/desktop/src/lib/sizeFit.ts b/desktop/src/lib/sizeFit.ts
new file mode 100644
index 00000000..f2d1d56b
--- /dev/null
+++ b/desktop/src/lib/sizeFit.ts
@@ -0,0 +1,74 @@
+// The "Bed fit" row of Design Health, as plain data so its wording stays testable.
+//
+// This row is a SIZE statement: how big each object is against the bed. It says nothing about where
+// anything sits, so it always begins "By size" and points at the placement check for position. The
+// combined extents of several separated objects or plates are never read as one big object.
+
+import type { ObjectSize } from "@/api";
+
+export type SizeLevel = "ok" | "warn" | "risk";
+
+export interface Bed {
+ x: number;
+ y: number;
+ z: number;
+}
+
+export interface SizeRow {
+ level: SizeLevel;
+ status: string;
+ detail: string;
+}
+
+type Dims = { x: number; y: number; z: number };
+
+const fitsBed = (d: Dims, bed: Bed) => d.x <= bed.x && d.y <= bed.y && d.z <= bed.z;
+const mm = (d: Dims) => `${d.x} × ${d.y} × ${d.z} mm`;
+
+export const UNMEASURED = "By size, Studio could not measure each object, so it cannot say whether they fit.";
+const SIZE_ONLY = "Size only: where it sits on the plate is a separate check.";
+
+/**
+ * `overall` is the combined extents of all the mesh data. It is only a signal that there IS geometry:
+ * it is never used to say an object fits or is too big, because for several separated objects or plates
+ * it is not any one object's size. No per-object sizes means "unknown".
+ */
+export function sizeRow(
+ objects: ObjectSize[] | null | undefined,
+ overall: Dims | null | undefined,
+ bed: Bed,
+ bedSource: string,
+ unmeasured = 0,
+): SizeRow | null {
+ const bedStr = `${bed.x} × ${bed.y} × ${bed.z} mm`;
+ const sized = (objects ?? []).filter((o) => o.dimensions_mm);
+ if (sized.length === 0) {
+ if (!overall) return null;
+ return { level: "warn", status: "Unknown", detail: UNMEASURED };
+ }
+ const missing =
+ unmeasured > 0
+ ? ` ${unmeasured} build item${unmeasured === 1 ? "" : "s"} could not be measured, so Studio cannot say whether everything fits.`
+ : "";
+ const tooBig = sized.filter((o) => !fitsBed(o.dimensions_mm, bed));
+ const many = sized.length > 1;
+ if (tooBig.length > 0) {
+ const first = tooBig[0];
+ const who = many && first.object_id != null ? `object ${first.object_id} (${mm(first.dimensions_mm)})` : mm(first.dimensions_mm);
+ const more = tooBig.length > 1 ? ` ${tooBig.length} objects are larger than the bed.` : "";
+ return {
+ level: "risk",
+ status: "Too big",
+ detail: `By size, ${who} is larger than ${bedSource} ${bedStr} bed — scale it down or split it.${more}${missing}`,
+ };
+ }
+ const largest = sized.reduce((a, b) => (b.dimensions_mm.x * b.dimensions_mm.y > a.dimensions_mm.x * a.dimensions_mm.y ? b : a));
+ const subject = many
+ ? `each of the ${sized.length} objects fits ${bedSource} ${bedStr} bed (the largest is ${mm(largest.dimensions_mm)})`
+ : `${mm(largest.dimensions_mm)} fits ${bedSource} ${bedStr} bed`;
+ return {
+ level: missing ? "warn" : "ok",
+ status: missing ? "Check" : "Fits",
+ detail: `By size, ${subject}. ${SIZE_ONLY}${missing}`,
+ };
+}
diff --git a/desktop/src/routes/DesignInsights.tsx b/desktop/src/routes/DesignInsights.tsx
index 3e21e06f..c0a4cfdb 100644
--- a/desktop/src/routes/DesignInsights.tsx
+++ b/desktop/src/routes/DesignInsights.tsx
@@ -403,7 +403,7 @@ export default function DesignInsights() {
Design Health
-
+
)}
diff --git a/desktop/src/routes/LiveWorkspace.tsx b/desktop/src/routes/LiveWorkspace.tsx
index 4aea6b71..995f2f06 100644
--- a/desktop/src/routes/LiveWorkspace.tsx
+++ b/desktop/src/routes/LiveWorkspace.tsx
@@ -122,7 +122,7 @@ export default function LiveWorkspace() {
Design Health
-
+
)}
diff --git a/docs/testing/scene-contract/README.md b/docs/testing/scene-contract/README.md
index 76566254..69880fd8 100644
--- a/docs/testing/scene-contract/README.md
+++ b/docs/testing/scene-contract/README.md
@@ -303,12 +303,15 @@ py -3.13 tools/scene_benchmark.py --out ../docs/testing/scene-contract/results.j
## Known limitations (carry into the PR description)
-1. **Size-only consumers are not placement-aware.** DesignHealth, `bed_fit`, `validation_report` and the service
- bed-fit path answer "does the SIZE fit". A small object translated off the plate is not detected by them, and
- separated instances inflate the union. The scene's placement findings are the placement-aware source. Tracked
- as issue #91; the copy change qualifying those claims "by size" is the planned PR 3c.
-2. **Repeated build items of one object id are mis-placed by `plate_placement.assess` on `main`**
- (`placement.py` keeps the last transform: a cube at X=500 and X=100 reports "inside"). Not fixed here (tracked as issue #93). The scene reports `REPEATED_INSTANCE_PLACEMENT_UNVERIFIED` and attaches no finding to those nodes.
+1. **Size-only consumers say so (issue #91, addressed by PR #104).** DesignHealth, `bed_fit`, `validation_report` and
+ the service bed-fit path judge each object's SIZE on its own and say "By size"; where the instances sit is
+ reported separately ("By placement"). Several small, separated objects are no longer summed into one "too big"
+ object, and an object whose size cannot be measured stays unknown. The scene's placement findings remain the
+ viewer's placement source.
+2. **Repeated build items of one object id (issue #93, addressed by PR #102).** `plate_placement.assess` now judges
+ every build item on its own, keyed by build item and part. The scene still reports
+ `REPEATED_INSTANCE_PLACEMENT_UNVERIFIED` and attaches no engine finding to those nodes; enabling them is a
+ separate change.
3. **Plates.** Non-Bambu projects (Prusa, plain 3MF, STL) have no plate records (`PLATES_UNAVAILABLE`). Grid origins
of multi-plate projects are unknown, so multi-plate projects get no placement findings.
4. **Stuck native calls** cannot be interrupted in-process (fail-closed `WORKER_WEDGED` backstop, no multiprocessing).