diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ff6ec7..7e26b4a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project are documented here. The format is based on - **Print risk signals replace the readiness percentage.** The "Print readiness (estimate)" percentage, the star rating and the Studio score are gone. Studio now lists each risk it found, once per condition, with what it means and what to do, and says what it did and did not check. Community guidance attaches only to the conditions it actually fits. ### Fixed +- Bed fit now reports each object's size and the instances' placement as separate facts; several small, separated objects are no longer summed into one "too big" object (#91). - Object placement judges each placed copy of a repeated object on its own, so a copy that hangs off the plate is no longer hidden by one that does not (#93). - Placement and plate-fit checks are withheld (reported as not checked) for projects whose physical size the file does not establish: a non-millimetre unit, or a rotated or scaled object. diff --git a/backend/snapstudio_api/service.py b/backend/snapstudio_api/service.py index ca499a55..1cd1f6fa 100644 --- a/backend/snapstudio_api/service.py +++ b/backend/snapstudio_api/service.py @@ -496,7 +496,7 @@ def diff(a: str, b: str) -> dict: def insights(path: str) -> dict: """Rich read-only Project Intelligence (geometry, materials, readiness).""" from snapstudio_core.intelligence import project_info - return project_info(path) + return project_info(path, placement_aware=True) def report(path: str) -> dict: @@ -2077,12 +2077,14 @@ def mm_doctor(path: str, host: str | None = None, port: int = 7125, snapshot: di def bed_fit(path: str, host: str | None = None, port: int = 7125, snapshot: dict | None = None) -> 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) + info = project_info(path, placement_aware=True) basis = info.get("geometry_basis") if not geometry_basis.is_established(basis): # The numbers in the file are not the physical size: say "not checked", never "fits". @@ -2093,12 +2095,46 @@ def bed_fit(path: str, host: str | None = None, port: int = 7125, snapshot: dict multi = (info.get("colors") or 0) > 1 bed = None bed_known = False - caps = _caps(host, port, snapshot) - bm = (caps or {}).get("bed_mm") - if bm and bm.get("x"): - bed, bed_known = bm, True - return bf.assess(dims, bed=bed, bed_known=bed_known, - object_count=object_count, multi_material=multi) + # 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 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: + from snapstudio_core import printer_profiles + target = None + try: + caps = _caps(host, port, snapshot) + if caps is None: + raise RuntimeError("printer unreachable") # fall back to the profile Studio prepares for + identity = printer_profiles.identify({"reachable": True, + "klipper_objects": caps.get("klipper_objects"), + "toolhead_count": caps.get("toolhead_count")}) + target = _placement_target({"identity": identity}) + if identity.get("printer_id"): + # the identified printer's RECORDED printable volume, labelled as the profile's, not as + # something the printer measured + profile = printer_profiles.load(identity["printer_id"]) + else: + # a printer Studio cannot identify reports axis travel, not a printable size: neither + # the size nor the placement can be judged + return {"schema_version": bf.SCHEMA_VERSION, "available": False, "basis": "size", + "reason": ("Studio does not know this connected printer's printable area (it " + "reports axis travel, which is not a printable size), so it cannot say " + "whether the objects fit.")} + except Exception: + target = _placement_target({"identity": {"printer_id": printer_profiles_target_id()}}) + # 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. + try: + placed = _assess_against(path, target) + except Exception: + placed = None + return bf.assess_objects(info.get("object_sizes_mm"), placed=placed, + unmeasured=info.get("objects_unmeasured") or 0, + bed=bed, bed_known=bed_known, profile=profile, object_count=object_count, + multi_material=multi) def _printer_snapshot(host: str, port: int = 7125, limit: int = 50) -> dict: @@ -2691,6 +2727,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/bed_fit.py b/backend/snapstudio_core/bed_fit.py index 52daa697..d7cb1ad2 100644 --- a/backend/snapstudio_core/bed_fit.py +++ b/backend/snapstudio_core/bed_fit.py @@ -8,16 +8,31 @@ fits, a height/split warning, and — in multi-material mode — whether there's room left for the prime/wipe tower. -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. +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 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 SCHEMA_VERSION = "bedfit/1" +#: The root model part: an object in any other part is named with it. +ROOT_PART = "3D/3dmodel.model" + +#: What every size consumer says when per-object sizes are missing. +UNMEASURED_TEXT = "By size, Studio could not measure each object, so it cannot say whether they fit." + # A typical multi-material prime/wipe tower footprint side (mm) — the clearance the # model must leave on the plate or Orca pushes the tower out of bounds. PRIME_TOWER_MM = 55.0 @@ -37,10 +52,16 @@ def _f(level: str, text: str, id: str | None = None, action: str | None = None) 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). @@ -70,6 +91,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 @@ -82,7 +104,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.", id="bed-height", action=f"Scale to {bz / z * 100:.0f}% to fit the height, or split into parts.")) @@ -95,7 +117,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.", id="bed-footprint", action=f"Scale to {scale_pct:.0f}% so it fits the {bx:.0f}×{by:.0f} mm bed.")) @@ -108,7 +130,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.", id="bed-near-full", action="Center the model, drop the brim/skirt, or scale down slightly.")) @@ -119,28 +141,47 @@ def bump(level: str) -> None: mx, my = bx - x, by - y if mx < PRIME_TOWER_MM or my < PRIME_TOWER_MM: bump("warn") - findings.append(_f("warn", f"Little room for the multi-material prime/wipe tower " - f"(~{PRIME_TOWER_MM:.0f} mm, depending on your Orca tower settings): " - f"only {mx:.0f}×{my:.0f} mm is free beside the model, so Orca may push " - f"the tower off the bed (out of bounds).", - id="bed-prime-tower-room", action="Shrink the model a little, or reduce/disable the prime tower in Orca.")) - fixes.append("Shrink the model a little, or reduce/disable the prime tower in Orca.") + findings.append(_f("warn", f"By size, {who} leaves little room for the multi-material " + f"prime/wipe tower (~{PRIME_TOWER_MM:.0f} mm, depending on your " + f"Snapmaker Orca tower settings): the bed is only {mx:.0f}×{my:.0f} mm " + f"bigger than it, so the tower may not fit beside it. " + f"This is about size; where the tower lands depends on the arrangement.", + id="bed-prime-tower-room", + action="Shrink the model a little, or reduce/disable the prime tower in Snapmaker Orca.")) + fixes.append("Shrink the model a little, or reduce/disable the prime tower in Snapmaker Orca.") # Multiple parts share the plate — the arrangement must fit, not just one part. if object_count and object_count > 1: - findings.append(_f("ok", f"{object_count} objects share the plate — the whole " - f"arrangement must fit. If Orca still says out of bounds, one object " - f"is off the plate; use Arrange to re-pack them.")) + findings.append(_f("ok", f"The project has {object_count} objects. Whatever sits on one plate " + f"must fit that plate together; if Snapmaker Orca still says out of " + f"bounds, one object is off its plate and Arrange can 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 = f"By size, {who} 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, @@ -151,8 +192,157 @@ 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. + + Placement has three parts: where the instances sit sideways, how high they stand (their placed Z, + build-item scale included: a 10 mm cube scaled to 300 is 300 mm tall where it is placed), and + which of them could not be judged at all.""" + if not placed or not placed.get("available"): + return None + 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.", + 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.", + 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 + + +def _placed_sideways(placed: dict, findings: list, fixes: list) -> str | 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.", + 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')}", + 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')}", + 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 + 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" + + +_ORDER = {"ok": 0, "warn": 1, "risk": 2} + + +def assess_objects(objects: list[dict] | None, placed: dict | None = None, unmeasured: int = 0, + **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. When no object could be + measured the answer is UNKNOWN, not a fall-back to the overall extents. Every object is + checked (there is no cap); ``unmeasured`` says how many build items had no measurable + object, which keeps the result from claiming that everything fits. + """ + objects = [o for o in (objects or []) if o.get("dimensions_mm")] + if not objects: + return {"schema_version": SCHEMA_VERSION, "available": False, "basis": "size", + "reason": UNMEASURED_TEXT} + many = len(objects) > 1 + + def label_of(entry): + if not many or entry.get("object_id") is None: + return None + where = entry.get("part") + return f"object {entry['object_id']}" + (f" in {where}" if where and where != ROOT_PART else "") + + # Size first, object by object and WITHOUT the placement, so a level here is purely about size. + sized = [] + for entry in objects: + 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 worst object's result, with the placement added once ... + out = assess(worst_entry["dimensions_mm"], placed=placed, subject=label_of(worst_entry), **kw) + # ... and EVERY other object that is not fine reported too, each fix naming its object: scaling + # one object to fit says nothing about another that is too tall. + failing = [(e, r) for e, r in sized if r["overall_level"] != "ok"] + if many: + worst_res = next(r for e, r in sized if e is worst_entry) + placement_fixes = out["fixes"][len(worst_res["fixes"]):] # size fixes come first + out["fixes"] = [f"{label_of(e)}: {fix}" for e, r in failing for fix in r["fixes"]] + placement_fixes + for entry, res in failing: + if entry is worst_entry: + continue + out["findings"] += [f for f in res["findings"] if f["level"] != "ok"] + if _ORDER[res["overall_level"]] > _ORDER[out["overall_level"]]: + out["overall_level"] = res["overall_level"] + risky = [e for e, r in sized if r["overall_level"] == "risk"] + if len(risky) > 1: + out["overall_text"] = (f"By size, {len(risky)} objects won't fit as-is — this is the " + "out-of-bounds error, with a fix for each below.") + out["objects_checked"] = len(sized) + out["objects_unmeasured"] = unmeasured + out["objects_by_size"] = [{"object_id": e.get("object_id"), "part": e.get("part"), + "level": r["overall_level"], "dims_mm": r["dims_mm"], + "findings": [f["text"] for f in r["findings"] if f["level"] != "ok"], + "fixes": list(r["fixes"])} for e, r in sized] + if unmeasured: + out["findings"].append(_f("warn", f"By size, {unmeasured} build item" + f"{'s' if unmeasured != 1 else ''} could not be measured, so " + "Studio cannot say whether everything fits.")) + if out["overall_level"] != "risk": + out["overall_level"] = "warn" + out["overall_text"] = ("By size, it fits as far as Studio could measure, but " + f"{unmeasured} build item{'s' if unmeasured != 1 else ''} could not be " + "measured — see below before slicing.") + return out 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/geometry.py b/backend/snapstudio_core/geometry.py index 05ac19cf..d635c9f0 100644 --- a/backend/snapstudio_core/geometry.py +++ b/backend/snapstudio_core/geometry.py @@ -11,10 +11,13 @@ from __future__ import annotations import re from dataclasses import dataclass +import copy +import threading from pathlib import Path from . import roles as _roles from .container import ThreeMF +from .fileid import file_identity, stat_identity # noqa: F401 (the one content key; see fileid) from .config_io import load_model_settings # Mirror intelligence.py's byte guard; plus a triangle cap for analysis cost. @@ -219,7 +222,7 @@ def build_item_dims(path: str) -> list[dict]: An item (or component) that names a ``p:path`` is looked up in THAT part and nowhere else; one that cannot be found is left out here and reported by ``measure_items``.""" - return measure_items(path)[0] + return _measure(path)[0] def measure_items(path: str) -> tuple[list[dict], list[dict]]: @@ -228,14 +231,58 @@ def measure_items(path: str) -> tuple[list[dict], list[dict]]: ``unresolved`` lists the build items whose object could not be found where the file says it is (a ``p:path`` naming a part that is absent, or an object the part does not hold). They are never measured against some other object that happens to share the id.""" + items, unresolved, _sizes = _measure(path) + return items, unresolved + + +def object_sizes(path: str) -> list[dict]: + """Per-object size (mm): each object as modelled, keyed by (part, object id), 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)[2] + + +def measure(path: str) -> tuple[list[dict], list[dict], list[dict]]: + """``(placed items, unresolved items, object sizes)`` from a single read of the project.""" + return _measure(path) + + +#: The last few projects measured, keyed by file identity: one request often asks for the size, the +#: 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 _measure(path: str): + key = file_identity(path) + 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]: + stored = copy.deepcopy(result) + with _CACHE_LOCK: + while len(_CACHE) >= _CACHE_SLOTS: + _CACHE.pop(next(iter(_CACHE)), None) + _CACHE[key] = stored + return result + + +def _measure_uncached(path: str): 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 [], [], [] # (part, object_id) -> (verts, [(component_objectid, component_part|None, component_xform)]) parsed: dict[str, dict[str, tuple]] = {} @@ -263,7 +310,7 @@ def measure_items(path: str) -> tuple[list[dict], list[dict]]: root_file = ROOT_MODEL if root_file not in parsed: - return [], [] + return [], [], [] def find_obj(objid, part, explicit): """The part that holds ``objid``. A named part is the only place looked in; with no @@ -277,7 +324,10 @@ def find_obj(objid, part, explicit): return f return None + # The size of the objects and the placing of the items are counted against the vertex limit + # separately, so measuring both does not halve what a project may hold. budget = [0] + placed_budget = [0] def collect(objid, part, explicit, xform, acc, seen, bad, printing=None, role=None, records=None): f = find_obj(objid, part, explicit) @@ -310,34 +360,59 @@ def collect(objid, part, explicit, xform, acc, seen, bad, printing=None, role=No collect(cid, cpart or f, cpart is not None, _compose(xform, ctf), acc, seen, bad, 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] + return (min(xs), min(ys), min(zs)), (max(xs), max(ys), max(zs)) + + items = build_items(model_files[root_file]) try: - settings_text = tm.read_part(SETTINGS_PART).decode("utf-8", "ignore") \ - if tm.has_part(SETTINGS_PART) else "" + settings_text = tm.read_part(SETTINGS_PART).decode("utf-8", "ignore") if tm.has_part(SETTINGS_PART) else "" records = _roles.read_records(settings_text) except Exception: records = _roles.Records() + uses: dict[tuple, int] = {} + for it in items: + part = it["part"] or root_file + uses[(part, it["object_id"])] = uses.get((part, it["object_id"]), 0) + 1 + # Each distinct object is collected once, as modelled; an item then only moves those points. + collected: dict[tuple, tuple] = {} out = [] unresolved = [] - for it in build_items(model_files[root_file]): + sizes: dict[tuple, dict] = {} + for it in items: oid = it["object_id"] - acc: list = [] - printing: list = [] - bad: list = [] part = it["part"] or root_file - collect(oid, part, it["part"] is not None, _xform(it["transform"]), - acc, frozenset(), bad, printing, None, records) + 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, + printing, None, records) + collected[key] = (points, printing, bad) + # 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), + "z": round(hi[2] - lo[2], 2)}} + points, printing, bad = collected[key] if bad: unresolved.append({"item_index": it["item_index"], "object_id": oid, "part": part, "reason": bad[0]}) continue - if not acc: + if not points: continue + 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 acc - xs = [p[0] for p in measured]; ys = [p[1] for p in measured]; zs = [p[2] for p in measured] - lo = (min(xs), min(ys), min(zs)); hi = (max(xs), max(ys), max(zs)) + measured = printing or points + placed_budget[0] += len(measured) + if placed_budget[0] > _MAX_VERTS: + raise _TooLarge() + lo, hi = box_of([_apply(v, xform) for v in measured]) # `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, @@ -347,11 +422,11 @@ def collect(objid, part, explicit, xform, acc, seen, bad, printing=None, role=No "y": round(hi[1] - lo[1], 2), "z": round(hi[2] - lo[2], 2)}, "bounds": {"min": lo, "max": hi}}) - return out, unresolved + return out, unresolved, 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 2df111ac..57de4610 100644 --- a/backend/snapstudio_core/intelligence.py +++ b/backend/snapstudio_core/intelligence.py @@ -109,10 +109,15 @@ def _complexity(triangles: int | None) -> str | None: return "high" -def project_info(path: str) -> dict: +def project_info(path: str, placement_aware: bool = False) -> dict: """Rich, read-only insights for a design. Builds on the Doctor diagnosis and adds real geometry + material detail. Never raises on geometry — returns what - it can and leaves the rest null.""" + it can and leaves the rest null. + + ``placement_aware`` also reads every object's size and every instance's placed bounds + (``object_sizes_mm``, ``placed``). That means reading each mesh once more, so it is asked for + only by the callers that use it (Design Health, the bed-fit check, the validation report); the + rest keep the cost they had and get ``None`` for those three keys.""" diag = diagnose_path(path).to_dict() is_stl = str(path).lower().endswith(".stl") @@ -128,6 +133,40 @@ 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, 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 + # 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] | None = [] + unmeasured: int | None = 0 # build items whose object could not be found, so have no size here + placed: dict | None + if not placement_aware: + object_sizes, unmeasured, placed = None, None, None + elif 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, unresolved, sizes = geometry.measure(path) + object_sizes = [{"object_id": s["object_id"], "part": s["part"], + "instance_count": s["instance_count"], + "dimensions_mm": {k: round(v, 1) for k, v in s["dimensions"].items()}} + for s in sizes] + unmeasured = len(unresolved) + placed = plate_placement.placed_instances(path, measured=(placed_items, unresolved)) + 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, @@ -144,6 +183,10 @@ 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, + "objects_unmeasured": unmeasured, + "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 63c79296..7f6a2e40 100644 --- a/backend/snapstudio_core/intelligence_report.py +++ b/backend/snapstudio_core/intelligence_report.py @@ -106,7 +106,8 @@ def build(predict=None, bed_fit=None, mm=None, first_layer=None, health=None, # belongs to the condition. The risk list, Biggest risk, Next, the printer line and the counts all derive from this. contribs: list = [] recs: list = [] - for label, doc in (("Size fit (dimensions only)", bed_fit), ("Multi-Material Doctor", mm), ("First Layer Doctor", first_layer)): + bed_label = "Bed fit (size and placement)" if (bed_fit or {}).get("placement_checked") else "Size fit (dimensions only)" + for label, doc in ((bed_label, bed_fit), ("Multi-Material Doctor", mm), ("First Layer Doctor", first_layer)): contribs.extend(C.doctor_contributions(doc, label)) if doc and doc.get("available", True): recs.extend(doc.get("fixes") or []) @@ -158,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/snapstudio_core/placement.py b/backend/snapstudio_core/placement.py index 03b15235..7ee2fdc5 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 SCHEMA_VERSION = "placement/1" @@ -208,7 +209,38 @@ def _read(archive: zipfile.ZipFile, name: str) -> str: return "" +_READ_CACHE: dict = {} +_READ_SLOTS = 4 +_READ_LOCK = threading.Lock() + + def read_objects(path: str, keep_points: bool = True) -> dict: + """See ``_read_objects``. With ``keep_points=False`` the per-vertex outline is not collected and + the result is remembered for the next call on the same, unchanged file: footprints are all most + callers need, and they no longer re-read every mesh to get them.""" + import copy + + from . import geometry + + if keep_points: + return _read_objects(path, True) + key = geometry.file_identity(path) + 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]: + 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 + + +def _read_objects(path: str, keep_points: bool = True) -> dict: """Every logical object in a prepared project, with its footprint on the plate. The footprint is the printable geometry only. A modifier or a support blocker diff --git a/backend/snapstudio_core/plate_placement.py b/backend/snapstudio_core/plate_placement.py index 73d458ef..d31c44a9 100644 --- a/backend/snapstudio_core/plate_placement.py +++ b/backend/snapstudio_core/plate_placement.py @@ -236,7 +236,7 @@ def _printable_only(path: str, items: list[dict]) -> list[dict]: from . import placement try: - read = placement.read_objects(path) + read = placement.read_objects(path, keep_points=False) except Exception: return items # Keyed by build item, not by object id: one object can be used by several items, each @@ -266,6 +266,76 @@ def _printable_only(path: str, items: list[dict]) -> list[dict]: return out +def placed_instances(path: str, measured: tuple | 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. ``measured`` may carry ``geometry.measure_items`` output + already read. ``not_judged`` counts the build items that could not be found or placed on a plate; + they are not in ``instances`` and are never counted as fitting. 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."} + raw_items, geometry_unresolved = measured if measured is not None else geometry.measure_items(path) + items = _printable_only(path, raw_items) + if not items: + return {"available": False, "basis": "placed_bounds", "instances": [], + "plate_count": 0, "plate_extents": [], "combined_extent_mm": None, + "not_judged": len(geometry_unresolved), + "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, plate_unresolved = _group_items_by_plate(items, plates) if plates else ({}, []) + not_judged = len(geometry_unresolved) + (len(plate_unresolved) if plate_count > 1 else 0) + plate_of = {i.get("item_index"): number for number, members in grouped.items() for i in members} + + totals: dict[tuple, int] = {} + for item in items: + who = (item.get("part"), str(item["object_id"])) + totals[who] = totals.get(who, 0) + 1 + seen: dict[tuple, int] = {} + instances = [] + for item in items: + oid = (item.get("part"), 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, + "not_judged": not_judged, "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 f59272e9..5687d1d1 100644 --- a/backend/snapstudio_core/validation_report.py +++ b/backend/snapstudio_core/validation_report.py @@ -33,7 +33,7 @@ def _check(name, ok, detail): def readiness_report(path: str) -> dict: - info = project_info(path) + info = project_info(path, placement_aware=True) diag = diagnose_path(path).to_dict() is_stl = diag.get("input_type") == "stl" verdict = diag.get("verdict") @@ -57,13 +57,46 @@ def readiness_report(path: str) -> dict: # "ready" below (ready needs every check to pass). checks.append({"name": "Fits the print bed", "status": "not_checked", "detail": f"Not checked. {basis['summary']}"}) - 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"))) + else: + # 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. No per-object size means UNKNOWN, not the overall extents. + sized = [o for o in (info.get("object_sizes_mm") or []) if o.get("dimensions_mm")] + unmeasured = info.get("objects_unmeasured") or 0 + 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)] + many = len(sized) > 1 + + def _name(entry): + return f'object {entry["object_id"]} ' if many and entry.get("object_id") is not None else "" + + bed_text = f"{U1_BUILD[0]:.0f} × {U1_BUILD[1]:.0f} × {U1_BUILD[2]:.0f} mm bed" + if too_big: + d = too_big[0]["dimensions_mm"] + detail = (f'By size, {_name(too_big[0])}({d["x"]} × {d["y"]} × {d["z"]} mm) is larger than the U1 ' + "bed — scale it down or split it." + if many else + f'By size, {d["x"]} × {d["y"]} × {d["z"]} mm is larger than the U1 bed — ' + "scale it down or split it.") + if len(too_big) > 1: + detail += f" {len(too_big)} objects are larger than the bed." + else: + largest = max(sized, key=lambda o: o["dimensions_mm"]["x"] * o["dimensions_mm"]["y"]) + d = largest["dimensions_mm"] + detail = (f'By size, the largest object ({_name(largest).strip()}, {d["x"]} × {d["y"]} × ' + f'{d["z"]} mm) fits the U1\'s {bed_text}.' + if many else + f'By size, {d["x"]} × {d["y"]} × {d["z"]} mm fits the U1\'s {bed_text}.') + if unmeasured: + detail += (f" {unmeasured} build item{'s' if unmeasured != 1 else ''} could not be measured, " + "so Studio cannot say whether everything fits.") + checks.append(_check("Fits the print bed", not too_big and not unmeasured, detail)) + elif dims: + from .bed_fit import UNMEASURED_TEXT + checks.append(_check("Fits the print bed", False, UNMEASURED_TEXT)) if colors: colors_ok = colors <= U1_TOOLHEADS checks.append(_check( diff --git a/backend/tests/test_geometry_verdict.py b/backend/tests/test_geometry_verdict.py index 413db07f..2f24c3f7 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 s["predict"]["available"] is True and "status" not in s["predict"] and isinstance(s["predict"]["signals"], list) 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"] diff --git a/backend/tests/test_ready_now_api.py b/backend/tests/test_ready_now_api.py index b15a9c4f..b01e9a8e 100644 --- a/backend/tests/test_ready_now_api.py +++ b/backend/tests/test_ready_now_api.py @@ -431,6 +431,26 @@ def boom(*a, **k): 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 + def test_a_same_size_replacement_with_the_same_timestamp_is_not_served_stale(tmp_path, monkeypatch): import os from snapstudio_api import service diff --git a/backend/tests/test_role_inheritance_size.py b/backend/tests/test_role_inheritance_size.py new file mode 100644 index 00000000..16e5a9bf --- /dev/null +++ b/backend/tests/test_role_inheritance_size.py @@ -0,0 +1,68 @@ +"""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, REV, SEAM_WORDS, bed_fit_check, + nested_assembly, on_assembly, on_nested_record, + ready_placement) + + +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 + SEAM_WORDS) +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" + + +@pytest.mark.parametrize("role", ["modifier_part", "precise_seam_center"]) +def test_one_fixture_every_consumer_agrees(tmp_path, monkeypatch, role): + """The 10 mm printable part beside a 300 mm helper volume under a nested assembly: preflight, Ready Now, + the Bed-Fit API, the validation report, the overall extents and the scene say the same thing.""" + from snapstudio_core import plate_placement as pp, scene + path = nested_assembly(tmp_path, on_assembly(role)) + assert bed_fit_check(path, monkeypatch)["result"] == "ok" # preflight + assert pp.over_height(ready_placement(path)) == [] # Ready Now + assert service.bed_fit(path)["overall_level"] == "ok" # Bed-Fit API + assert fits_check(path)["status"] == "pass" # validation report + assert project_info(path)["dimensions_mm"]["z"] == 10.0 # overall extents + sc = scene.build_scene_dict(path, REV) # scene + assert next(n for n in sc["nodes"] if n["id"] == "b0")["bounds_mm"]["max"][2] == 10.0 + assert not any(f["kind"] in ("size", "placement") for f in sc["findings"]) diff --git a/backend/tests/test_second_printer_failures.py b/backend/tests/test_second_printer_failures.py index 72beea5b..a0406593 100644 --- a/backend/tests/test_second_printer_failures.py +++ b/backend/tests/test_second_printer_failures.py @@ -54,14 +54,33 @@ def test_a_printer_reporting_fewer_tools_than_the_profile_is_believed(): assert any(c["field"] == "tool_count" for c in out["conflicts"]) -def test_a_much_bigger_bed_is_used_as_reported(): +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", "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)) + path = str(fx.three_mf(tmp_path / "far.3mf", fx.model_xml([fx.cube_object("1", 20)], [("1", fx.tf(500, 500, 0))]))) + # the placement is DERIVED through the service from the identified printer, not written by hand + target = service._placement_target({"identity": {"printer_id": "bigmachine"}}) + placement = service._assess_against(path, target) + assert placement["available"] and placement["off_plate"] == [] project = {"filament_count": {"value": 1, "confidence": "confirmed", "evidence": "t"}} - big = facts(bed_mm={"x": 1000.0, "y": 1000.0, "z": 1000.0}) - out = pf.evaluate(project, big, placement={ - "available": True, "off_plate": [], "bed_height_mm": 1000.0, - "bed": {"min_x": 0.0, "min_y": 0.0, "max_x": 1000.0, "max_y": 1000.0}, "items": []}) + 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: { + "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 new file mode 100644 index 00000000..6b00d460 --- /dev/null +++ b/backend/tests/test_size_unknown_and_limits.py @@ -0,0 +1,322 @@ +"""Issue 91 repair round: an unknown size stays unknown, every object is checked, size wording claims no +position, and measuring a project once is enough.""" +from __future__ import annotations + +from snapstudio_api import service +from snapstudio_core import bed_fit, geometry, placement, plate_placement as pp +from snapstudio_core.intelligence import project_info +from tests import scene_fixtures as fx +from tests.test_size_vs_placed_bounds import box_object, project, texts + +SETTINGS = "Metadata/model_settings.config" + + +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 = {"[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)) + + +# --- unknown stays unknown ---------------------------------------------------------------------------- + +def test_unmeasured_objects_do_not_fall_back_to_the_combined_extents(tmp_path): + path = _renamed_root(tmp_path) + info = project_info(path, placement_aware=True) + 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 # 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"] in ("warn", "not_checked") + + +def test_assess_objects_with_nothing_measured_is_unavailable_not_a_guess(): + out = bed_fit.assess_objects([]) + assert out["available"] is False and out["reason"] == bed_fit.UNMEASURED_TEXT + assert bed_fit.assess_objects(None)["available"] is False + + +def test_some_objects_unmeasured_never_reads_as_everything_fits(tmp_path): + root = fx.model_xml([fx.cube_object("1", 10)], + [("1", fx.tf(100, 100, 0)), ("1", fx.tf(100, 100, 0), "/3D/Objects/missing.model")]) + path = str(fx.three_mf(tmp_path / "u.3mf", root)) + info = project_info(path, placement_aware=True) + assert info["objects_unmeasured"] == 1 and len(info["object_sizes_mm"]) == 1 + result = service.bed_fit(path) + assert result["overall_level"] == "warn" and result["objects_unmeasured"] == 1 + assert "could not be measured" in result["overall_text"] + check = next(c for c in service.report(path)["checks"] if c["name"] == "Fits the print bed") + assert check["status"] == "warn" and "1 build item could not be measured" in check["detail"] + + +# --- every object is checked ---------------------------------------------------------------------------- + +def test_the_65th_object_is_checked_too(tmp_path): + objects = [box_object(str(i), 10, 10, 10) for i in range(1, 65)] + [box_object("65", 10, 10, 300)] + items = [(str(i), fx.tf(5 + (i % 20) * 12, 5 + (i // 20) * 12, 0)) for i in range(1, 66)] + path = project(tmp_path, objects, items) + result = service.bed_fit(path) + assert result["objects_checked"] == 65 and result["overall_level"] == "risk" + assert "By size, object 65 is taller than" in texts(result) + check = next(c for c in service.report(path)["checks"] if c["name"] == "Fits the print bed") + assert check["status"] == "warn" and "object 65 (10.0 × 10.0 × 300.0 mm)" in check["detail"] + + +# --- size statements claim no position ----------------------------------------------------------------------- + +def test_the_prime_tower_finding_is_about_size_not_placement(): + out = bed_fit.assess({"x": 250, "y": 250, "z": 40}, multi_material=True) + tower = next(f for f in out["findings"] if "tower" in f["text"]) + assert tower["text"].startswith("By size,") and "Snapmaker Orca" in tower["text"] + assert "off the bed" not in tower["text"] and "push" not in tower["text"] + + +def test_a_multi_plate_project_is_not_told_its_objects_share_one_plate(tmp_path): + settings = fx.model_settings_xml({"1": [("1", "normal_part")], "2": [("2", "normal_part")]}, + [(1, [("1", 0)]), (2, [("2", 0)])]) + path = project(tmp_path, [fx.cube_object("1", 10), fx.cube_object("2", 10)], + [("1", fx.tf(50, 50, 0)), ("2", fx.tf(400, 50, 0))], settings=settings) + text = texts(service.bed_fit(path)) + assert "share the plate" not in text and "whole arrangement must fit" not in text + assert "Whatever sits on one plate must fit that plate" in text + + +def test_overall_wording_names_size_first(): + big = bed_fit.assess({"x": 320, "y": 100, "z": 50}) + assert big["overall_text"] == ("By size, this model won't fit as-is — this is the out-of-bounds error, " + "with the fix below.") + tight = bed_fit.assess({"x": 262, "y": 120, "z": 40}) + assert tight["overall_text"] == "By size, it fits, but the edges are tight — see below before slicing." + + +# --- a size is as modelled; a scaled item shows up as a placed bound ------------------------------------------ + +def test_object_size_ignores_scale_and_turn_on_the_first_item(tmp_path): + path = project(tmp_path, [box_object("1", 10, 10, 10)], + [("1", "2 0 0 0 2 0 0 0 2 10 10 0"), ("1", "0 1 0 -1 0 0 0 0 1 50 50 0")]) + [size] = geometry.object_sizes(path) + assert size["dimensions"] == {"x": 10.0, "y": 10.0, "z": 10.0} and size["instance_count"] == 2 + placed = geometry.build_item_dims(path) + assert placed[0]["dimensions"] == {"x": 20.0, "y": 20.0, "z": 20.0} + + +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 + # 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 ------------------------------------------------------------------------------------------- + +def test_a_project_is_measured_once_across_the_consumers(tmp_path, monkeypatch): + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))], name="once.3mf") + calls = {"measure": 0, "read": 0} + real_measure, real_read = geometry._measure_uncached, placement._read_objects + + def counting_measure(p): + calls["measure"] += 1 + return real_measure(p) + + def counting_read(p, *args, **kw): + calls["read"] += 1 + return real_read(p, *args, **kw) + + monkeypatch.setattr(geometry, "_measure_uncached", counting_measure) + monkeypatch.setattr(placement, "_read_objects", counting_read) + project_info(path, placement_aware=True) + service.bed_fit(path) + service.report(path) + assert calls == {"measure": 1, "read": 1} + + +def test_the_measurement_cache_follows_the_file(tmp_path): + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))], name="chg.3mf") + assert geometry.object_sizes(path)[0]["dimensions"]["x"] == 10.0 + project(tmp_path, [fx.cube_object("1", 30)], [("1", fx.tf(100, 100, 0))], name="chg.3mf") + assert geometry.object_sizes(path)[0]["dimensions"]["x"] == 30.0 + + +def test_measuring_sizes_and_items_does_not_halve_the_vertex_limit(tmp_path, monkeypatch): + path = project(tmp_path, [fx.cube_object("1", 10), fx.cube_object("2", 10)], + [("1", fx.tf(10, 10, 0)), ("2", fx.tf(60, 10, 0))], name="lim.3mf") + monkeypatch.setattr(geometry, "_MAX_VERTS", 16) # exactly the 16 vertices the file holds + items, unresolved, sizes = geometry.measure(path) + assert len(items) == 2 and len(sizes) == 2 and unresolved == [] + + +def test_two_parts_with_the_same_object_id_have_two_sizes(tmp_path): + a, b = "3D/Objects/a.model", "3D/Objects/b.model" + extra = {a: fx.sub_model_xml([fx.cube_object("1", 10)]), b: fx.sub_model_xml([fx.cube_object("1", 50)])} + path = str(fx.three_mf(tmp_path / "two.3mf", fx.model_xml( + [], [("1", fx.tf(10, 10, 0), "/" + a), ("1", fx.tf(100, 10, 0), "/" + b)]), extra)) + sizes = {s["part"]: s["dimensions"]["x"] for s in geometry.object_sizes(path)} + assert sizes == {a: 10.0, b: 50.0} + # a build item that names its part (p:path) is structure the RC2 guard cannot follow, so placement + # is not checked: no per-instance verdict, no number (the sizes above are read untransformed) + report = pp.assess(path) + assert report["available"] is False and report["status"] == "not_checked" + assert report["geometry_basis"]["established"] is False + + +def test_project_info_keeps_its_old_cost_unless_asked_for_placement_data(tmp_path, monkeypatch): + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))], name="plain.3mf") + monkeypatch.setattr(geometry, "_measure_uncached", lambda p: (_ for _ in ()).throw(AssertionError("read"))) + info = project_info(path) + assert info["object_sizes_mm"] is None and info["placed"] is None and info["objects_unmeasured"] is None + assert info["dimensions_mm"] == {"x": 10.0, "y": 10.0, "z": 10.0} + + +# --- review round 2 -------------------------------------------------------------------------------------- + +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}, "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_mm"]["y"] <= 270 # the profile volume, not the travel + assert result["bed_mm_source"] == "profile" and result["bed_known"] is False + assert "connected" not in result["bed_source"] # recorded volume, not "your printer" + 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) + inside = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))], name="inside.3mf") + assert service.bed_fit(inside, host="u1.invalid")["overall_level"] == "ok" + + +def test_every_failing_object_is_reported_with_its_own_fix(tmp_path): + path = project(tmp_path, [box_object("1", 280, 280, 10), box_object("2", 10, 10, 1000)], + [("1", fx.tf(0, 0, 0)), ("2", fx.tf(0, 0, 0))]) + result = service.bed_fit(path) + assert result["overall_level"] == "risk" + body = " ".join(f["text"] for f in result["findings"]) + assert "object 1 is too big" in body and "object 2 is taller than" in body + assert any(fx_.startswith("object 1: Scale to") for fx_ in result["fixes"]) + assert any(fx_.startswith("object 2: Scale to") for fx_ in result["fixes"]) + assert "2 objects won't fit as-is" in result["overall_text"] + by_object = {o["object_id"]: o for o in result["objects_by_size"]} + assert by_object["1"]["level"] == "risk" and by_object["1"]["fixes"] and by_object["1"]["findings"] + assert by_object["2"]["level"] == "risk" and by_object["2"]["fixes"] and by_object["2"]["findings"] + + +def test_a_same_size_replacement_that_keeps_its_timestamp_is_not_served_stale(tmp_path): + import os + path = project(tmp_path, [fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))], name="swap.3mf") + before = os.stat(path) + assert geometry.object_sizes(path)[0]["dimensions"]["x"] == 10.0 + project(tmp_path, [fx.cube_object("1", 30)], [("1", fx.tf(100, 100, 0))], name="swap.3mf") + assert os.stat(path).st_size == before.st_size + os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns)) # same size, same timestamp + assert os.stat(path).st_mtime_ns == before.st_mtime_ns + assert geometry.object_sizes(path)[0]["dimensions"]["x"] == 30.0 + settings = fx.model_settings_xml({"1": [("1", "normal_part")]}, [(1, [("1", 0)])]) + fx.three_mf(tmp_path / "fp.3mf", fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))]), {SETTINGS: settings}) + fp = str(tmp_path / "fp.3mf") + stat = os.stat(fp) + assert placement.read_objects(fp, keep_points=False)["objects"][0]["footprint"]["width"] == 10.0 + fx.three_mf(tmp_path / "fp.3mf", fx.model_xml([fx.cube_object("1", 30)], [("1", fx.tf(100, 100, 0))]), {SETTINGS: settings}) + os.utime(fp, ns=(stat.st_atime_ns, stat.st_mtime_ns)) + assert placement.read_objects(fp, keep_points=False)["objects"][0]["footprint"]["width"] == 30.0 + + +def test_placed_instances_number_uses_of_the_same_object(tmp_path): + a, b = "3D/Objects/a.model", "3D/Objects/b.model" + extra = {a: fx.sub_model_xml([fx.cube_object("1", 10)]), b: fx.sub_model_xml([fx.cube_object("1", 50)])} + path = str(fx.three_mf(tmp_path / "ids.3mf", fx.model_xml( + [], [("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") + # neither the printable size nor the placement is known: unknown, never "fits" + assert result["available"] is False and "does not know this connected printer's printable area" in result["reason"] + long = project(tmp_path, [box_object("1", 10, 300, 10)], [("1", fx.tf(10, 10, 0))], name="long.3mf") + assert service.bed_fit(long, host="x.invalid")["available"] is False # 10 x 300 x 10 is not reported as fitting + + +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 == [] + + +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 + + +def test_a_tall_modifier_does_not_make_the_bed_fit_check_report_over_height(tmp_path): + 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})) + 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_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 new file mode 100644 index 00000000..065c1b19 --- /dev/null +++ b/backend/tests/test_size_vs_placed_bounds.py @@ -0,0 +1,195 @@ +"""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", "part": "3D/3dmodel.model", "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_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) + assert info["dimensions_basis"] == "overall_extents_by_size" + assert info["object_sizes_mm"] == [{"object_id": "1", "part": "3D/3dmodel.model", "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, placement_aware=True) + 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, placement_aware=True) + 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, the largest object (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, placement_aware=True)["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, placement_aware=True) + 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, the largest object (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, placement_aware=True)["dimensions_mm"] == {"x": 30.0, "y": 30.0, "z": 30.0} + + +def test_unmeasurable_size_stays_unavailable(): + assert bed_fit.assess_objects([])["available"] is False diff --git a/desktop/src/api.ts b/desktop/src/api.ts index 82b0e576..ecac8676 100644 --- a/desktop/src/api.ts +++ b/desktop/src/api.ts @@ -372,7 +372,15 @@ 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[]; + // Build items whose object could not be found, so they have no size above. + objects_unmeasured?: number; + // 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[]; @@ -1352,6 +1360,31 @@ 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; + part?: string; + 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 331c29b9..609d7bd7 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"; import { sizeNotEstablished } from "@/components/GeometryNotice"; // U1 build volume (mm) — matches the engine's bed-fit check. @@ -19,7 +20,8 @@ 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, + unmeasured = 0): Row[] { const rows: Row[] = []; const integ = mesh.integrity; const fitBed = bed ?? U1_BED; @@ -71,33 +73,36 @@ function buildRows(mesh: MeshReport, dims?: { x: number; y: number; z: number } : "Few steep overhangs — this should print without supports." }); } - // 5. Bed fit (from dimensions vs the real connected printer bed, else the U1 default) - if (dims && unsure) { + // 5. Bed fit BY SIZE: each object against the real connected printer bed, else the U1 default. + // A size says nothing about position; placement is its own card. When the file does not establish + // its physical size, the numbers are not judged against the bed at all. + if (unsure) { rows.push({ key: "bedfit", label: "Bed fit", icon: Ruler, level: "warn", status: "Not checked", - detail: `${basis!.summary} The ${dims.x} × ${dims.y} × ${dims.z} numbers stored in the file are not judged against the bed.` }); - } else if (dims) { - const fits = dims.x <= fitBed.x && dims.y <= fitBed.y && dims.z <= fitBed.z; - const bedStr = `${fitBed.x} × ${fitBed.y} × ${fitBed.z} mm`; - rows.push({ key: "bedfit", label: "Bed fit", icon: Ruler, level: fits ? "ok" : "risk", - status: fits ? "Fits" : "Too big", - detail: fits - ? `${dims.x} × ${dims.y} × ${dims.z} mm fits ${bedSource} ${bedStr} bed.` - : `${dims.x} × ${dims.y} × ${dims.z} mm is larger than ${bedSource} ${bedStr} bed — scale it down or split it.` }); + detail: dims + ? `${basis!.summary} The ${dims.x} × ${dims.y} × ${dims.z} numbers stored in the file are not judged against the bed.` + : `${basis!.summary} The sizes stored in the file are not judged against the bed.` }); + } else { + const fit = sizeRow(objects, dims, fitBed, bedSource, unmeasured); + if (fit) { + rows.push({ key: "bedfit", label: "Bed fit (by size)", icon: Ruler, level: fit.level, + status: fit.status, detail: fit.detail }); + } } return rows; } /** Design Health — at-a-glance geometry verdicts with plain-language what/why/do. * `mode` "advanced" appends the raw metric footer. */ -export function DesignHealth({ mesh, dims, bed, mode = "simple" }: { +export function DesignHealth({ mesh, dims, objects, unmeasured, bed, mode = "simple" }: { mesh?: MeshReport; dims?: { x: number; y: number; z: number } | null; + objects?: ObjectSize[] | null; unmeasured?: number; bed?: { x: number; y: number; z: number } | null; mode?: "simple" | "advanced"; }) { if (!mesh) return null; if (!mesh.available) { return

Geometry analysis isn’t available for this file (it may be too large).

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