From 14bc7f6987f51d805a33d52d0ac3a86cb339b4a7 Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Fri, 9 Oct 2026 20:58:16 -0400 Subject: [PATCH 1/9] fix(bed-fit): keep object size and placed bounds apart (#91) Size-only bed-fit statements read the overall mesh extents, so a small object moved off the plate still "fit", and separated objects or plates could add up to a combined extent that produced scale-or-split advice that was not about any single object. - geometry.object_sizes / measure: per-object size in mm (units-correct, before the build item moves, turns or scales it) from the same read as the placed per-item bounds. - plate_placement.placed_instances: per-instance placed bounds and plate; no combined extent across plates. - intelligence.project_info: object_sizes_mm and placed, with dimensions_mm kept and labelled as overall extents by size. - bed_fit: every size statement says "By size" and each object is judged on its own; placement is added separately ("By placement") and an off-plate instance makes the result a risk. - validation_report: "Fits the print bed" is per object, by size. - Desktop: Design Health bed-fit row is "by size" per object. Verified: backend pytest 3194 passed / 49 skipped; desktop tsc, vitest (823 passed, incl. prepare-copy guard) and build clean. Co-Authored-By: Claude Sonnet 5.5 --- backend/snapstudio_api/service.py | 16 +- backend/snapstudio_core/bed_fit.py | 126 ++++++++++- backend/snapstudio_core/geometry.py | 52 ++++- backend/snapstudio_core/intelligence.py | 32 +++ .../snapstudio_core/intelligence_report.py | 2 +- backend/snapstudio_core/plate_placement.py | 65 ++++++ backend/snapstudio_core/validation_report.py | 29 ++- backend/tests/test_size_vs_placed_bounds.py | 202 ++++++++++++++++++ desktop/src/api.ts | 30 +++ desktop/src/components/DesignHealth.tsx | 25 ++- desktop/src/lib/sizeFit.test.ts | 40 ++++ desktop/src/lib/sizeFit.ts | 67 ++++++ desktop/src/routes/DesignInsights.tsx | 2 +- desktop/src/routes/LiveWorkspace.tsx | 2 +- 14 files changed, 648 insertions(+), 42 deletions(-) create mode 100644 backend/tests/test_size_vs_placed_bounds.py create mode 100644 desktop/src/lib/sizeFit.test.ts create mode 100644 desktop/src/lib/sizeFit.ts diff --git a/backend/snapstudio_api/service.py b/backend/snapstudio_api/service.py index 0b638749..da5658ce 100644 --- a/backend/snapstudio_api/service.py +++ b/backend/snapstudio_api/service.py @@ -2074,8 +2074,20 @@ def bed_fit(path: str, host: str | None = None, port: int = 7125) -> dict: bed, bed_known = bm, True except Exception: pass - return bf.assess(dims, bed=bed, bed_known=bed_known, - object_count=object_count, multi_material=multi) + # Two separate facts: how big each object is (size, per object) and where the instances sit + # (placement, per instance and plate). A combined extent is neither, and is never used here. + placed = None + try: + from snapstudio_core import plate_placement + rect = ({"min_x": 0.0, "min_y": 0.0, "max_x": float(bed["x"]), "max_y": float(bed["y"])} + if bed_known and bed else None) + placed = plate_placement.assess( + path, bed=rect, bed_name=("your connected printer's" if rect else None)) + except Exception: + placed = None + return bf.assess_objects(info.get("object_sizes_mm"), fallback_dims=dims, placed=placed, + bed=bed, bed_known=bed_known, object_count=object_count, + multi_material=multi) def predict_success(path: str, host: str | None = None, port: int = 7125) -> dict: diff --git a/backend/snapstudio_core/bed_fit.py b/backend/snapstudio_core/bed_fit.py index 073543bc..79210fcf 100644 --- a/backend/snapstudio_core/bed_fit.py +++ b/backend/snapstudio_core/bed_fit.py @@ -8,6 +8,14 @@ fits, a height/split warning, and — in multi-material mode — whether there's room left for the prime/wipe tower. +Two different questions are answered here and the text always says which one: + + by size can this object, as modelled, fit the bed at all (per object) + by placement do the placed instances sit on the plate (per instance, per plate) + +A size says nothing about a position, and a combined extent across several separated +objects or plates is neither: it is never used to say "too big" or to suggest scaling. + Read-only and offline-capable: it uses the connected printer's REAL bed when known, else the printable volume recorded in the profile of the machine the file is being prepared for. Honest: it explains only what the geometry proves, returns @@ -32,10 +40,16 @@ def _f(level: str, text: str) -> dict: def assess(dims, bed=None, bed_known: bool = False, object_count: int = 1, - multi_material: bool = False, profile: dict | None = None) -> dict: + multi_material: bool = False, profile: dict | None = None, + subject: str | None = None, placed: dict | None = None) -> dict: """Diagnose whether a model fits the bed and explain any "out of bounds". - dims: object bounding box {x, y, z} in mm (from geometry). + dims: ONE object's size {x, y, z} in mm, as modelled (``geometry.object_sizes``). + Every statement made from it starts "By size" and claims no position. + subject: what ``dims`` is the size of, e.g. "object 3" (default "this model"). + placed: a ``plate_placement.assess`` result. When given, where the instances sit + is reported separately as "By placement", and an instance off the plate makes + the overall result a risk even though each object is small enough by size. bed: the printer's real bed {x, y, z}; falls back to the profile's volume. bed_known: True when `bed` came from a connected printer. object_count: parts on the plate (the whole arrangement must fit). @@ -65,6 +79,7 @@ def assess(dims, bed=None, bed_known: bool = False, object_count: int = 1, findings: list[dict] = [] fixes: list[str] = [] worst = "ok" + who = subject or "this model" def bump(level: str) -> None: nonlocal worst @@ -77,7 +92,7 @@ def bump(level: str) -> None: # Too tall — the machine cannot reach the height. if over_z: bump("risk") - findings.append(_f("risk", f"Taller than {source} can print: {z:.0f} mm vs the " + findings.append(_f("risk", f"By size, {who} is taller than {source} can print: {z:.0f} mm vs the " f"{bz:.0f} mm max height — Orca reports this as out of bounds. " f"Scale to {bz / z * 100:.0f}% or split it into shorter parts.")) fixes.append(f"Scale to {bz / z * 100:.0f}% to fit the height, or split into parts.") @@ -89,7 +104,7 @@ def bump(level: str) -> None: # a zero-width/zero-depth axis (flat plane) which would ZeroDivision here. _factors = ([bx / x] if x > 0 else []) + ([by / y] if y > 0 else []) scale_pct = (min(_factors) if _factors else 0.0) * 100.0 - findings.append(_f("risk", f"Too big for the bed: {x:.0f}×{y:.0f} mm on a " + findings.append(_f("risk", f"By size, {who} is too big for the bed: {x:.0f}×{y:.0f} mm on a " f"{bx:.0f}×{by:.0f} mm bed — this is the “out of bounds” error " f"Orca shows without saying which way. Scale to {scale_pct:.0f}% to fit.")) fixes.append(f"Scale to {scale_pct:.0f}% so it fits the {bx:.0f}×{by:.0f} mm bed.") @@ -101,7 +116,7 @@ def bump(level: str) -> None: # Almost the whole plate — fits, but brim/skirt/tower can spill over. elif x > bx * EDGE_FRAC or y > by * EDGE_FRAC: bump("warn") - findings.append(_f("warn", f"Fills almost the whole bed ({x:.0f}×{y:.0f} of " + findings.append(_f("warn", f"By size, {who} fills almost the whole bed ({x:.0f}×{y:.0f} of " f"{bx:.0f}×{by:.0f} mm) — a skirt, brim, or prime tower can spill " f"past the edge and trigger out of bounds.")) fixes.append("Center the model, drop the brim/skirt, or scale down slightly.") @@ -123,15 +138,32 @@ def bump(level: str) -> None: f"arrangement must fit. If Orca still says out of bounds, one object " f"is off the plate; use Arrange to re-pack them.")) + # Where the instances sit is a different fact from how big each object is. + placed_level = _placed_findings(placed, findings, fixes) + if placed_level: + bump(placed_level) + if worst == "ok" and not any(f["level"] != "ok" for f in findings): - findings.insert(0, _f("ok", f"Fits {source}'s {bx:.0f}×{by:.0f}×{bz:.0f} mm bed " + findings.insert(0, _f("ok", f"By size, {who} fits {source}'s {bx:.0f}×{by:.0f}×{bz:.0f} mm bed " f"with room to spare ({x:.0f}×{y:.0f}×{z:.0f} mm).")) - overall_text = { - "ok": f"This model fits {source}'s bed.", - "warn": "It fits, but the edges are tight — see below before slicing.", - "risk": "It won't fit as-is — this is the out-of-bounds error, with the fix below.", - }[worst] + size_level = "risk" if (over_x or over_y or over_z) else ( + "warn" if (x > bx * EDGE_FRAC or y > by * EDGE_FRAC) else "ok") + checked_placement = bool(placed and placed.get("available")) + if size_level == "risk": + overall_text = "By size, it won't fit as-is — this is the out-of-bounds error, with the fix below." + elif placed_level == "risk": + overall_text = (f"By size, {who} is small enough for {source}'s bed, but the placement is a " + "problem — see the placement finding below.") + elif size_level == "warn": + overall_text = "By size it fits, but the edges are tight — see below before slicing." + elif worst == "warn": + overall_text = "By size it fits, but there is a point to check — see below before slicing." + elif checked_placement: + overall_text = f"By size, {who} fits {source}'s bed, and the placement check found nothing off the plate." + else: + overall_text = (f"By size, {who} fits {source}'s bed. Where it sits on the plate was not " + "checked here.") return { "schema_version": SCHEMA_VERSION, @@ -142,8 +174,80 @@ def bump(level: str) -> None: "measured_against": printer_profiles.summarise(None if bed_known else profile), "bed_mm": {"x": bx, "y": by, "z": bz}, "dims_mm": {"x": round(x, 1), "y": round(y, 1), "z": round(z, 1)}, + "basis": "size_and_placement" if checked_placement else "size", + "subject": subject, + "placement_checked": checked_placement, "overall_level": worst, "overall_text": overall_text, "findings": findings, "fixes": fixes, } + + +def _placed_findings(placed: dict | None, findings: list, fixes: list) -> str | None: + """Add the "By placement" statements; returns the level they raised, or None.""" + if not placed or not placed.get("available"): + return None + plate_count = placed.get("plate_count") or 1 + if plate_count > 1: + # Plates share one grid with unrecorded spacing: each plate is judged on whether its own + # contents fit, never on a combined extent across plates. + oversized = placed.get("oversized_plates") or [] + if oversized: + for plate in oversized: + findings.append(_f("risk", f"By placement, plate {plate['plate']}: {plate['reason']}. " + "Scale it down or split it.")) + fixes.append("Scale the oversized plate down or split it, then use Arrange in Orca.") + return "risk" + findings.append(_f("ok", f"By placement, each of the {plate_count} plates fits on its own. " + "Positions across plates are not checked; use Arrange in Orca.")) + return None + if not placed.get("off_plate"): + findings.append(_f("ok", "By placement, every placed instance sits inside the plate.")) + return None + findings.append(_f("risk", f"By placement: {placed.get('summary')}")) + fixes.append("Move the arrangement onto the plate (Studio can save a corrected copy), " + "or use Arrange in Orca." if placed.get("fixable") else + "Use Arrange in Orca to bring every instance onto the plate.") + return "risk" + + +_ORDER = {"ok": 0, "warn": 1, "risk": 2} + + +def assess_objects(objects: list[dict] | None, fallback_dims=None, placed: dict | None = None, + **kw) -> dict: + """Size every object on its own, report the worst, and add the placement separately. + + ``objects`` is ``intelligence.project_info()["object_sizes_mm"]``. The combined extents of + several objects are never used: a project of small, separated objects is not one big + object, and "scale to 40%" would shrink all of them for nothing. With no per-object sizes + (a file Studio could not measure per object) the overall extents are used and the text + says they are an overall figure, by size. + """ + objects = [o for o in (objects or []) if o.get("dimensions_mm")] + if not objects: + out = assess(fallback_dims, placed=placed, subject="this model (overall extents)", **kw) + out["objects_checked"] = 0 + return out + many = len(objects) > 1 + + def label_of(entry): + return f"object {entry['object_id']}" if many and entry.get("object_id") is not None else None + + # Size first, object by object and WITHOUT the placement, so a level here is purely about size. + sized = [] + for entry in objects[:64]: + res = assess(entry["dimensions_mm"], subject=label_of(entry), **kw) + if not res.get("available"): + return res + sized.append((entry, res)) + worst_entry, _ = max( + sized, key=lambda pair: (_ORDER[pair[1]["overall_level"]], + pair[0]["dimensions_mm"]["x"] * pair[0]["dimensions_mm"]["y"])) + # Then the one result that is reported, with the placement added once. + out = assess(worst_entry["dimensions_mm"], placed=placed, subject=label_of(worst_entry), **kw) + out["objects_checked"] = len(sized) + out["objects_by_size"] = [{"object_id": e.get("object_id"), "level": r["overall_level"], + "dims_mm": r["dims_mm"]} for e, r in sized] + return out diff --git a/backend/snapstudio_core/geometry.py b/backend/snapstudio_core/geometry.py index a90c6d94..4d62a6b8 100644 --- a/backend/snapstudio_core/geometry.py +++ b/backend/snapstudio_core/geometry.py @@ -106,16 +106,33 @@ def appA(v): def build_item_dims(path: str) -> list[dict]: """Per-build-item bounding-box dimensions (mm), with the 3MF build transform and nested component transforms applied — i.e. each placed object's real on-plate - size. Read-only, exception-safe (returns [] on any failure). Used by the Scale - Doctor size-options ladder for per-plate dimensions.""" + size, one entry per build item (a repeated object appears once per item). + Read-only, exception-safe (returns [] on any failure). Used by the Scale Doctor + size-options ladder for per-plate dimensions. This is a PLACED measure: it + depends on where, and how turned, the item is. For the size of the object + itself see ``object_sizes``.""" + return measure(path)[0] + + +def object_sizes(path: str) -> list[dict]: + """Per-object size (mm): each root object as modelled, with its nested component + transforms applied but BEFORE any build item moves, turns or scales it. One entry + per distinct object (``instance_count`` says how many build items use it). This is + a SIZE measure and says nothing about where anything sits.""" + return measure(path)[1] + + +def measure(path: str) -> tuple[list[dict], list[dict]]: + """``(placed items, object sizes)`` from a single read of the project. See + ``build_item_dims`` and ``object_sizes``.""" try: tm = ThreeMF.open(path) except Exception: - return [] + return [], [] try: model_files = {p: tm.read_part(p) for p in tm.list_parts() if p.endswith(".model")} if sum(len(b) for b in model_files.values()) > _MAX_BYTES: - return [] + return [], [] # object_id -> (verts, [(component_objectid, component_path, component_xform)]), per file parsed: dict[str, dict[str, tuple]] = {} @@ -150,7 +167,7 @@ def build_item_dims(path: str) -> list[dict]: root_file = "3D/3dmodel.model" if root_file not in parsed: - return [] + return [], [] def find_obj(objid, prefer): if prefer in parsed and objid in parsed[prefer]: @@ -179,17 +196,32 @@ def collect(objid, prefer_file, xform, acc, seen): for cid, cpath, ctf in comps: collect(cid, cpath or f, _compose(xform, ctf), acc, seen) + def box_of(acc): + xs = [p[0] for p in acc]; ys = [p[1] for p in acc]; zs = [p[2] for p in acc] + return (min(xs), min(ys), min(zs)), (max(xs), max(ys), max(zs)) + out = [] + sizes: dict[str, dict] = {} for ordinal, it in enumerate(load_model_settings(model_files[root_file]).iter(f"{_3MF_CORE_NS}item")): oid = it.get("objectid") + if oid in sizes: + sizes[oid]["instance_count"] += 1 + else: + bare: list = [] + collect(oid, root_file, None, bare, frozenset()) + if bare: + lo, hi = box_of(bare) + sizes[oid] = {"object_id": oid, "instance_count": 1, + "dimensions": {"x": round(hi[0] - lo[0], 2), + "y": round(hi[1] - lo[1], 2), + "z": round(hi[2] - lo[2], 2)}} acc: list = [] collect(oid, root_file, _units.scale_translation(_xform(it.get("transform")), unit_scale[root_file]), acc, frozenset()) if not acc: continue - xs = [p[0] for p in acc]; ys = [p[1] for p in acc]; zs = [p[2] for p in acc] - lo = (min(xs), min(ys), min(zs)); hi = (max(xs), max(ys), max(zs)) + lo, hi = box_of(acc) # `item_index` is the build item's ordinal in the root model: one object used by two items is # two entries, and this tells them apart (an item with no geometry leaves a gap in the sequence). out.append({"object_id": oid, @@ -198,11 +230,11 @@ def collect(objid, prefer_file, xform, acc, seen): "y": round(hi[1] - lo[1], 2), "z": round(hi[2] - lo[2], 2)}, "bounds": {"min": lo, "max": hi}}) - return out + return out, list(sizes.values()) except _TooLarge: - return [] + return [], [] except Exception: - return [] + return [], [] def load_mesh(path: str) -> Mesh | None: diff --git a/backend/snapstudio_core/intelligence.py b/backend/snapstudio_core/intelligence.py index 0dd30bec..bb800c7d 100644 --- a/backend/snapstudio_core/intelligence.py +++ b/backend/snapstudio_core/intelligence.py @@ -113,6 +113,35 @@ def project_info(path: str) -> dict: except Exception: pass # geometry/materials are best-effort; diagnosis still stands + # SIZE and PLACEMENT are different facts and are kept apart here. + # object_sizes_mm how big each object is (per object, millimetres, before any build item + # moves, turns or scales it). Says nothing about where anything sits. + # placed where each instance sits and on which plate (placed bounds). + # dimensions_mm kept for existing readers: the overall extents of all the mesh data + # together. A size-only figure; for several objects it is NOT one object's + # size and NOT a position (dimensions_basis says so). + object_sizes: list[dict] = [] + placed: dict + if is_stl: + if dims: + object_sizes = [{"object_id": None, "instance_count": 1, "dimensions_mm": dict(dims)}] + placed = {"available": False, "basis": "placed_bounds", "instances": [], + "plate_count": 0, "plate_extents": [], "combined_extent_mm": None, + "reason": "A bare STL has no placement of its own; Studio centers it when it wraps " + "the model into a U1 project."} + else: + try: + from . import geometry, plate_placement + placed_items, sizes = geometry.measure(path) + object_sizes = [{"object_id": s["object_id"], "instance_count": s["instance_count"], + "dimensions_mm": {k: round(v, 1) for k, v in s["dimensions"].items()}} + for s in sizes] + placed = plate_placement.placed_instances(path, items=placed_items) + except Exception: + placed = {"available": False, "basis": "placed_bounds", "instances": [], + "plate_count": 0, "plate_extents": [], "combined_extent_mm": None, + "reason": "Studio could not read where the objects sit in this project."} + return { "schema_version": SCHEMA_VERSION, "name": Path(path).name, @@ -128,6 +157,9 @@ def project_info(path: str) -> dict: "painted": diag.get("painted"), "materials": materials, "dimensions_mm": dims, + "dimensions_basis": "overall_extents_by_size", + "object_sizes_mm": object_sizes, + "placed": placed, "triangles": triangles, "complexity": _complexity(triangles), "issues": [*(diag.get("validation_issues") or []), *(diag.get("compatibility_issues") or [])], diff --git a/backend/snapstudio_core/intelligence_report.py b/backend/snapstudio_core/intelligence_report.py index 8f650fcf..9f7d8592 100644 --- a/backend/snapstudio_core/intelligence_report.py +++ b/backend/snapstudio_core/intelligence_report.py @@ -119,7 +119,7 @@ def build(predict=None, bed_fit=None, mm=None, first_layer=None, health=None, # --- risks + recommendations from every doctor --- risks: list = [] recs: list = [] - _push_findings(risks, recs, "Size fit (dimensions only)", bed_fit) + _push_findings(risks, recs, "Bed fit (size and placement)", bed_fit) _push_findings(risks, recs, "Multi-Material Doctor", mm) _push_findings(risks, recs, "First Layer Doctor", first_layer) if avail["health"]: diff --git a/backend/snapstudio_core/plate_placement.py b/backend/snapstudio_core/plate_placement.py index a9f4a665..cba7ba71 100644 --- a/backend/snapstudio_core/plate_placement.py +++ b/backend/snapstudio_core/plate_placement.py @@ -214,6 +214,71 @@ def _printable_only(path: str, items: list[dict]) -> list[dict]: return out +def placed_instances(path: str, items: list[dict] | None = None) -> dict: + """Where each build item sits, with the plate it belongs to: PLACED bounds. + + This is the placed counterpart of a size. It answers "where is each instance" and + "which plate is it on", never "is the object small enough" (that is a size, per + object, from ``geometry.object_sizes``). Bounds are per instance in millimetres, + measured from the parts that print. ``items`` may carry ``geometry.build_item_dims`` + output already read, to save reading the project twice. When the project has more than one plate the + plates sit on one shared grid whose spacing the file does not record, so no extent + is combined across them: ``plate_extents`` gives each plate its own, and + ``combined_extent_mm`` is None. Read-only, never raises. + """ + from . import geometry + + try: + tm = ThreeMF.open(path) + except Exception: + return {"available": False, "basis": "placed_bounds", "instances": [], + "plate_count": 0, "plate_extents": [], "combined_extent_mm": None, + "reason": "Studio could not open this file as a 3MF project."} + items = _printable_only(path, geometry.build_item_dims(path) if items is None else items) + if not items: + return {"available": False, "basis": "placed_bounds", "instances": [], + "plate_count": 0, "plate_extents": [], "combined_extent_mm": None, + "reason": "Studio could not read where the objects sit in this project."} + + plates = _plates_from_model_settings(tm) + numbers = sorted({p["ui_number"] for p in plates if p.get("ui_number") is not None}) + plate_count = max(len(numbers), 1) + grouped, _unresolved = _group_items_by_plate(items, plates) if plates else ({}, items) + plate_of = {i.get("item_index"): number for number, members in grouped.items() for i in members} + + totals: dict[str, int] = {} + for item in items: + totals[str(item["object_id"])] = totals.get(str(item["object_id"]), 0) + 1 + seen: dict[str, int] = {} + instances = [] + for item in items: + oid = str(item["object_id"]) + ordinal = seen.get(oid, 0) + seen[oid] = ordinal + 1 + lo, hi = item["bounds"]["min"], item["bounds"]["max"] + instances.append({ + "item_index": item.get("item_index"), + "object_id": item["object_id"], + "instance_index": ordinal, + "instance_count": totals[oid], + "plate": plate_of.get(item.get("item_index")), + "bounds_mm": {"min": [round(v, 2) for v in lo], "max": [round(v, 2) for v in hi]}, + "dimensions_mm": dict(item["dimensions"]), + }) + + def extent(members: list[dict]) -> dict: + cluster = _cluster_bounds(members) + return {"x": round(cluster["max_x"] - cluster["min_x"], 1), + "y": round(cluster["max_y"] - cluster["min_y"], 1)} + + plate_extents = [dict(plate=number, instances=len(members), **extent(members)) + for number, members in sorted(grouped.items())] + return {"available": True, "basis": "placed_bounds", "instances": instances, + "plate_count": plate_count, "plate_extents": plate_extents, + "combined_extent_mm": extent(items) if plate_count <= 1 else None, + "reason": None} + + def _unavailable(reason: str) -> dict: return {"schema_version": SCHEMA_VERSION, "available": False, "reason": reason, "items": [], "off_plate": [], "fixable": False} diff --git a/backend/snapstudio_core/validation_report.py b/backend/snapstudio_core/validation_report.py index 0e7db096..462c3de9 100644 --- a/backend/snapstudio_core/validation_report.py +++ b/backend/snapstudio_core/validation_report.py @@ -48,13 +48,36 @@ def readiness_report(path: str) -> dict: verdict in (READY, CONVERTIBLE, "REPAIRABLE"), "Prepare a U1 copy and review in Orca" if verdict != READY else "Review in Orca before slicing")) - if dims: + # SIZE, not position: each object against the bed on its own. Where the objects sit on the plate is + # judged below under "Layout / plate fit", and the combined extents of several separated objects or + # plates are never read as one big object. + sized = [o for o in (info.get("object_sizes_mm") or []) if o.get("dimensions_mm")] + if sized: + def _over(entry): + d = entry["dimensions_mm"] + return d["x"] > U1_BUILD[0] or d["y"] > U1_BUILD[1] or d["z"] > U1_BUILD[2] + too_big = [o for o in sized if _over(o)] + shown = too_big[0] if too_big else max( + sized, key=lambda o: o["dimensions_mm"]["x"] * o["dimensions_mm"]["y"]) + d = shown["dimensions_mm"] + many = len(sized) > 1 and shown.get("object_id") is not None + label = f'object {shown["object_id"]} ' if many else "" + fits = not too_big + checks.append(_check( + "Fits the print bed", + fits, + f'By size, {label}{d["x"]} × {d["y"]} × {d["z"]} mm ' + + (("is the largest object and fits 270 × 270 × 270" if len(sized) > 1 else "fits 270 × 270 × 270") + if fits else + "is larger than the U1 bed — scale or split" + ( + f" ({len(too_big)} objects are)" if len(too_big) > 1 else "")))) + elif dims: fits = dims["x"] <= U1_BUILD[0] and dims["y"] <= U1_BUILD[1] and dims["z"] <= U1_BUILD[2] checks.append(_check( "Fits the print bed", fits, - f'{dims["x"]} × {dims["y"]} × {dims["z"]} mm ' - + ("fits 270 × 270 × 270" if fits else "is larger than the U1 bed — scale or split"))) + f'By size, the overall extents {dims["x"]} × {dims["y"]} × {dims["z"]} mm ' + + ("fit 270 × 270 × 270" if fits else "are larger than the U1 bed — check each object in Orca"))) if colors: colors_ok = colors <= U1_TOOLHEADS checks.append(_check( diff --git a/backend/tests/test_size_vs_placed_bounds.py b/backend/tests/test_size_vs_placed_bounds.py new file mode 100644 index 00000000..29d9de7a --- /dev/null +++ b/backend/tests/test_size_vs_placed_bounds.py @@ -0,0 +1,202 @@ +"""Issue 91: the size of an object is not where it sits. + +Size-only statements (how big is each object) and placed statements (where is each instance, on which +plate) are separate facts. Each consumer says which one it uses; a size never claims a position, and the +combined extents of separated objects or plates never become "scale or split" advice. +""" +from __future__ import annotations + +import pytest + +from snapstudio_api import service +from snapstudio_core import bed_fit, geometry, plate_placement as pp +from snapstudio_core.intelligence import project_info +from tests import scene_fixtures as fx + +SETTINGS = "Metadata/model_settings.config" + + +def box_object(oid, dx, dy, dz, origin=(0.0, 0.0, 0.0)): + ox, oy, oz = origin + verts = [(ox, oy, oz), (ox + dx, oy, oz), (ox + dx, oy + dy, oz), (ox, oy + dy, oz), + (ox, oy, oz + dz), (ox + dx, oy, oz + dz), (ox + dx, oy + dy, oz + dz), (ox, oy + dy, oz + dz)] + return f'{fx.mesh_xml(verts, fx.CUBE_TRIS)}' + + +def project(tmp_path, objects, items, name="p.3mf", settings=None, unit="millimeter"): + extra = {SETTINGS: settings} if settings else None + return str(fx.three_mf(tmp_path / name, fx.model_xml(objects, items, unit=unit), extra)) + + +def texts(result): + return " ".join(f["text"] for f in result["findings"]) + " " + result["overall_text"] + + +# --- size is per object and before any build item acts on it ---------------------------------------- + +def test_object_size_ignores_where_the_item_puts_the_object(tmp_path): + path = project(tmp_path, [box_object("1", 100, 20, 10)], + [("1", fx.tf(500, 40, 0)), ("1", "0 1 0 -1 0 0 0 0 1 50 50 0")]) # moved; and turned 90 degrees + sizes = geometry.object_sizes(path) + assert sizes == [{"object_id": "1", "instance_count": 2, "dimensions": {"x": 100.0, "y": 20.0, "z": 10.0}}] + placed = geometry.build_item_dims(path) + assert placed[0]["dimensions"] == {"x": 100.0, "y": 20.0, "z": 10.0} + assert placed[1]["dimensions"] == {"x": 20.0, "y": 100.0, "z": 10.0} # placed bounds follow the turn + + +def test_object_size_is_in_millimetres_for_an_inch_project(tmp_path): + path = project(tmp_path, [box_object("1", 12, 1, 1)], [("1", fx.tf(0, 0, 0))], unit="inch") + assert geometry.object_sizes(path)[0]["dimensions"] == {"x": 304.8, "y": 25.4, "z": 25.4} + info = project_info(path) + assert info["object_sizes_mm"][0]["dimensions_mm"]["x"] == 304.8 + + +def test_project_info_keeps_size_and_placed_apart(tmp_path): + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(500, 100, 0)), ("1", fx.tf(100, 100, 0))]) + info = project_info(path) + assert info["dimensions_basis"] == "overall_extents_by_size" + assert info["object_sizes_mm"] == [{"object_id": "1", "instance_count": 2, + "dimensions_mm": {"x": 10.0, "y": 10.0, "z": 10.0}}] + placed = info["placed"] + assert placed["available"] and placed["basis"] == "placed_bounds" and placed["plate_count"] == 1 + assert [i["bounds_mm"]["min"][0] for i in placed["instances"]] == [500.0, 100.0] + assert placed["combined_extent_mm"] == {"x": 410.0, "y": 10.0} + + +def test_stl_has_a_size_and_no_position(tmp_path): + path = str(fx.binary_stl(tmp_path / "c.stl", 20.0)) + info = project_info(path) + assert info["object_sizes_mm"][0]["dimensions_mm"] == {"x": 20.0, "y": 20.0, "z": 20.0} + assert info["placed"]["available"] is False and info["placed"]["instances"] == [] + + +# --- translated: a small object moved off the plate ----------------------------------------------------- + +def test_a_small_object_moved_off_the_plate_is_not_reported_as_fitting(tmp_path): + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(500, 100, 0))]) + result = service.bed_fit(path) + assert result["overall_level"] == "risk" + text = texts(result) + assert "By size" in text and "small enough" in result["overall_text"] + assert "By placement" in text and "outside" in text + assert "too big" not in text.lower() and not any("scale to" in f.lower() for f in result["fixes"]) + assert result["basis"] == "size_and_placement" and result["placement_checked"] is True + + +def test_the_size_check_alone_claims_no_position(tmp_path): + out = bed_fit.assess({"x": 10, "y": 10, "z": 10}) + assert out["basis"] == "size" and out["placement_checked"] is False + assert out["overall_text"].startswith("By size") + assert "not checked" in out["overall_text"] + assert all(f["text"].startswith("By size") for f in out["findings"]) + + +def test_a_small_object_inside_the_plate_is_ok_by_size_and_placement(tmp_path): + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))]) + result = service.bed_fit(path) + assert result["overall_level"] == "ok" + assert "By placement, every placed instance sits inside the plate" in texts(result) + + +# --- separated: meshes far apart in their own coordinates, objects close together on the plate ---------- + +def _separated(tmp_path): + # Object 2's mesh is authored 500 mm from object 1's; its build item brings it back beside it. + return project(tmp_path, + [box_object("1", 20, 20, 20), box_object("2", 20, 20, 20, origin=(500, 0, 0))], + [("1", fx.tf(50, 50, 0)), ("2", fx.tf(-400, 50, 0))]) + + +def test_separated_meshes_are_not_one_big_object(tmp_path): + path = _separated(tmp_path) + info = project_info(path) + assert info["dimensions_mm"]["x"] == 520.0 # the overall extents, as before + assert [o["dimensions_mm"]["x"] for o in info["object_sizes_mm"]] == [20.0, 20.0] + # the legacy overall figure would say "too big, scale to 52%"; the per-object check does not + assert bed_fit.assess(info["dimensions_mm"])["overall_level"] == "risk" + result = service.bed_fit(path) + assert result["overall_level"] == "ok", texts(result) + assert result["objects_checked"] == 2 + assert "too big" not in texts(result).lower() and result["fixes"] == [] + assert pp.assess(path)["off_plate"] == [] + + +def test_separated_report_check_is_by_size_per_object(tmp_path): + check = next(c for c in service.report(_separated(tmp_path))["checks"] if c["name"] == "Fits the print bed") + assert check["status"] == "pass" and check["detail"].startswith("By size, object ") + assert "20.0 × 20.0 × 20.0 mm" in check["detail"] and "largest object" in check["detail"] + + +def test_report_names_the_object_that_is_too_big_by_size(tmp_path): + path = project(tmp_path, [box_object("1", 20, 20, 20), box_object("2", 300, 20, 20)], + [("1", fx.tf(10, 10, 0)), ("2", fx.tf(10, 100, 0))]) + check = next(c for c in service.report(path)["checks"] if c["name"] == "Fits the print bed") + assert check["status"] == "warn" + assert check["detail"].startswith("By size, object 2 300.0 × 20.0 × 20.0 mm is larger than the U1 bed") + result = service.bed_fit(path) + assert result["overall_level"] == "risk" and "By size, object 2 is too big" in texts(result) + + +def test_objects_far_apart_on_one_plate_are_a_placement_problem_not_a_size_problem(tmp_path): + path = project(tmp_path, [fx.cube_object("1", 20), fx.cube_object("2", 20)], + [("1", fx.tf(10, 10, 0)), ("2", fx.tf(400, 10, 0))]) + result = service.bed_fit(path) + assert result["overall_level"] == "risk" + assert all(o["level"] == "ok" for o in result["objects_by_size"]) # each object is small enough + assert "By placement" in texts(result) and "By size, object" not in " ".join( + f["text"] for f in result["findings"] if f["level"] == "risk") + + +# --- multi-plate ------------------------------------------------------------------------------------------ + +def _two_plates(tmp_path, second_x=-130.0, second_size=20): + settings = fx.model_settings_xml({"1": [("1", "normal_part")], "2": [("2", "normal_part")]}, + [(1, [("1", 0)]), (2, [("2", 0)])]) + # plate 2 sits 400 mm along the shared grid: its object's mesh is authored there + return project(tmp_path, + [box_object("1", 20, 20, 20), box_object("2", second_size, 20, 20, origin=(400, 0, 0))], + [("1", fx.tf(50, 50, 0)), ("2", fx.tf(second_x, 50, 0))], settings=settings) + + +def test_a_multi_plate_project_has_no_combined_extent(tmp_path): + path = _two_plates(tmp_path) + placed = project_info(path)["placed"] + assert placed["plate_count"] == 2 and placed["combined_extent_mm"] is None + assert [(p["plate"], p["instances"]) for p in placed["plate_extents"]] == [(1, 1), (2, 1)] + assert [i["plate"] for i in placed["instances"]] == [1, 2] + + +def test_multi_plate_extents_do_not_become_scale_or_split_advice(tmp_path): + path = _two_plates(tmp_path) + info = project_info(path) + assert info["dimensions_mm"]["x"] > 400.0 # overall: 420 across both plates + result = service.bed_fit(path) + text = texts(result) + assert result["overall_level"] == "ok", text + assert "each of the 2 plates fits on its own" in text and "Positions across plates are not checked" in text + assert "scale" not in text.lower() and result["fixes"] == [] + + +def test_a_plate_whose_own_contents_do_not_fit_is_named(tmp_path): + path = _two_plates(tmp_path, second_size=300) + result = service.bed_fit(path) + assert result["overall_level"] == "risk" + assert any(f["text"].startswith("By placement, plate 2:") for f in result["findings"]) + assert any(f["text"].startswith("By size, object 2 is too big") for f in result["findings"]) + + +def test_report_check_for_multi_plate_is_still_by_size_per_object(tmp_path): + check = next(c for c in service.report(_two_plates(tmp_path))["checks"] if c["name"] == "Fits the print bed") + assert check["status"] == "pass" and check["detail"].startswith("By size, object ") + + +# --- the legacy figure is still there for readers that only want a size --------------------------------- + +def test_dimensions_mm_is_unchanged_for_a_single_object(tmp_path): + path = project(tmp_path, [fx.cube_object("1", 30)], [("1", fx.tf(50, 60, 0))]) + assert project_info(path)["dimensions_mm"] == {"x": 30.0, "y": 30.0, "z": 30.0} + + +@pytest.mark.parametrize("dims", [None, {"x": None, "y": 1, "z": 1}]) +def test_unmeasurable_size_stays_unavailable(dims): + assert bed_fit.assess_objects([], fallback_dims=dims)["available"] is False diff --git a/desktop/src/api.ts b/desktop/src/api.ts index cdc9d1d5..830c0874 100644 --- a/desktop/src/api.ts +++ b/desktop/src/api.ts @@ -339,7 +339,13 @@ export interface Insights { colors: number | null; painted: boolean; materials: { color: string; type: string | null }[]; + // Overall extents of all the mesh data together: a size-only figure, not one object's size and not a position. dimensions_mm: { x: number; y: number; z: number } | null; + dimensions_basis?: string; + // Per-object size (mm), before any build item moves, turns or scales it. + object_sizes_mm?: ObjectSize[]; + // Where each instance sits and on which plate (placed bounds), kept apart from the sizes above. + placed?: PlacedInstances; triangles: number | null; complexity: string | null; issues: string[]; @@ -1297,6 +1303,30 @@ export async function ecosystemAdvice( // while passing every size check. These read where each object sits and, when a // single honest move fixes it, write a NEW copy — the original is never touched. +export interface ObjectSize { + object_id: string | null; + instance_count: number; + dimensions_mm: { x: number; y: number; z: number }; +} + +export interface PlacedInstances { + available: boolean; + basis: string; + reason?: string | null; + plate_count: number; + instances: { + item_index: number | null; + object_id: string; + instance_index: number; + instance_count: number; + plate: number | null; + bounds_mm: { min: number[]; max: number[] }; + dimensions_mm: { x: number; y: number; z: number }; + }[]; + plate_extents: { plate: number; instances: number; x: number; y: number }[]; + combined_extent_mm: { x: number; y: number } | null; +} + export interface PlacementItem { object_id: string; // One object can be used by several build items; these tell the instances apart. diff --git a/desktop/src/components/DesignHealth.tsx b/desktop/src/components/DesignHealth.tsx index cbb38545..972b6e56 100644 --- a/desktop/src/components/DesignHealth.tsx +++ b/desktop/src/components/DesignHealth.tsx @@ -1,7 +1,8 @@ import { CheckCircle2, AlertTriangle, ShieldAlert, Droplets, Boxes, MoveVertical, LifeBuoy, Ruler } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; -import type { MeshReport } from "@/api"; +import type { MeshReport, ObjectSize } from "@/api"; +import { sizeRow } from "@/lib/sizeFit"; // U1 build volume (mm) — matches the engine's bed-fit check. const U1_BED = { x: 270, y: 270, z: 270 }; @@ -18,7 +19,7 @@ const STATUS_ICON: Record = { 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): Row[] { const rows: Row[] = []; const integ = mesh.integrity; const fitBed = bed ?? U1_BED; @@ -67,30 +68,28 @@ 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) { - 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.` }); + // 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. + const fit = sizeRow(objects, dims, fitBed, bedSource); + 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, bed, mode = "simple" }: { mesh?: MeshReport; dims?: { x: number; y: number; z: number } | null; + objects?: ObjectSize[] | null; 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); if (!rows.length) return null; return ( diff --git a/desktop/src/lib/sizeFit.test.ts b/desktop/src/lib/sizeFit.test.ts new file mode 100644 index 00000000..b8f089c3 --- /dev/null +++ b/desktop/src/lib/sizeFit.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import type { ObjectSize } from "@/api"; +import { 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 points at placement for position", () => { + 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("Where it sits on the plate is checked separately"); + }); + + 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("falls back to the overall extents and says so", () => { + const row = sizeRow([], { x: 300, y: 10, z: 10 }, BED, "your connected U1"); + expect(row?.level).toBe("risk"); + expect(row?.detail).toContain("By size, the overall extents"); + expect(sizeRow(undefined, null, BED, "x")).toBeNull(); + }); +}); diff --git a/desktop/src/lib/sizeFit.ts b/desktop/src/lib/sizeFit.ts new file mode 100644 index 00000000..887f7cc1 --- /dev/null +++ b/desktop/src/lib/sizeFit.ts @@ -0,0 +1,67 @@ +// 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 function sizeRow( + objects: ObjectSize[] | null | undefined, + overall: Dims | null | undefined, + bed: Bed, + bedSource: string, +): 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 fitsBed(overall, bed) + ? { level: "ok", status: "Fits", detail: `By size, the overall extents ${mm(overall)} fit ${bedSource} ${bedStr} bed.` } + : { + level: "risk", + status: "Too big", + detail: `By size, the overall extents ${mm(overall)} are larger than ${bedSource} ${bedStr} bed — check each object on its own in Snapmaker Orca.`, + }; + } + 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}`, + }; + } + 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: "ok", + status: "Fits", + detail: `By size, ${subject}. Where it sits on the plate is checked separately, under Object placement.`, + }; +} diff --git a/desktop/src/routes/DesignInsights.tsx b/desktop/src/routes/DesignInsights.tsx index f2befa61..c199a90c 100644 --- a/desktop/src/routes/DesignInsights.tsx +++ b/desktop/src/routes/DesignInsights.tsx @@ -428,7 +428,7 @@ export default function DesignInsights() {
Design Health
- +
)} diff --git a/desktop/src/routes/LiveWorkspace.tsx b/desktop/src/routes/LiveWorkspace.tsx index 35a1711d..421a481f 100644 --- a/desktop/src/routes/LiveWorkspace.tsx +++ b/desktop/src/routes/LiveWorkspace.tsx @@ -125,7 +125,7 @@ export default function LiveWorkspace() {
Design Health
- +
)} From 54a22747f3c48dc2eb00c21f39e0265f25673bed Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Sat, 10 Oct 2026 09:04:14 -0400 Subject: [PATCH 2/9] fix(bed-fit): identified printer's printable area, locked caches (#91 repair 3) Merges the #93 repair (printable area not travel, unjudged/tall never OK, safe move writer) and then: - service.bed_fit identifies a connected printer from what it reports: the U1 (or another profiled printer) is checked against its recorded printable volume and polygon; one that cannot be identified keeps a size check against what it reported and gets no placement verdict. - The measurement caches take a lock around every read and write. - Tests: unidentified printer, unjudged instances in a two-plate project stay a warning, caches under eight threads. Verified: backend pytest 3243 passed / 49 skipped; desktop tsc, vitest (826 passed) and build clean. Co-Authored-By: Claude Sonnet 5.5 --- backend/snapstudio_api/service.py | 31 +++++++++--- backend/snapstudio_core/geometry.py | 18 ++++--- backend/snapstudio_core/placement.py | 17 +++++-- backend/tests/test_size_unknown_and_limits.py | 49 ++++++++++++++++++- 4 files changed, 94 insertions(+), 21 deletions(-) diff --git a/backend/snapstudio_api/service.py b/backend/snapstudio_api/service.py index 1a5716ab..50d68feb 100644 --- a/backend/snapstudio_api/service.py +++ b/backend/snapstudio_api/service.py @@ -2059,24 +2059,33 @@ def bed_fit(path: str, host: str | None = None, port: int = 7125) -> dict: multi = (info.get("colors") or 0) > 1 bed = None bed_known = False + # Offline: the machine Studio prepares for (its profile polygon). Connected: the printable area of + # the printer that identifies itself; a printer's reported bed is axis TRAVEL (about 271 x 335 on a + # U1, anchored at 0,0), never used as a printable rectangle, so a printer Studio cannot identify + # gets a size check against what it reported and NO placement check. + target = _placement_target({"identity": {"printer_id": printer_profiles_target_id()}}) if host: - from snapstudio_core import moonraker + from snapstudio_core import moonraker, printer_profiles + target = None try: caps = moonraker.capabilities(host, port) + identity = printer_profiles.identify({"reachable": True, + "klipper_objects": caps.get("klipper_objects"), + "toolhead_count": caps.get("toolhead_count")}) + target = _placement_target({"identity": identity}) bm = caps.get("bed_mm") - if bm and bm.get("x"): + if identity.get("printer_id"): + volume = printer_profiles.load(identity["printer_id"]).get("build_volume_mm") or {} + if volume.get("x") and volume.get("y") and volume.get("z"): + bed, bed_known = volume, True + elif bm and bm.get("x"): bed, bed_known = bm, True except Exception: pass # Two separate facts: how big each object is (size, per object) and where the instances sit # (placement, per instance and plate). A combined extent is neither, and is never used here. - placed = None try: - from snapstudio_core import plate_placement - # The printable area of the machine Studio prepares for (its profile polygon and edge margin). - # A connected printer's reported bed is axis TRAVEL (about 271 x 335 on a U1, anchored at 0,0), - # not a printable rectangle, so it is never used as one here. - placed = plate_placement.assess(path) + placed = _assess_against(path, target) except Exception: placed = None return bf.assess_objects(info.get("object_sizes_mm"), placed=placed, @@ -2588,6 +2597,12 @@ def batch_status(job_id: str) -> dict | None: _ready_cache_lock = threading.Lock() +def printer_profiles_target_id() -> str: + from snapstudio_core import printer_profiles + + return printer_profiles.PREPARE_TARGET_ID + + def _placement_target(facts: dict) -> dict | None: """The printable area to judge placement against, or None when it is not known. diff --git a/backend/snapstudio_core/geometry.py b/backend/snapstudio_core/geometry.py index d3a4263b..46167ffa 100644 --- a/backend/snapstudio_core/geometry.py +++ b/backend/snapstudio_core/geometry.py @@ -11,6 +11,7 @@ from __future__ import annotations from dataclasses import dataclass import copy +import threading from pathlib import Path from .container import ThreeMF @@ -187,6 +188,7 @@ def measure(path: str) -> tuple[list[dict], list[dict], list[dict]]: #: placement and the layout of the same file, and each of those used to re-read every mesh. _CACHE: dict = {} _CACHE_SLOTS = 4 +_CACHE_LOCK = threading.Lock() # requests run on several threads; the dict is never touched unlocked def stat_identity(path: str): @@ -221,16 +223,20 @@ def file_identity(path: str): def _measure(path: str): key = file_identity(path) - if key is not None and key in _CACHE: - items, unresolved, sizes = _CACHE[key] - return copy.deepcopy((items, unresolved, sizes)) + if key is not None: + with _CACHE_LOCK: + hit = _CACHE.get(key) + if hit is not None: + return copy.deepcopy(hit) result = _measure_uncached(path) # only remember it if the file is still the one that was read: a change during the measurement # would otherwise be filed under the identity of the old content if key is not None and stat_identity(path) == key[:3]: - while len(_CACHE) >= _CACHE_SLOTS: - _CACHE.pop(next(iter(_CACHE))) - _CACHE[key] = copy.deepcopy(result) + stored = copy.deepcopy(result) + with _CACHE_LOCK: + while len(_CACHE) >= _CACHE_SLOTS: + _CACHE.pop(next(iter(_CACHE)), None) + _CACHE[key] = stored return result diff --git a/backend/snapstudio_core/placement.py b/backend/snapstudio_core/placement.py index 83a187ef..c61c83ec 100644 --- a/backend/snapstudio_core/placement.py +++ b/backend/snapstudio_core/placement.py @@ -23,6 +23,7 @@ import json import re +import threading import zipfile from . import units as _units @@ -212,6 +213,7 @@ def _read(archive: zipfile.ZipFile, name: str) -> str: _READ_CACHE: dict = {} _READ_SLOTS = 4 +_READ_LOCK = threading.Lock() def read_objects(path: str, keep_points: bool = True) -> dict: @@ -225,13 +227,18 @@ def read_objects(path: str, keep_points: bool = True) -> dict: if keep_points: return _read_objects(path, True) key = geometry.file_identity(path) - if key is not None and key in _READ_CACHE: - return copy.deepcopy(_READ_CACHE[key]) + if key is not None: + with _READ_LOCK: + hit = _READ_CACHE.get(key) + if hit is not None: + return copy.deepcopy(hit) result = _read_objects(path, False) if key is not None and geometry.stat_identity(path) == key[:3]: - while len(_READ_CACHE) >= _READ_SLOTS: - _READ_CACHE.pop(next(iter(_READ_CACHE))) - _READ_CACHE[key] = copy.deepcopy(result) + stored = copy.deepcopy(result) + with _READ_LOCK: + while len(_READ_CACHE) >= _READ_SLOTS: + _READ_CACHE.pop(next(iter(_READ_CACHE)), None) + _READ_CACHE[key] = stored return result diff --git a/backend/tests/test_size_unknown_and_limits.py b/backend/tests/test_size_unknown_and_limits.py index 108d2972..6d197458 100644 --- a/backend/tests/test_size_unknown_and_limits.py +++ b/backend/tests/test_size_unknown_and_limits.py @@ -172,10 +172,12 @@ def test_project_info_keeps_its_old_cost_unless_asked_for_placement_data(tmp_pat def test_a_connected_printers_travel_extents_are_not_a_printable_rectangle(tmp_path, monkeypatch): from snapstudio_core import moonraker # a real U1 reports ~271 x 335 of axis TRAVEL; its printable area is 270 x 270 from about (0.5, 1) - monkeypatch.setattr(moonraker, "capabilities", lambda host, port: {"bed_mm": {"x": 271, "y": 335, "z": 275}}) + monkeypatch.setattr(moonraker, "capabilities", lambda host, port: { + "bed_mm": {"x": 271, "y": 335, "z": 275}, "toolhead_count": 4, + "klipper_objects": ["print_task_config", "extruder"]}) # a Snapmaker: the U1 identifies itself path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(100, 300, 0))], name="travel.3mf") result = service.bed_fit(path, host="u1.invalid") - assert result["bed_known"] is True # the size check still uses the report + assert result["bed_known"] is True and result["bed_mm"]["y"] <= 270 # the profile volume, not the travel assert result["overall_level"] == "risk" assert any(f["text"].startswith("By placement") and f["level"] == "risk" for f in result["findings"]) assert "outside" in texts(result) @@ -225,3 +227,46 @@ def test_placed_instances_number_uses_of_the_same_object(tmp_path): [], [("1", fx.tf(10, 10, 0), "/" + a), ("1", fx.tf(100, 10, 0), "/" + b)]), extra)) instances = pp.placed_instances(path)["instances"] assert [(i["instance_index"], i["instance_count"]) for i in instances] == [(0, 1), (0, 1)] + + +def test_a_printer_that_cannot_be_identified_gets_no_placement_verdict(tmp_path, monkeypatch): + from snapstudio_core import moonraker + monkeypatch.setattr(moonraker, "capabilities", lambda host, port: { + "bed_mm": {"x": 271, "y": 335, "z": 275}, "toolhead_count": 1, "klipper_objects": ["extruder"]}) + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(100, 300, 0))], name="unknown.3mf") + result = service.bed_fit(path, host="x.invalid") + assert result["placement_checked"] is False + assert not any(f["text"].startswith("By placement") for f in result["findings"]) + assert "Where it sits on the plate was not checked here" in result["overall_text"] + + +def test_unjudged_instances_in_a_two_plate_project_are_a_warning_never_a_fit(tmp_path): + objects = {"1": [("1", "normal_part")]} + settings = fx.model_settings_xml(objects, [(1, [("1", None)]), (2, [("1", None)])]) # no instance_id + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(50, 50, 0)), ("1", fx.tf(400, 50, 0))], + settings=settings, name="unjudged.3mf") + result = service.bed_fit(path) + text = texts(result) + assert result["overall_level"] == "warn" + assert "each of the" not in text and "found nothing off the plate" not in text + assert any(f["level"] == "warn" and f["text"].startswith("By placement") for f in result["findings"]) + + +def test_the_caches_survive_many_threads(tmp_path): + import threading + paths = [project(tmp_path, [fx.cube_object("1", 10 + i)], [("1", fx.tf(100, 100, 0))], name=f"t{i}.3mf") + for i in range(8)] + errors = [] + + def work(i): + try: + for p in paths: + geometry.measure(p) + placement.read_objects(p, keep_points=False) + except Exception as exc: # noqa: BLE001 + errors.append(exc) + + threads = [threading.Thread(target=work, args=(i,)) for i in range(8)] + [t.start() for t in threads] + [t.join() for t in threads] + assert errors == [] From 05212c8734c4da7717ba3405940234477a4e74f2 Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Sat, 10 Oct 2026 09:05:32 -0400 Subject: [PATCH 3/9] test(ready-now): an unavailable placement is not cached as done Co-Authored-By: Claude Sonnet 5.5 --- backend/tests/test_ready_now_api.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/backend/tests/test_ready_now_api.py b/backend/tests/test_ready_now_api.py index f941c261..79e5ea57 100644 --- a/backend/tests/test_ready_now_api.py +++ b/backend/tests/test_ready_now_api.py @@ -429,3 +429,24 @@ def boom(*a, **k): assert state1 == state2 == "ok" assert placement1 is None and placement2 is None assert calls["n"] == 2 + + +def test_an_unavailable_placement_is_not_cached_as_done(tmp_path, monkeypatch): + import shutil + from pathlib import Path + from snapstudio_api import service + from snapstudio_core import plate_placement + f = tmp_path / "a.3mf" + shutil.copy(Path(__file__).parent / "fixtures" / "painted" / "orcaslicer-2.4.2-painted-cube.3mf", f) + service._READY_CACHE.clear() + calls = {"n": 0} + + def unavailable(*a, **k): + calls["n"] += 1 + return {"available": False, "reason": "too large to measure", "items": [], "off_plate": []} + + monkeypatch.setattr(plate_placement, "assess", unavailable) + target = {"key": ("test",), "bed": None, "name": None, "height": None} + service._ready_analysis(str(f), target, need_placement=True) + service._ready_analysis(str(f), target, need_placement=True) + assert calls["n"] == 2 # tried again, not remembered as done From a7ff9b0464ed888f0576b2cb671864a7596ec4a0 Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Sat, 10 Oct 2026 11:36:59 -0400 Subject: [PATCH 4/9] fix(bed-fit): size from printing parts only, sunk parts flagged (#91 repair 5) Merges the #93 repair (below-plate parts, torn Ready Now reads, named printer) and then: - An object's size is the size of the parts that print, like its placed footprint: a 300 mm modifier next to a 10 mm cube no longer makes the object too large or too tall by size. - The bed-fit placement findings flag instances that reach below the build plane (a point to check, not a fit). - Stale docstrings and comments describe the printable-area behaviour; the big-printer test also drives service.bed_fit through printer identification. Verified: backend pytest 3255 passed / 49 skipped; desktop tsc, vitest (826 passed) and build clean. Co-Authored-By: Claude Sonnet 5.5 --- backend/snapstudio_api/service.py | 12 ++++---- backend/snapstudio_core/bed_fit.py | 22 ++++++++++---- backend/snapstudio_core/geometry.py | 29 +++++++++++++++---- backend/tests/test_second_printer_failures.py | 9 ++++++ backend/tests/test_size_unknown_and_limits.py | 27 +++++++++++++++++ 5 files changed, 82 insertions(+), 17 deletions(-) diff --git a/backend/snapstudio_api/service.py b/backend/snapstudio_api/service.py index d0184883..6e5ec3b7 100644 --- a/backend/snapstudio_api/service.py +++ b/backend/snapstudio_api/service.py @@ -2048,9 +2048,11 @@ def mm_doctor(path: str, host: str | None = None, port: int = 7125) -> dict: def bed_fit(path: str, host: str | None = None, port: int = 7125) -> dict: - """Bed-Fit / Out-of-Bounds Doctor: does the model fit the U1 bed, and if not, - WHY (the cryptic 'out of bounds' error) and HOW to fix it. Uses the connected - U1's real bed when reachable, else the known U1 bed. Read-only; works offline.""" + """Bed-Fit / Out-of-Bounds Doctor: does each object fit the printer's printable area (by size), + do the placed instances sit on it (by placement), and if not, WHY (the cryptic 'out of bounds' + error) and HOW to fix it. Uses the profile of the connected printer when it identifies itself, + else the known U1 profile; a connected printer Studio cannot identify is reported as unknown. + Read-only; works offline.""" from snapstudio_core.intelligence import project_info from snapstudio_core import bed_fit as bf info = project_info(path, placement_aware=True) @@ -2061,8 +2063,8 @@ def bed_fit(path: str, host: str | None = None, port: int = 7125) -> dict: bed_known = False # Offline: the machine Studio prepares for (its profile polygon). Connected: the printable area of # the printer that identifies itself; a printer's reported bed is axis TRAVEL (about 271 x 335 on a - # U1, anchored at 0,0), never used as a printable rectangle, so a printer Studio cannot identify - # gets a size check against what it reported and NO placement check. + # U1, anchored at 0,0), never used as a printable rectangle or size, so a printer Studio cannot + # identify gets neither a size nor a placement verdict. target = _placement_target({"identity": {"printer_id": printer_profiles_target_id()}}) profile = None if host: diff --git a/backend/snapstudio_core/bed_fit.py b/backend/snapstudio_core/bed_fit.py index a8abbcfb..175accec 100644 --- a/backend/snapstudio_core/bed_fit.py +++ b/backend/snapstudio_core/bed_fit.py @@ -16,11 +16,12 @@ A size says nothing about a position, and a combined extent across several separated objects or plates is neither: it is never used to say "too big" or to suggest scaling. -Read-only and offline-capable: it uses the connected printer's REAL bed when -known, else the printable volume recorded in the profile of the machine the file -is being prepared for. Honest: it explains only what the geometry proves, returns -unavailable when there are no dimensions, and says which bed it measured against -so a profile figure never reads as a measurement. +Read-only and offline-capable: it uses the printable volume recorded in the profile of the +printer (the connected one when it identifies itself, else the machine the file is being +prepared for). A printer's reported axis travel is not a printable size and is never used as +one. Honest: it explains only what the geometry proves, returns unavailable when there are no +measurable dimensions or the printer's printable area is unknown, and says which bed it +measured against so a profile figure never reads as a measurement. """ from __future__ import annotations @@ -199,10 +200,19 @@ def _placed_findings(placed: dict | None, findings: list, fixes: list) -> str | which of them could not be judged at all.""" if not placed or not placed.get("available"): return None - from .plate_placement import over_height + from .plate_placement import below_plate, over_height tall = over_height(placed) + sunk = below_plate(placed) level = _placed_sideways(placed, findings, fixes) + if sunk: + low = min(row["bounds_mm"]["min"][2] for row in sunk) + n = len(sunk) + findings.append(_f("warn", f"By placement, {n} placed instance{'s' if n != 1 else ''} reach down to " + f"{low:g} mm, below the build plate; Studio cannot confirm " + f"{'they print' if n != 1 else 'it prints'} as modelled.")) + fixes.append("Check the height of the sunk instance in Snapmaker Orca.") + level = level or "warn" if tall: top = max(row["bounds_mm"]["max"][2] for row in tall) n = len(tall) diff --git a/backend/snapstudio_core/geometry.py b/backend/snapstudio_core/geometry.py index f8f870df..06013f8e 100644 --- a/backend/snapstudio_core/geometry.py +++ b/backend/snapstudio_core/geometry.py @@ -273,7 +273,7 @@ def find_obj(objid, part, explicit): budget = [0] placed_budget = [0] - def collect(objid, part, explicit, xform, acc, seen, bad): + def collect(objid, part, explicit, xform, acc, seen, bad, printing=None, roles=None): f = find_obj(objid, part, explicit) if f is None: bad.append(f"object {objid} is not in {part}") @@ -283,19 +283,33 @@ def collect(objid, part, explicit, xform, acc, seen, bad): return seen = seen | {key} verts, comps = parsed[f][objid] + # a mesh whose volume role is a modifier, negative volume or support blocker is an + # instruction to the slicer, not something that prints: it is in `acc` but not `printing` + prints = roles is None or roles.get(objid, "normal_part") == "normal_part" for v in verts: - acc.append(_apply(v, xform)) + moved = _apply(v, xform) + acc.append(moved) + if printing is not None and prints: + printing.append(moved) budget[0] += 1 if budget[0] > _MAX_VERTS: raise _TooLarge() for cid, cpart, ctf in comps: - collect(cid, cpart or f, cpart is not None, _compose(xform, ctf), acc, seen, bad) + collect(cid, cpart or f, cpart is not None, _compose(xform, ctf), acc, seen, bad, + printing, roles) def box_of(points): xs = [p[0] for p in points]; ys = [p[1] for p in points]; zs = [p[2] for p in points] return (min(xs), min(ys), min(zs)), (max(xs), max(ys), max(zs)) items = build_items(model_files[root_file]) + try: + from . import multipart + + settings_text = tm.read_part("Metadata/model_settings.config").decode("utf-8", "ignore") if tm.has_part("Metadata/model_settings.config") else "" + parts_by_object = multipart._parts_by_object(settings_text) if settings_text else {} + except Exception: + parts_by_object = {} uses: dict[tuple, int] = {} for it in items: part = it["part"] or root_file @@ -312,11 +326,14 @@ def box_of(points): key = (part, oid) if key not in collected: points: list = [] + printing: list = [] bad: list = [] - collect(oid, part, it["part"] is not None, None, points, frozenset(), bad) + collect(oid, part, it["part"] is not None, None, points, frozenset(), bad, + printing, dict(parts_by_object.get(oid, []))) collected[key] = (points, bad) - if points and not bad: - lo, hi = box_of(points) + # the SIZE of an object is the size of what prints, like its placed footprint + if printing and not bad: + lo, hi = box_of(printing) sizes[key] = {"object_id": oid, "part": part, "instance_count": uses[key], "dimensions": {"x": round(hi[0] - lo[0], 2), "y": round(hi[1] - lo[1], 2), diff --git a/backend/tests/test_second_printer_failures.py b/backend/tests/test_second_printer_failures.py index 1d7a0cee..986a6294 100644 --- a/backend/tests/test_second_printer_failures.py +++ b/backend/tests/test_second_printer_failures.py @@ -70,6 +70,15 @@ def test_a_much_bigger_printable_area_is_used_when_the_printer_is_identified(tmp out = pf.evaluate(project, facts(bed_mm={"x": 1000.0, "y": 1000.0, "z": 1000.0}), placement=placement) bed = next(c for c in out["checks"] if c["id"] == "bed.fit") assert bed["result"] == pf.OK and "1000 × 1000 mm printable area" in bed["evidence"] + # and through the Bed-Fit API: identified by what it reports, judged against its recorded volume + from snapstudio_core import moonraker + monkeypatch.setattr(moonraker, "capabilities", lambda host, port: { + "bed_mm": {"x": 271.0, "y": 335.0, "z": 275.0}, "klipper_objects": [], "toolhead_count": 1}) + monkeypatch.setattr(printer_profiles, "identify", lambda facts, profiles=None: {"printer_id": "bigmachine"}) + result = service.bed_fit(path, host="big.invalid") + assert result["overall_level"] == "ok" and result["bed_mm"]["x"] == 1000.0 + assert result["bed_mm_source"] == "profile" and result["placement_checked"] is True + assert "bigmachine" not in result["bed_source"] def test_a_zero_tool_printer_does_not_divide_by_it(): diff --git a/backend/tests/test_size_unknown_and_limits.py b/backend/tests/test_size_unknown_and_limits.py index 06d2a9fd..298dc05c 100644 --- a/backend/tests/test_size_unknown_and_limits.py +++ b/backend/tests/test_size_unknown_and_limits.py @@ -291,3 +291,30 @@ def test_a_tall_modifier_does_not_make_the_bed_fit_check_report_over_height(tmp_ settings = fx.model_settings_xml({"100": [("1", "normal_part"), ("2", "modifier_part")]}, [(1, [("100", 0)])]) path = str(fx.three_mf(tmp_path / "mod.3mf", root, {"3D/Objects/o.model": sub, SETTINGS: settings})) assert not any("stand up to" in f["text"] for f in service.bed_fit(path)["findings"]) + + +def test_the_size_check_counts_only_the_parts_that_print(tmp_path): + # a 10 mm printable cube and a 300 mm modifier volume in the same object + sub = fx.sub_model_xml([fx.cube_object("1", 10), fx.cube_object("2", 300, origin=(40, 0, 0))]) + root = fx.model_xml([fx.composite_object("100", [("1", "/3D/Objects/o.model", None), ("2", "/3D/Objects/o.model", None)])], + [("100", fx.tf(20, 20, 0))]) + settings = fx.model_settings_xml({"100": [("1", "normal_part"), ("2", "modifier_part")]}, [(1, [("100", 0)])]) + path = str(fx.three_mf(tmp_path / "mod.3mf", root, {"3D/Objects/o.model": sub, SETTINGS: settings})) + [size] = geometry.object_sizes(path) + assert size["dimensions"] == {"x": 10.0, "y": 10.0, "z": 10.0} + result = service.bed_fit(path) + assert result["overall_level"] == "ok", texts(result) + assert result["dims_mm"] == {"x": 10.0, "y": 10.0, "z": 10.0} + report = next(c for c in service.report(path)["checks"] if c["name"] == "Fits the print bed") + assert report["status"] == "pass" and "10.0 × 10.0 × 10.0 mm" in report["detail"] + # a tall PRINTABLE part is still too tall + settings2 = fx.model_settings_xml({"100": [("1", "normal_part"), ("2", "normal_part")]}, [(1, [("100", 0)])]) + path2 = str(fx.three_mf(tmp_path / "tall.3mf", root, {"3D/Objects/o.model": sub, SETTINGS: settings2})) + assert service.bed_fit(path2)["overall_level"] == "risk" + + +def test_a_part_sunk_below_the_plane_is_a_point_to_check_in_the_bed_fit(tmp_path): + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", "1 0 0 0 1 0 0 0 30 100 100 -100")], name="sunk.3mf") + result = service.bed_fit(path) + assert result["overall_level"] != "ok" + assert any(f["text"].startswith("By placement") and "below the build plate" in f["text"] for f in result["findings"]) From eca807bd1cf49ae30a63dcaaf463350f623ca88c Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Sat, 10 Oct 2026 12:39:11 -0400 Subject: [PATCH 5/9] fix(bed-fit): sizes use the shared role rule (#91 repair 6) Merges the #93 repair (roles propagate down through assemblies) and makes an object's size use the same rule through geometry: a non- printing role on an assembly applies to every descendant, roles on nested objects are consulted at every level, missing means a normal part, conflicting or unrecognised roles count. Tests go through service.bed_fit, the validation report and the per-object sizes for each of the four helper roles on a nested assembly (role on the assembly and on the nested record), a missing role, conflicting records and a child beneath a modifier. The big-printer test also asserts the preflight source names the printer from the derived placement. Verified: backend pytest 3281 passed / 49 skipped; desktop tsc, vitest (826 passed) and build clean. Co-Authored-By: Claude Sonnet 5.5 --- backend/snapstudio_core/geometry.py | 25 +++++---- backend/tests/test_role_inheritance_size.py | 51 +++++++++++++++++++ backend/tests/test_second_printer_failures.py | 4 +- 3 files changed, 69 insertions(+), 11 deletions(-) create mode 100644 backend/tests/test_role_inheritance_size.py diff --git a/backend/snapstudio_core/geometry.py b/backend/snapstudio_core/geometry.py index 06013f8e..12fac059 100644 --- a/backend/snapstudio_core/geometry.py +++ b/backend/snapstudio_core/geometry.py @@ -15,6 +15,7 @@ from pathlib import Path from .container import ThreeMF +from . import roles as _roles from .fileid import file_identity, stat_identity # noqa: F401 (the one content key; see fileid) from .config_io import load_model_settings from . import units as _units @@ -273,7 +274,7 @@ def find_obj(objid, part, explicit): budget = [0] placed_budget = [0] - def collect(objid, part, explicit, xform, acc, seen, bad, printing=None, roles=None): + def collect(objid, part, explicit, xform, acc, seen, bad, printing=None, role=None, records=None): f = find_obj(objid, part, explicit) if f is None: bad.append(f"object {objid} is not in {part}") @@ -283,9 +284,10 @@ def collect(objid, part, explicit, xform, acc, seen, bad, printing=None, roles=N return seen = seen | {key} verts, comps = parsed[f][objid] - # a mesh whose volume role is a modifier, negative volume or support blocker is an - # instruction to the slicer, not something that prints: it is in `acc` but not `printing` - prints = roles is None or roles.get(objid, "normal_part") == "normal_part" + # a volume whose role (its own, or inherited from an assembly above it) is a modifier, negative + # volume or support helper is an instruction to the slicer, not something that prints: it is in + # `acc` but not `printing`. One rule for this everywhere: see ``roles``. + prints = _roles.counts_toward_size(role) for v in verts: moved = _apply(v, xform) acc.append(moved) @@ -295,8 +297,13 @@ def collect(objid, part, explicit, xform, acc, seen, bad, printing=None, roles=N if budget[0] > _MAX_VERTS: raise _TooLarge() for cid, cpart, ctf in comps: + child_role = role + if records is not None and f == root_file: + # part records are matched against root-model objects; a missing record is a normal part + child_role = _roles.inherit(role, _roles.own_role( + records.part_subtypes, records.metadata_objects, objid, cid, missing=None)) collect(cid, cpart or f, cpart is not None, _compose(xform, ctf), acc, seen, bad, - printing, roles) + printing, child_role, records) def box_of(points): xs = [p[0] for p in points]; ys = [p[1] for p in points]; zs = [p[2] for p in points] @@ -304,12 +311,10 @@ def box_of(points): items = build_items(model_files[root_file]) try: - from . import multipart - settings_text = tm.read_part("Metadata/model_settings.config").decode("utf-8", "ignore") if tm.has_part("Metadata/model_settings.config") else "" - parts_by_object = multipart._parts_by_object(settings_text) if settings_text else {} + records = _roles.read_records(settings_text) except Exception: - parts_by_object = {} + records = _roles.Records() uses: dict[tuple, int] = {} for it in items: part = it["part"] or root_file @@ -329,7 +334,7 @@ def box_of(points): printing: list = [] bad: list = [] collect(oid, part, it["part"] is not None, None, points, frozenset(), bad, - printing, dict(parts_by_object.get(oid, []))) + printing, None, records) collected[key] = (points, bad) # the SIZE of an object is the size of what prints, like its placed footprint if printing and not bad: diff --git a/backend/tests/test_role_inheritance_size.py b/backend/tests/test_role_inheritance_size.py new file mode 100644 index 00000000..4b31a778 --- /dev/null +++ b/backend/tests/test_role_inheritance_size.py @@ -0,0 +1,51 @@ +"""Printing roles propagate down through assemblies, seen through the size entry points: the Bed-Fit API, +the validation report and Design Health's per-object sizes (same fixtures and rule as the placement tests).""" +from __future__ import annotations + +import pytest + +from snapstudio_api import service +from snapstudio_core import geometry +from snapstudio_core.intelligence import project_info +from tests.test_role_inheritance_placement import (HELPER_ROLES, nested_assembly, on_assembly, + on_nested_record) + + +def fits_check(path): + return next(c for c in service.report(path)["checks"] if c["name"] == "Fits the print bed") + + +@pytest.mark.parametrize("where", [on_assembly, on_nested_record], ids=["on_assembly", "on_nested_record"]) +@pytest.mark.parametrize("role", HELPER_ROLES) +def test_a_helper_role_on_an_assembly_keeps_the_size_to_what_prints(tmp_path, role, where): + path = nested_assembly(tmp_path, where(role)) + [size] = geometry.object_sizes(path) + assert size["dimensions"]["z"] == 10.0 + info = project_info(path, placement_aware=True) + assert info["object_sizes_mm"][0]["dimensions_mm"]["z"] == 10.0 + result = service.bed_fit(path) + assert result["overall_level"] == "ok", result["overall_text"] + assert result["dims_mm"]["z"] == 10.0 + check = fits_check(path) + assert check["status"] == "pass" and "10.0 mm" in check["detail"] + + +def test_a_missing_role_is_a_normal_part_and_makes_the_object_too_tall(tmp_path): + path = nested_assembly(tmp_path, {"100": [("1", "normal_part")]}) + assert geometry.object_sizes(path)[0]["dimensions"]["z"] == 300.0 + result = service.bed_fit(path) + assert result["overall_level"] == "risk" + assert fits_check(path)["status"] == "warn" + + +def test_conflicting_records_count_toward_the_size(tmp_path): + path = nested_assembly(tmp_path, {"100": [("1", "normal_part"), ("5", "modifier_part"), ("5", "normal_part")]}) + assert geometry.object_sizes(path)[0]["dimensions"]["z"] == 300.0 # unknown cannot be proven away + assert service.bed_fit(path)["overall_level"] == "risk" + + +def test_a_child_cannot_print_again_beneath_a_non_printing_assembly(tmp_path): + records = {"100": [("1", "normal_part"), ("5", "modifier_part")], "5": [("3", "normal_part")]} + path = nested_assembly(tmp_path, records) + assert geometry.object_sizes(path)[0]["dimensions"]["z"] == 10.0 + assert service.bed_fit(path)["overall_level"] == "ok" diff --git a/backend/tests/test_second_printer_failures.py b/backend/tests/test_second_printer_failures.py index 986a6294..a0406593 100644 --- a/backend/tests/test_second_printer_failures.py +++ b/backend/tests/test_second_printer_failures.py @@ -57,7 +57,7 @@ def test_a_printer_reporting_fewer_tools_than_the_profile_is_believed(): def test_a_much_bigger_printable_area_is_used_when_the_printer_is_identified(tmp_path, monkeypatch): from snapstudio_api import service from tests import scene_fixtures as fx - big_profile = {"printer_id": "bigmachine", "build_volume_mm": {"x": 1000.0, "y": 1000.0, "z": 1000.0}} + big_profile = {"printer_id": "bigmachine", "display_name": "Big Machine", "build_volume_mm": {"x": 1000.0, "y": 1000.0, "z": 1000.0}} real_load = printer_profiles.load monkeypatch.setattr(printer_profiles, "load", lambda pid: big_profile if pid == "bigmachine" else real_load(pid)) @@ -70,6 +70,8 @@ def test_a_much_bigger_printable_area_is_used_when_the_printer_is_identified(tmp out = pf.evaluate(project, facts(bed_mm={"x": 1000.0, "y": 1000.0, "z": 1000.0}), placement=placement) bed = next(c for c in out["checks"] if c["id"] == "bed.fit") assert bed["result"] == pf.OK and "1000 × 1000 mm printable area" in bed["evidence"] + # the printer's name comes from the producer path (target -> assess), not from a hand-built bed_name + assert "Big Machine" in bed["source"] and "U1" not in bed["source"] # and through the Bed-Fit API: identified by what it reports, judged against its recorded volume from snapstudio_core import moonraker monkeypatch.setattr(moonraker, "capabilities", lambda host, port: { From 66471f4dcd8bd754233232e25bc447ff4e93a27b Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Wed, 21 Oct 2026 20:23:43 -0400 Subject: [PATCH 6/9] fix(bed-fit): per-object sizes under main's geometry guard; tests follow the guard Removes the last unit conversion from the size path, gates the Design Health bed-fit row on geometry_basis, and updates tests that pinned unit conversion or a Bed-Fit verdict for scaled items (main withholds those as not checked). Co-Authored-By: Claude Sonnet 5.5 --- backend/snapstudio_core/geometry.py | 2 +- backend/tests/test_geometry_verdict.py | 6 ++++- backend/tests/test_size_unknown_and_limits.py | 24 +++++++++---------- backend/tests/test_size_vs_placed_bounds.py | 7 ------ 4 files changed, 17 insertions(+), 22 deletions(-) diff --git a/backend/snapstudio_core/geometry.py b/backend/snapstudio_core/geometry.py index 692ac5a2..b049fa20 100644 --- a/backend/snapstudio_core/geometry.py +++ b/backend/snapstudio_core/geometry.py @@ -406,7 +406,7 @@ def box_of(points): continue if not points: continue - xform = _units.scale_translation(_xform(it["transform"]), unit_scale[root_file]) + xform = _xform(it["transform"]) # the placed bounds are those of what PRINTS; an object made only of helper volumes has nothing # that prints, and keeps its full extent rather than vanishing from the checks measured = printing or points diff --git a/backend/tests/test_geometry_verdict.py b/backend/tests/test_geometry_verdict.py index 14136761..ea8a851a 100644 --- a/backend/tests/test_geometry_verdict.py +++ b/backend/tests/test_geometry_verdict.py @@ -110,7 +110,11 @@ def _surfaces(path): def test_established_cases_keep_every_verdict(tmp_path, label, tf): s = _surfaces(make(tmp_path, "ok", items=(("1", tf),))) assert s["insights"]["geometry_basis"]["established"] is True, label - assert s["bed_fit"]["available"] is True and s["bed_fit"]["overall_level"] == "ok" + # Bed fit judges size and, separately, where the items sit: a mesh authored at the plate corner (no/identity + # transform) or mirrored off it is a placement concern, not a change of the size verdict. + assert s["bed_fit"]["available"] is True and s["bed_fit"]["overall_text"].startswith("By size") + if label == "translation only": + assert s["bed_fit"]["overall_level"] == "ok" fit = next(c for c in s["readiness"]["checks"] if c["name"] == "Fits the print bed") assert fit["status"] == "pass" assert isinstance(s["predict"]["likelihood"], int) and s["predict"]["band"] in ("likely", "uncertain", "risky") diff --git a/backend/tests/test_size_unknown_and_limits.py b/backend/tests/test_size_unknown_and_limits.py index 298dc05c..2e62f906 100644 --- a/backend/tests/test_size_unknown_and_limits.py +++ b/backend/tests/test_size_unknown_and_limits.py @@ -15,7 +15,7 @@ def _renamed_root(tmp_path): """Two small meshes authored 500 mm apart, in a project whose root model is NOT 3D/3dmodel.model.""" root = fx.model_xml([box_object("1", 20, 20, 20), box_object("2", 20, 20, 20, origin=(500, 0, 0))], [("1", fx.tf(50, 50, 0)), ("2", fx.tf(-400, 50, 0))]) - entries = {"_rels/.rels": fx.rels_xml(target="/3D/main.model"), "3D/main.model": root} + entries = {"[Content_Types].xml": fx.CONTENT_TYPES_XML, "_rels/.rels": fx.rels_xml(target="/3D/main.model"), "3D/main.model": root} return str(fx.write_zip(tmp_path / "renamed.3mf", entries)) @@ -27,10 +27,10 @@ def test_unmeasured_objects_do_not_fall_back_to_the_combined_extents(tmp_path): assert info["object_sizes_mm"] == [] assert info["dimensions_mm"]["x"] == 520.0 # the legacy figure still exists ... result = service.bed_fit(path) # ... and is not used for fit - assert result["available"] is False and result["reason"] == bed_fit.UNMEASURED_TEXT - assert "scale" not in result["reason"].lower() and "too big" not in result["reason"].lower() + assert result["available"] is False # unmeasurable per object, and main's guard also withholds a root it cannot read + assert "scale" not in str(result.get("reason", "")).lower() and "too big" not in result["reason"].lower() check = next(c for c in service.report(path)["checks"] if c["name"] == "Fits the print bed") - assert check["status"] == "warn" and check["detail"] == bed_fit.UNMEASURED_TEXT + assert check["status"] in ("warn", "not_checked") def test_assess_objects_with_nothing_measured_is_unavailable_not_a_guess(): @@ -106,9 +106,11 @@ def test_object_size_ignores_scale_and_turn_on_the_first_item(tmp_path): def test_a_scaled_instance_beyond_the_bed_is_caught_by_placement_not_size(tmp_path): path = project(tmp_path, [box_object("1", 100, 100, 10)], [("1", "3 0 0 0 3 0 0 0 1 10 10 0")]) result = service.bed_fit(path) - assert result["overall_level"] == "risk" - assert result["overall_text"].startswith("By size, this model is small enough") - assert any(f["text"].startswith("By placement") for f in result["findings"] if f["level"] == "risk") + # RC2 guard: a scaled item's size is not established, so the Bed-Fit API withholds its verdict. The placed + # footprint (3x) is still measured: the item reaches 310 mm on a 270 mm plate. + assert result["available"] is False and result["status"] == "not_checked" + placed = geometry.build_item_dims(path) + assert placed[0]["dimensions"]["x"] == 300.0 # --- measure once ------------------------------------------------------------------------------------------- @@ -278,10 +280,7 @@ def work(i): def test_a_cube_scaled_to_300_mm_tall_is_over_height_by_placement(tmp_path): path = project(tmp_path, [fx.cube_object("1", 10)], [("1", "1 0 0 0 1 0 0 0 30 100 100 0")], name="zscale.3mf") result = service.bed_fit(path) - assert result["overall_level"] == "risk" - assert any(f["text"].startswith("By placement") and "stand up to 300 mm" in f["text"] and f["level"] == "risk" - for f in result["findings"]) - assert any("tall instance" in fix for fix in result["fixes"]) + assert result["available"] is False and result["status"] == "not_checked" # RC2 guard: a scaled item's size is not established def test_a_tall_modifier_does_not_make_the_bed_fit_check_report_over_height(tmp_path): @@ -316,5 +315,4 @@ def test_the_size_check_counts_only_the_parts_that_print(tmp_path): def test_a_part_sunk_below_the_plane_is_a_point_to_check_in_the_bed_fit(tmp_path): path = project(tmp_path, [fx.cube_object("1", 10)], [("1", "1 0 0 0 1 0 0 0 30 100 100 -100")], name="sunk.3mf") result = service.bed_fit(path) - assert result["overall_level"] != "ok" - assert any(f["text"].startswith("By placement") and "below the build plate" in f["text"] for f in result["findings"]) + assert result["available"] is False and result["status"] == "not_checked" # RC2 guard: a scaled item's size is not established diff --git a/backend/tests/test_size_vs_placed_bounds.py b/backend/tests/test_size_vs_placed_bounds.py index f9f4b9cf..cf6dadf5 100644 --- a/backend/tests/test_size_vs_placed_bounds.py +++ b/backend/tests/test_size_vs_placed_bounds.py @@ -45,13 +45,6 @@ def test_object_size_ignores_where_the_item_puts_the_object(tmp_path): assert placed[1]["dimensions"] == {"x": 20.0, "y": 100.0, "z": 10.0} # placed bounds follow the turn -def test_object_size_is_in_millimetres_for_an_inch_project(tmp_path): - path = project(tmp_path, [box_object("1", 12, 1, 1)], [("1", fx.tf(0, 0, 0))], unit="inch") - assert geometry.object_sizes(path)[0]["dimensions"] == {"x": 304.8, "y": 25.4, "z": 25.4} - info = project_info(path, placement_aware=True) - assert info["object_sizes_mm"][0]["dimensions_mm"]["x"] == 304.8 - - def test_project_info_keeps_size_and_placed_apart(tmp_path): path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(500, 100, 0)), ("1", fx.tf(100, 100, 0))]) info = project_info(path, placement_aware=True) From 64ef1bda151a2146f133e9812ffb5e3dbffcd234 Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Wed, 21 Oct 2026 20:24:47 -0400 Subject: [PATCH 7/9] test(desktop): Design Health size rows get per-object sizes Co-Authored-By: Claude Sonnet 5.5 --- desktop/src/components/SizeNotChecked.test.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/desktop/src/components/SizeNotChecked.test.tsx b/desktop/src/components/SizeNotChecked.test.tsx index fa807468..eae0f305 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(); @@ -76,19 +79,19 @@ describe("Design Health does not judge the bed or vouch for a raw size", () => { }); 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(); + render(); expect(document.body.textContent).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/); }); }); From 3a7c0ec15157cb74b8229226bfba34d5e767ff11 Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Wed, 21 Oct 2026 21:57:03 -0400 Subject: [PATCH 8/9] fix(desktop): Design Health never judges a size whose basis is not established An unestablished size with no overall dimensions fell through to the per-object size verdict and could say Fits. Every unestablished path is now Not checked. Adds the null-dims regression test, renames three RC2 tests to say what they pin, drops a dead test parameter and a duplicate import, and corrects the object_sizes_mm comment. Verified: vitest SizeNotChecked + lib (646 pass), tsc clean, 76 backend tests pass. Co-Authored-By: Claude Sonnet 5.5 --- backend/snapstudio_core/geometry.py | 1 - backend/snapstudio_core/intelligence.py | 2 +- backend/tests/test_size_unknown_and_limits.py | 6 +++--- backend/tests/test_size_vs_placed_bounds.py | 5 ++--- desktop/src/components/DesignHealth.tsx | 6 ++++-- desktop/src/components/SizeNotChecked.test.tsx | 16 +++++++++++++++- 6 files changed, 25 insertions(+), 11 deletions(-) diff --git a/backend/snapstudio_core/geometry.py b/backend/snapstudio_core/geometry.py index b049fa20..d635c9f0 100644 --- a/backend/snapstudio_core/geometry.py +++ b/backend/snapstudio_core/geometry.py @@ -17,7 +17,6 @@ from . import roles as _roles from .container import ThreeMF -from . import roles as _roles from .fileid import file_identity, stat_identity # noqa: F401 (the one content key; see fileid) from .config_io import load_model_settings diff --git a/backend/snapstudio_core/intelligence.py b/backend/snapstudio_core/intelligence.py index b0b89f07..57de4610 100644 --- a/backend/snapstudio_core/intelligence.py +++ b/backend/snapstudio_core/intelligence.py @@ -134,7 +134,7 @@ def project_info(path: str, placement_aware: bool = False) -> dict: pass # geometry/materials are best-effort; diagnosis still stands # SIZE and PLACEMENT are different facts and are kept apart here. - # object_sizes_mm how big each object is (per object, millimetres, before any build item + # object_sizes_mm how big each object is (per object, in file units; millimetres only when geometry_basis is established, before any build item # moves, turns or scales it). Says nothing about where anything sits. # placed where each instance sits and on which plate (placed bounds). # dimensions_mm kept for existing readers: the overall extents of all the mesh data diff --git a/backend/tests/test_size_unknown_and_limits.py b/backend/tests/test_size_unknown_and_limits.py index 2e62f906..d5894023 100644 --- a/backend/tests/test_size_unknown_and_limits.py +++ b/backend/tests/test_size_unknown_and_limits.py @@ -103,7 +103,7 @@ def test_object_size_ignores_scale_and_turn_on_the_first_item(tmp_path): assert placed[0]["dimensions"] == {"x": 20.0, "y": 20.0, "z": 20.0} -def test_a_scaled_instance_beyond_the_bed_is_caught_by_placement_not_size(tmp_path): +def test_a_scaled_instance_beyond_the_bed_is_not_checked_not_judged(tmp_path): path = project(tmp_path, [box_object("1", 100, 100, 10)], [("1", "3 0 0 0 3 0 0 0 1 10 10 0")]) result = service.bed_fit(path) # RC2 guard: a scaled item's size is not established, so the Bed-Fit API withholds its verdict. The placed @@ -277,7 +277,7 @@ def work(i): assert errors == [] -def test_a_cube_scaled_to_300_mm_tall_is_over_height_by_placement(tmp_path): +def test_a_cube_scaled_to_300_mm_tall_is_not_checked(tmp_path): path = project(tmp_path, [fx.cube_object("1", 10)], [("1", "1 0 0 0 1 0 0 0 30 100 100 0")], name="zscale.3mf") result = service.bed_fit(path) assert result["available"] is False and result["status"] == "not_checked" # RC2 guard: a scaled item's size is not established @@ -312,7 +312,7 @@ def test_the_size_check_counts_only_the_parts_that_print(tmp_path): assert service.bed_fit(path2)["overall_level"] == "risk" -def test_a_part_sunk_below_the_plane_is_a_point_to_check_in_the_bed_fit(tmp_path): +def test_a_scaled_part_sunk_below_the_plane_is_not_checked(tmp_path): path = project(tmp_path, [fx.cube_object("1", 10)], [("1", "1 0 0 0 1 0 0 0 30 100 100 -100")], name="sunk.3mf") result = service.bed_fit(path) assert result["available"] is False and result["status"] == "not_checked" # RC2 guard: a scaled item's size is not established diff --git a/backend/tests/test_size_vs_placed_bounds.py b/backend/tests/test_size_vs_placed_bounds.py index cf6dadf5..065c1b19 100644 --- a/backend/tests/test_size_vs_placed_bounds.py +++ b/backend/tests/test_size_vs_placed_bounds.py @@ -191,6 +191,5 @@ def test_dimensions_mm_is_unchanged_for_a_single_object(tmp_path): assert project_info(path, placement_aware=True)["dimensions_mm"] == {"x": 30.0, "y": 30.0, "z": 30.0} -@pytest.mark.parametrize("dims", [None, {"x": None, "y": 1, "z": 1}]) -def test_unmeasurable_size_stays_unavailable(dims): - assert bed_fit.assess_objects([], fallback_dims=dims)["available"] is False +def test_unmeasurable_size_stays_unavailable(): + assert bed_fit.assess_objects([])["available"] is False diff --git a/desktop/src/components/DesignHealth.tsx b/desktop/src/components/DesignHealth.tsx index 215708b6..609d7bd7 100644 --- a/desktop/src/components/DesignHealth.tsx +++ b/desktop/src/components/DesignHealth.tsx @@ -76,9 +76,11 @@ function buildRows(mesh: MeshReport, dims?: { x: number; y: number; z: number } // 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 (dims && unsure) { + 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.` }); + 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) { diff --git a/desktop/src/components/SizeNotChecked.test.tsx b/desktop/src/components/SizeNotChecked.test.tsx index eae0f305..181699c4 100644 --- a/desktop/src/components/SizeNotChecked.test.tsx +++ b/desktop/src/components/SizeNotChecked.test.tsx @@ -78,6 +78,14 @@ 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(); const text = document.body.textContent ?? ""; @@ -85,11 +93,17 @@ describe("Design Health does not judge the bed or vouch for a raw size", () => { expect(text).not.toMatch(/Not checked|not a confirmed physical size/); }); - it("control: a mesh without a basis (older engine) behaves as before", () => { + 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(); expect(document.body.textContent).toMatch(/Too big/); From 1c4241ef9da5952e9814a0cea9b6b52b1269b88c Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Thu, 22 Oct 2026 07:29:15 -0400 Subject: [PATCH 9/9] fix(report): placement findings carry condition ids and own actions; unavailable bed fit is not verified Placement findings from bed_fit had no condition id, so the Intelligence Report treated them as unmapped and the legacy keyword fallback attached the out-of-bounds community fix and a generic next action. They now carry stable placement-* ids (no community entry) and their own action. A bed-fit result that could not be produced (unidentified printer, nothing measurable) is listed under not_verified instead of reading as no risk. Verified: backend pytest 3718 passed, 49 skipped, 0 failed on this tree; two new regression tests in test_intelligence_report.py. Co-Authored-By: Claude Sonnet 5.5 --- backend/snapstudio_core/bed_fit.py | 29 +++++++++++++------ backend/snapstudio_core/conditions.py | 9 ++++++ .../snapstudio_core/intelligence_report.py | 4 +++ backend/tests/test_intelligence_report.py | 26 +++++++++++++++++ 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/backend/snapstudio_core/bed_fit.py b/backend/snapstudio_core/bed_fit.py index c3b07606..d7cb1ad2 100644 --- a/backend/snapstudio_core/bed_fit.py +++ b/backend/snapstudio_core/bed_fit.py @@ -220,14 +220,18 @@ def _placed_findings(placed: dict | None, findings: list, fixes: list) -> str | n = len(sunk) findings.append(_f("warn", f"By placement, {n} placed instance{'s' if n != 1 else ''} reach down to " f"{low:g} mm, below the build plate; Studio cannot confirm " - f"{'they print' if n != 1 else 'it prints'} as modelled.")) + f"{'they print' if n != 1 else 'it prints'} as modelled.", + id="placement-below-plate", + action="Check the height of the sunk instance in Snapmaker Orca.")) fixes.append("Check the height of the sunk instance in Snapmaker Orca.") level = level or "warn" if tall: top = max(row["bounds_mm"]["max"][2] for row in tall) n = len(tall) findings.append(_f("risk", f"By placement, {n} placed instance{'s' if n != 1 else ''} stand up to " - f"{top:.0f} mm, above the {placed['bed_height_mm']:.0f} mm height limit.")) + f"{top:.0f} mm, above the {placed['bed_height_mm']:.0f} mm height limit.", + id="placement-over-height", + action="Scale the tall instance down or split it in Snapmaker Orca.")) fixes.append("Scale the tall instance down or split it in Snapmaker Orca.") return "risk" return level @@ -242,25 +246,32 @@ def _placed_sideways(placed: dict, findings: list, fixes: list) -> str | None: if oversized: for plate in oversized: findings.append(_f("risk", f"By placement, plate {plate['plate']}: {plate['reason']}. " - "Scale it down or split it.")) + "Scale it down or split it.", + id="placement-plate-oversized", + action="Scale the oversized plate down or split it, then use Arrange in Snapmaker Orca.")) fixes.append("Scale the oversized plate down or split it, then use Arrange in Snapmaker Orca.") return "risk" if placed.get("not_judged"): - findings.append(_f("warn", f"By placement: {placed.get('summary')}")) + findings.append(_f("warn", f"By placement: {placed.get('summary')}", + id="placement-not-judged", + action="Check these instances in Snapmaker Orca; Studio could not judge their placement.")) return "warn" findings.append(_f("ok", f"By placement, each of the {plate_count} plates fits on its own. " "Positions across plates are not checked; use Arrange in Snapmaker Orca.")) return None if not placed.get("off_plate"): if placed.get("not_judged"): - findings.append(_f("warn", f"By placement: {placed.get('summary')}")) + findings.append(_f("warn", f"By placement: {placed.get('summary')}", + id="placement-not-judged", + action="Check these instances in Snapmaker Orca; Studio could not judge their placement.")) return "warn" findings.append(_f("ok", "By placement, every placed instance sits inside the plate.")) return None - findings.append(_f("risk", f"By placement: {placed.get('summary')}")) - fixes.append("Move the arrangement onto the plate (Studio can save a corrected copy), " - "or use Arrange in Snapmaker Orca." if placed.get("fixable") else - "Use Arrange in Snapmaker Orca to bring every instance onto the plate.") + move = ("Move the arrangement onto the plate (Studio can save a corrected copy), " + "or use Arrange in Snapmaker Orca." if placed.get("fixable") else + "Use Arrange in Snapmaker Orca to bring every instance onto the plate.") + findings.append(_f("risk", f"By placement: {placed.get('summary')}", id="placement-off-plate", action=move)) + fixes.append(move) return "risk" diff --git a/backend/snapstudio_core/conditions.py b/backend/snapstudio_core/conditions.py index 5cb17500..72da3298 100644 --- a/backend/snapstudio_core/conditions.py +++ b/backend/snapstudio_core/conditions.py @@ -25,6 +25,12 @@ BED_FOOTPRINT = "bed-footprint" BED_NEAR_FULL = "bed-near-full" BED_PRIME_TOWER = "bed-prime-tower-room" +# --- where the placed instances sit (placement, per instance / plate); never carries community guidance --- +PLACEMENT_OFF_PLATE = "placement-off-plate" +PLACEMENT_BELOW_PLATE = "placement-below-plate" +PLACEMENT_OVER_HEIGHT = "placement-over-height" +PLACEMENT_PLATE_OVERSIZED = "placement-plate-oversized" +PLACEMENT_NOT_JUDGED = "placement-not-judged" # --- first layer --- FIRST_LAYER_ADHESION = "first-layer-adhesion" FIRST_LAYER_BED_FLATNESS = "first-layer-bed-flatness" @@ -65,6 +71,9 @@ TOOLHEAD_FIT: "Multi-Material Doctor", FILAMENT_METADATA: "Multi-Material Doctor", PAINTED_REGIONS: "Multi-Material Doctor", BED_HEIGHT: "Size fit (dimensions only)", BED_FOOTPRINT: "Size fit (dimensions only)", BED_NEAR_FULL: "Size fit (dimensions only)", BED_PRIME_TOWER: "Size fit (dimensions only)", + PLACEMENT_OFF_PLATE: "Placement (where instances sit)", PLACEMENT_BELOW_PLATE: "Placement (where instances sit)", + PLACEMENT_OVER_HEIGHT: "Placement (where instances sit)", PLACEMENT_PLATE_OVERSIZED: "Placement (where instances sit)", + PLACEMENT_NOT_JUDGED: "Placement (where instances sit)", FIRST_LAYER_ADHESION: "First Layer Doctor", FIRST_LAYER_BED_FLATNESS: "First Layer Doctor", FIRST_LAYER_CORNER_LIFT: "First Layer Doctor", FIRST_LAYER_ORIENTATION: "First Layer Doctor", PRINTER_FAILURE_HISTORY: "Printer Doctor", FIRMWARE_NOT_READY: "Printer Doctor", diff --git a/backend/snapstudio_core/intelligence_report.py b/backend/snapstudio_core/intelligence_report.py index a408755e..7f6a2e40 100644 --- a/backend/snapstudio_core/intelligence_report.py +++ b/backend/snapstudio_core/intelligence_report.py @@ -159,6 +159,10 @@ def build(predict=None, bed_fit=None, mm=None, first_layer=None, health=None, # finding: it is listed as "not verified" and keeps the report from saying "found nothing". spacing_unverified = bool(spacing and spacing.get("status") == "unknown") not_verified = ["object spacing"] if spacing_unverified else [] + # A bed-fit result that could not be produced (unidentified printer, nothing measurable) is not "no risk": + # say it was not verified. (A size that is not established is reported by its own not_checked finding above.) + if bed_fit and not avail["bed_fit"] and bed_fit.get("status") != "not_checked": + not_verified.append("bed fit") seen_r = set() recommendations = [r for r in recs if not (r in seen_r or seen_r.add(r))] diff --git a/backend/tests/test_intelligence_report.py b/backend/tests/test_intelligence_report.py index 05373841..bdf0b9c0 100644 --- a/backend/tests/test_intelligence_report.py +++ b/backend/tests/test_intelligence_report.py @@ -290,3 +290,29 @@ def test_community_fix_attaches_by_condition_id_not_keywords_issue_106(): fit = ir.build(mm={"available": True, "overall_level": "risk", "findings": [ {"id": "toolhead-fit", "level": "risk", "text": "5 colors but only 4 toolheads"}]}) assert any("community" in r for r in fit["risks"] if r["condition"] == "toolhead-fit") + + +def test_placement_findings_carry_condition_ids_and_never_community_guidance(): + """Placement findings are identified by id, own their action, and get no keyword-matched community fix.""" + from snapstudio_core import bed_fit as bf, intelligence_report as ir + placed = {"available": True, "plate_count": 1, "bed_height_mm": 270.0, "items": [], + "off_plate": [{"object_id": "1"}], "fixable": True, "not_judged": [], + "summary": "1 placed instance sits outside the printable area."} + doc = bf.assess_objects([{"object_id": "1", "dimensions_mm": {"x": 10, "y": 10, "z": 10}}], placed=placed, + bed=None, bed_known=False) + ids = {f.get("id") for f in doc["findings"] if f["level"] in ("warn", "risk")} + assert "placement-off-plate" in ids + assert all(f.get("id") and f.get("action") for f in doc["findings"] if f["level"] in ("warn", "risk")) + out = ir.build(bed_fit=doc) + risk = next(r for r in out["risks"] if r["condition"] == "placement-off-plate") + assert "community" not in risk and risk["action"] and not risk["action"].startswith("Look into") + + +def test_an_unavailable_bed_fit_is_listed_as_not_verified_not_as_no_risk(): + from snapstudio_core import intelligence_report as ir + mm_ok = {"available": True, "overall_level": "ok", "findings": []} + out = ir.build(bed_fit={"available": False, "basis": "size", "reason": "printer not identified"}, mm=mm_ok) + assert "bed fit" in out["not_verified"] + # control: a size that is not established is reported by its own finding, not double-listed + nc = ir.build(bed_fit={"available": False, "status": "not_checked", "reason": "unit is inch"}, mm=mm_ok) + assert "bed fit" not in nc["not_verified"]