From 6cae03d60b8578a159bcf502c1f75fced4e53319 Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Fri, 9 Oct 2026 17:33:15 -0400 Subject: [PATCH 01/12] fix(placement): judge every build item, not every object id (#93) The placement check keyed its table by object id, so an object used by several build items took the last item's transform and every instance was reported at the same place. An instance far off the plate could read "inside". - placement.read_objects walks build items by ordinal (item_index, instance_index, instance_count) and composes nested component transforms. - geometry.build_item_dims reports item_index. - plate_placement.assess matches footprints to items by item_index, reports per-instance bounds, assigns plates per instance, and words the summary and off_plate list per placed instance. - verify_only_placement_moved compares build items by position, so the move-onto-plate copy is verified per instance (items with no transform are the identity). - Desktop: placement card keys and labels instances separately. Verified: backend pytest 3176 passed / 49 skipped; desktop tsc, vitest (819 passed) and build clean. Co-Authored-By: Claude Sonnet 5.5 --- backend/snapstudio_core/geometry.py | 5 +- backend/snapstudio_core/placement.py | 73 +++++- backend/snapstudio_core/plate_placement.py | 140 +++++++++--- .../tests/test_repeated_instance_placement.py | 214 ++++++++++++++++++ desktop/src/api.ts | 4 + desktop/src/components/PlacementCard.tsx | 6 +- desktop/src/lib/placement.test.ts | 25 +- desktop/src/lib/placement.ts | 25 +- 8 files changed, 450 insertions(+), 42 deletions(-) create mode 100644 backend/tests/test_repeated_instance_placement.py diff --git a/backend/snapstudio_core/geometry.py b/backend/snapstudio_core/geometry.py index 94861085..a90c6d94 100644 --- a/backend/snapstudio_core/geometry.py +++ b/backend/snapstudio_core/geometry.py @@ -180,7 +180,7 @@ def collect(objid, prefer_file, xform, acc, seen): collect(cid, cpath or f, _compose(xform, ctf), acc, seen) out = [] - for it in load_model_settings(model_files[root_file]).iter(f"{_3MF_CORE_NS}item"): + for ordinal, it in enumerate(load_model_settings(model_files[root_file]).iter(f"{_3MF_CORE_NS}item")): oid = it.get("objectid") acc: list = [] collect(oid, root_file, @@ -190,7 +190,10 @@ def collect(objid, prefer_file, xform, acc, seen): continue xs = [p[0] for p in acc]; ys = [p[1] for p in acc]; zs = [p[2] for p in acc] lo = (min(xs), min(ys), min(zs)); hi = (max(xs), max(ys), max(zs)) + # `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, + "item_index": ordinal, "dimensions": {"x": round(hi[0] - lo[0], 2), "y": round(hi[1] - lo[1], 2), "z": round(hi[2] - lo[2], 2)}, diff --git a/backend/snapstudio_core/placement.py b/backend/snapstudio_core/placement.py index 9a5d3b99..457ae601 100644 --- a/backend/snapstudio_core/placement.py +++ b/backend/snapstudio_core/placement.py @@ -250,11 +250,21 @@ def read_objects(path: str) -> dict: polygon = polygon_of(project) parts_by_object = multipart._parts_by_object(settings_text) if settings_text else {} - placements = dict(_BUILD_ITEM.findall(root)) components = _components_by_object(root) + # One entry per BUILD ITEM, in document order. Keying by object id would keep only the last + # transform of an object that several items use, and every instance would then be reported + # at the last one's position. objects = [] - for object_id, transform_text in placements.items(): + seen_per_object: dict[str, int] = {} + totals: dict[str, int] = {} + build = build_items(root) + for entry in build: + totals[entry["object_id"]] = totals.get(entry["object_id"], 0) + 1 + for entry in build: + object_id, transform_text = entry["object_id"], entry["transform"] + instance_index = seen_per_object.get(object_id, 0) + seen_per_object[object_id] = instance_index + 1 item = _units.scale_translation(parse_transform(transform_text), root_scale) part_ids = [part_id for part_id, _subtype in parts_by_object.get(object_id, [])] roles = dict(parts_by_object.get(object_id, [])) @@ -262,18 +272,21 @@ def read_objects(path: str) -> dict: (part_id, None) for part_id in part_ids] printable: list[tuple] = [] every: list[tuple] = [] - for mesh_id, component_transform in own_components: + for mesh_id, component_transform in _leaf_components(own_components, components, + meshes, root_scale): points = meshes.get(mesh_id) if points is None: continue - whole = compose(item, _units.scale_translation(parse_transform(component_transform), - root_scale)) + whole = compose(item, component_transform) moved = [apply(whole, point) for point in points] every.extend(moved) if roles.get(mesh_id, "normal_part") == "normal_part": printable.extend(moved) objects.append({ "object_id": object_id, + "item_index": entry["item_index"], + "instance_index": instance_index, + "instance_count": totals[object_id], "name": _name_of(settings_text, object_id), "transform": transform_text, "part_ids": part_ids, @@ -287,6 +300,45 @@ def read_objects(path: str) -> dict: "polygon_source": PROJECT_SETTINGS if polygon else None} +_ITEM_TAG = re.compile(r"]*>") +_OBJECTID_ATTR = re.compile(r'\bobjectid="([^"]*)"') +_TRANSFORM_ATTR = re.compile(r'\btransform="([^"]*)"') +_MAX_COMPONENT_DEPTH = 16 + + +def build_items(root: str) -> list[dict]: + """Every build item of a root model, in document order. + + `item_index` is the ordinal of the item among ALL `` tags, so it matches + `geometry.build_item_dims` and the scene's build index. `transform` is the item's + text, or None when it has none (the identity). + """ + out = [] + for ordinal, tag in enumerate(_ITEM_TAG.findall(root)): + object_id = _OBJECTID_ATTR.search(tag) + transform = _TRANSFORM_ATTR.search(tag) + out.append({"item_index": ordinal, + "object_id": object_id.group(1) if object_id else "", + "transform": transform.group(1) if transform else None}) + return out + + +def _leaf_components(entries: list, components: dict, meshes: dict, scale: float, + depth: int = 0, outer: tuple | None = None, seen: frozenset = frozenset()): + """(mesh id, transform in mm) for every mesh an object reaches through its components. + + A component may itself be an object made of components, with its own transform; the + transforms compose down the path rather than being applied one after the other. + """ + for mesh_id, text in entries: + here = compose(outer, _units.scale_translation(parse_transform(text), scale)) + if meshes.get(mesh_id): + yield mesh_id, here + elif mesh_id in components and mesh_id not in seen and depth < _MAX_COMPONENT_DEPTH: + yield from _leaf_components(components[mesh_id], components, meshes, scale, + depth + 1, here, seen | {mesh_id}) + + def _scaled(points: list[tuple], factor: float) -> list[tuple]: """Mesh points in millimetres. A millimetre part is returned untouched.""" if factor == 1.0: @@ -468,15 +520,24 @@ def assess(path: str, printer: str = "Snapmaker U1") -> dict: objects = [] for entry in read["objects"]: verdict = classify(entry["footprint"], polygon, entry.get("printable_points")) + label = entry["name"] + if entry["instance_count"] > 1: + # Two instances of one object need telling apart, or the sentence about the + # one that is off the plate reads as if it were about both. + label = (f"{label or 'This object'} (instance {entry['instance_index'] + 1} " + f"of {entry['instance_count']})") objects.append({ "object_id": entry["object_id"], + "item_index": entry["item_index"], + "instance_index": entry["instance_index"], + "instance_count": entry["instance_count"], "name": entry["name"], "transform": entry["transform"], "footprint_mm": _rounded(entry["footprint"]), "status": verdict["status"], "excess_mm": verdict.get("excess_mm") or {}, "fits_by_translation": verdict.get("fits_by_translation"), - "message": describe(entry["name"], verdict, printer), + "message": describe(label, verdict, printer), "reason": verdict.get("reason"), }) diff --git a/backend/snapstudio_core/plate_placement.py b/backend/snapstudio_core/plate_placement.py index e007f9cd..a9f4a665 100644 --- a/backend/snapstudio_core/plate_placement.py +++ b/backend/snapstudio_core/plate_placement.py @@ -113,6 +113,17 @@ def _overhang(bounds: dict, bed: dict) -> dict: } +def _instance_list(rows: list[dict]) -> str: + """'object 1 instance 1 of 2, object 3' — which placed items a sentence is about.""" + named = [] + for row in rows: + text = f"object {row['object_id']}" + if row.get("instance_count", 1) > 1: + text += f" instance {row['instance_index'] + 1} of {row['instance_count']}" + named.append(text) + return ", ".join(named) + + def _edges_text(over: dict) -> str: named = [name for name, mm in over.items() if mm > 0] return ", ".join(named) @@ -179,7 +190,9 @@ def _printable_only(path: str, items: list[dict]) -> list[dict]: read = placement.read_objects(path) except Exception: return items - footprints = {entry["object_id"]: entry["footprint"] + # Keyed by build item, not by object id: one object can be used by several items, each + # at its own place, and a table keyed by object id gives all of them the last one's. + footprints = {entry["item_index"]: entry["footprint"] for entry in read.get("objects") or () if entry.get("footprint")} if not footprints: @@ -187,7 +200,7 @@ def _printable_only(path: str, items: list[dict]) -> list[dict]: out = [] for item in items: - box = footprints.get(str(item.get("object_id"))) + box = footprints.get(item.get("item_index")) if box is None: out.append(item) continue @@ -236,15 +249,38 @@ def _unavailable(reason: str) -> dict: ) +_MODEL_INSTANCE_RE = re.compile(r"(.*?)", re.S) +_METADATA_RE = re.compile(r'key="([^"]*)"\s+value="([^"]*)"') + + def _plates_from_model_settings(tm: ThreeMF) -> list[dict]: - """UI plate number -> the object ids on it, from the project's own records.""" + """UI plate number -> the objects on it, from the project's own records. + + `object_ids` is every object named; `members` is the same list as + ``(object id, instance id or None)`` so a repeated object can be placed per instance. + """ part = "Metadata/model_settings.config" if not tm.has_part(part): return [] try: from .plate_remap import _parse_plates - return _parse_plates(tm.read_part(part).decode("utf-8", "ignore")) + text = tm.read_part(part).decode("utf-8", "ignore") + plates = _parse_plates(text) + bodies = re.findall(r"(.*?)", text, re.S) + # `_parse_plates` sorts by plate number; pair each body with its plate by number. + by_number: dict = {} + for body in bodies: + number = re.search(r'key="plater_id"\s+value="(\d+)"', body) + members = [] + for instance in _MODEL_INSTANCE_RE.findall(body): + meta = dict(_METADATA_RE.findall(instance)) + if meta.get("object_id") is not None: + members.append((meta["object_id"], meta.get("instance_id"))) + by_number.setdefault(int(number.group(1)) if number else None, []).extend(members) + for plate in plates: + plate["members"] = by_number.get(plate["ui_number"], []) + return plates except Exception: return [] @@ -252,21 +288,42 @@ def _plates_from_model_settings(tm: ThreeMF) -> list[dict]: def _group_items_by_plate(items: list[dict], plates: list[dict]): """Split build items across plates. Returns (grouped, unresolved). - An item whose object is on no plate record is 'unresolved': Studio cannot say - which plate it belongs to, and therefore cannot judge it. + Judged per instance: the n-th build item that uses an object is matched to the plate + record that names that instance. A record that names an object without saying which + instance can only place it when the object is used once. An item that no record (or + more than one plate) claims is 'unresolved': Studio cannot say which plate it is on, + and therefore cannot judge it. """ - owner: dict[str, int] = {} - for plate in plates: - for oid in plate.get("object_ids") or []: - owner[str(oid)] = plate["ui_number"] + totals: dict[str, int] = {} + for item in items: + totals[str(item["object_id"])] = totals.get(str(item["object_id"]), 0) + 1 + seen: dict[str, int] = {} grouped: dict[int, list[dict]] = {} unresolved: list[dict] = [] for item in items: - plate_no = owner.get(str(item["object_id"])) - if plate_no is None: + oid = str(item["object_id"]) + ordinal = seen.get(oid, 0) + seen[oid] = ordinal + 1 + owners: set = set() + ambiguous = False + for plate in plates: + members = plate.get("members") + if members is None: # a plate described by object ids alone + members = [(str(o), None) for o in plate.get("object_ids") or []] + for member_oid, instance in members: + if str(member_oid) != oid: + continue + if instance is None: + if totals[oid] == 1: + owners.add(plate["ui_number"]) + else: + ambiguous = True + elif str(instance).isdigit() and int(instance) == ordinal: + owners.add(plate["ui_number"]) + if ambiguous or len(owners) != 1 or None in owners: unresolved.append(item) else: - grouped.setdefault(plate_no, []).append(item) + grouped.setdefault(next(iter(owners)), []).append(item) return grouped, unresolved @@ -287,6 +344,7 @@ def _plate_fit(grouped: dict[int, list[dict]], bed: dict, "width": round(width, 2), "depth": round(depth, 2), "object_ids": [i["object_id"] for i in grouped[number]], + "item_indexes": [i.get("item_index") for i in grouped[number]], "reason": None if fits else ( f"the objects on this plate span {width:.0f} × {depth:.0f} mm, which is " f"larger than {whose} {usable_x:.0f} × {usable_y:.0f} mm plate"), @@ -333,12 +391,20 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d if multi_plate: plates = _plates_from_model_settings(tm) grouped, unresolved_items = _group_items_by_plate(items, plates) - unresolved = [{"object_id": i["object_id"]} for i in unresolved_items] + unresolved = [{"object_id": i["object_id"], "item_index": i.get("item_index")} + for i in unresolved_items] plate_fit = _plate_fit(grouped, target, whose) + totals: dict[str, int] = {} + for item in items: + totals[str(item["object_id"])] = totals.get(str(item["object_id"]), 0) + 1 + seen: dict[str, int] = {} reported = [] for item in items: lo, hi = item["bounds"]["min"], item["bounds"]["max"] + oid = str(item["object_id"]) + instance_index = seen.get(oid, 0) + seen[oid] = instance_index + 1 if multi_plate: # A plate's absolute coordinates on a multi-plate grid are an artefact # of the authoring slicer, not a fault. Judge the plate's *size*. @@ -349,6 +415,10 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d off = any(mm > 0 for mm in over.values()) reported.append({ "object_id": item["object_id"], + "item_index": item.get("item_index"), + "instance_index": instance_index, + "instance_count": totals[oid], + "bounds_mm": {"min": [round(v, 4) for v in lo], "max": [round(v, 4) for v in hi]}, "dimensions": item["dimensions"], "position": {"x": round((lo[0] + hi[0]) / 2.0, 2), "y": round((lo[1] + hi[1]) / 2.0, 2)}, @@ -359,9 +429,9 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d oversized_plates = [p for p in plate_fit if not p["fits"]] if multi_plate: - oversized_ids = {oid for p in oversized_plates for oid in p["object_ids"]} + oversized_items = {ix for p in oversized_plates for ix in p["item_indexes"]} for row in reported: - if row["object_id"] in oversized_ids: + if row["item_index"] in oversized_items: row["off_plate"] = True off_plate = [r for r in reported if r["off_plate"]] @@ -378,6 +448,15 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d # the file, so any move would be a guess. fixable = (not multi_plate) and bool(off_plate) and would_fit + # A repeated object is one object at several places; the sentence counts the places, so the + # instance that is off the plate is never averaged away by the one that is not. + repeated = any(r["instance_count"] > 1 for r in reported) + if repeated: + count_text = (f"{len(off_plate)} of {len(reported)} placed instance(s) " + f"({_instance_list(off_plate)})") + else: + count_text = f"{len(off_plate)} object(s)" + if multi_plate and not off_plate: summary = (f"All {len(plate_fit)} plates fit {whose} printable area. Studio does " "not reposition multi-plate projects — open the project in Snapmaker " @@ -390,19 +469,21 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d elif multi_plate: summary = MULTI_PLATE_REFUSAL elif not off_plate: - summary = (f"Every object sits inside {whose} printable area." + summary = (f"Every placed instance sits inside {whose} printable area." + if repeated else + f"Every object sits inside {whose} printable area." if len(reported) > 1 else f"The object sits inside {whose} printable area.") elif too_wide: - summary = (f"{len(off_plate)} object(s) fall outside {whose} plate, and the " + summary = (f"{count_text} fall outside {whose} plate, and the " "whole arrangement is wider than the plate — moving it cannot fix " "this. Scale it down or split it across plates.") elif would_fit: - summary = (f"{len(off_plate)} object(s) fall outside {whose} plate, but the " + summary = (f"{count_text} fall outside {whose} plate, but the " "whole arrangement fits — moving it as one piece brings everything " "back on, keeping the creator's layout, rotation and scale.") else: - summary = (f"{len(off_plate)} object(s) fall outside {whose} plate and one " + summary = (f"{count_text} fall outside {whose} plate and one " "move will not fix it. Open it in Snapmaker Orca and use Arrange.") return { @@ -539,15 +620,20 @@ def check(name: str, ok: bool, detail: str = "") -> bool: check("the root model's geometry and components are unchanged", strip(root_before) == strip(root_after)) - items_before = dict(placement._BUILD_ITEM.findall(root_before)) - items_after = dict(placement._BUILD_ITEM.findall(root_after)) - check("every object is still placed", set(items_before) == set(items_after)) + # Per build item, in order: an object used by two items is two items to compare. + items_before = placement.build_items(root_before) + items_after = placement.build_items(root_after) + check("every object is still placed", + [i["object_id"] for i in items_before] == [i["object_id"] for i in items_after]) deltas = set() - same_basis = True - for object_id, text in items_before.items(): - one = placement.parse_transform(text) - two = placement.parse_transform(items_after.get(object_id)) + same_basis = len(items_before) == len(items_after) + for before_item, after_item in zip(items_before, items_after): + # an item with no transform is at the identity + one = (placement.parse_transform(before_item["transform"]) + if before_item["transform"] else placement.IDENTITY) + two = (placement.parse_transform(after_item["transform"]) + if after_item["transform"] else placement.IDENTITY) if one is None or two is None: same_basis = False break diff --git a/backend/tests/test_repeated_instance_placement.py b/backend/tests/test_repeated_instance_placement.py new file mode 100644 index 00000000..203c8941 --- /dev/null +++ b/backend/tests/test_repeated_instance_placement.py @@ -0,0 +1,214 @@ +"""Issue 93: the placement check judges every build item, not every object id. + +One object used by two build items is two places on the plate. The check used to key by object id, so +every instance took the LAST item's footprint and an instance far off the plate could read "inside". +""" +from __future__ import annotations + +import zipfile + +import pytest + +from snapstudio_core import placement, plate_placement as pp, scene +from tests import scene_fixtures as fx + +REV = "ab" * 32 +SETTINGS = "Metadata/model_settings.config" + + +def _plain(tmp_path, items, name="p.3mf", with_part_record=True): + """One cube object `1` used by `items`, with the part record the issue's reproduction carries.""" + extra = {} + if with_part_record: + extra[SETTINGS] = fx.model_settings_xml({"1": [("1", "normal_part")]}, [(1, [("1", 0), ("1", 1)])]) + return fx.three_mf(tmp_path / name, fx.model_xml([fx.cube_object("1", 10)], items), extra) + + +def _rows(report): + return {row["item_index"]: row for row in report["items"]} + + +@pytest.mark.parametrize("record", [True, False]) +@pytest.mark.parametrize("order", [(500, 100), (100, 500)]) +def test_an_in_bounds_instance_cannot_hide_an_off_plate_one(tmp_path, record, order): + path = _plain(tmp_path, [("1", fx.tf(order[0], 100, 0)), ("1", fx.tf(order[1], 100, 0))], + with_part_record=record) + report = pp.assess(str(path)) + off_item = order.index(500) + assert report["available"] and report["item_count"] == 2 + assert len(report["off_plate"]) == 1 and report["off_plate"][0]["item_index"] == off_item + rows = _rows(report) + assert rows[off_item]["off_plate"] is True and rows[1 - off_item]["off_plate"] is False + assert rows[off_item]["position"]["x"] == 505.0 and rows[1 - off_item]["position"]["x"] == 105.0 + assert rows[off_item]["edges"] == "right" + assert [rows[i]["instance_index"] for i in (0, 1)] == [0, 1] + assert rows[0]["instance_count"] == 2 and rows[0]["object_id"] == "1" + # the summary names the instance, and does not claim everything is inside + assert "1 of 2 placed instance(s)" in report["summary"] + assert f"object 1 instance {off_item + 1} of 2" in report["summary"] + assert "inside" not in report["summary"] + + +def test_both_instances_off_the_plate_are_both_reported(tmp_path): + path = _plain(tmp_path, [("1", fx.tf(500, 100, 0)), ("1", fx.tf(-40, 100, 0))]) + report = pp.assess(str(path)) + assert [row["item_index"] for row in report["off_plate"]] == [0, 1] + assert {row["edges"] for row in report["off_plate"]} == {"right", "left"} + + +def test_every_instance_inside_says_so_per_instance(tmp_path): + report = pp.assess(str(_plain(tmp_path, [("1", fx.tf(50, 50, 0)), ("1", fx.tf(150, 50, 0))]))) + assert report["off_plate"] == [] and report["summary"] == ( + "Every placed instance sits inside the Snapmaker U1's printable area.") + + +def test_the_engine_placement_check_reports_each_instance(tmp_path): + path = _plain(tmp_path, [("1", fx.tf(500, 100, 0)), ("1", fx.tf(100, 100, 0))]) + objects = placement.read_objects(str(path))["objects"] + assert [(o["item_index"], o["instance_index"], o["instance_count"]) for o in objects] == [(0, 0, 2), (1, 1, 2)] + assert objects[0]["footprint"]["min_x"] == 500.0 and objects[1]["footprint"]["min_x"] == 100.0 + # the part record is present, so read_objects reads the footprint from it for BOTH items + assert all(o["footprint"] for o in objects) + + +def test_multiple_components_are_measured_per_instance(tmp_path): + path = fx.bambu_project(tmp_path / "b.3mf", parts=2, items=[("100", fx.tf(500, 100, 0)), ("100", fx.tf(100, 100, 0))], + plates=[(1, [("100", 0), ("100", 1)])]) + report = pp.assess(str(path)) + rows = _rows(report) + # two 10 mm cubes 20 mm apart: 30 mm wide, starting at the item's translation + assert rows[0]["bounds_mm"]["min"][0] == 500.0 and rows[0]["bounds_mm"]["max"][0] == 530.0 + assert rows[1]["bounds_mm"]["min"][0] == 100.0 and rows[1]["bounds_mm"]["max"][0] == 130.0 + assert [row["item_index"] for row in report["off_plate"]] == [0] + + +def test_a_modifier_component_does_not_count_for_either_instance(tmp_path): + path = fx.bambu_project(tmp_path / "m.3mf", parts=2, roles=["normal_part", "modifier_part"], + items=[("100", fx.tf(100, 100, 0)), ("100", fx.tf(150, 100, 0))], + plates=[(1, [("100", 0), ("100", 1)])]) + rows = _rows(pp.assess(str(path))) + assert rows[0]["bounds_mm"]["max"][0] == 110.0 and rows[1]["bounds_mm"]["max"][0] == 160.0 + assert pp.assess(str(path))["off_plate"] == [] + + +def _nested(tmp_path, xs): + inner = fx.composite_object("5", [("1", None, "-1 0 0 0 1 0 0 0 1 0 0 0")]) # mirror x + outer = fx.composite_object("6", [("5", None, fx.tf(30, 0, 0))]) + root = fx.model_xml([fx.cube_object("1", 10), inner, outer], [("6", fx.tf(x, 50, 0)) for x in xs]) + return fx.three_mf(tmp_path / "n.3mf", root) + + +def test_nested_component_transforms_compose_per_instance(tmp_path): + path = _nested(tmp_path, [0, 400]) + rows = _rows(pp.assess(str(path))) + # mirror -> x in [-10, 0]; +30 -> [20, 30]; plus the item's own x + assert (rows[0]["bounds_mm"]["min"][0], rows[0]["bounds_mm"]["max"][0]) == (20.0, 30.0) + assert (rows[1]["bounds_mm"]["min"][0], rows[1]["bounds_mm"]["max"][0]) == (420.0, 430.0) + assert [row["item_index"] for row in pp.assess(str(path))["off_plate"]] == [1] + footprints = [o["footprint"] for o in placement.read_objects(str(path))["objects"]] + assert [(f["min_x"], f["max_x"]) for f in footprints] == [(20.0, 30.0), (420.0, 430.0)] + + +def _fixtures(tmp_path): + return { + "repeated": _plain(tmp_path, [("1", fx.tf(500, 100, 0)), ("1", fx.tf(100, 100, 0))]), + "repeated_no_record": _plain(tmp_path, [("1", fx.tf(100, 100, 0)), ("1", fx.tf(-40, 90, 0))], + name="q.3mf", with_part_record=False), + "components": fx.bambu_project(tmp_path / "c.3mf", parts=3, + items=[("100", fx.tf(20, 30, 0)), ("100", fx.tf(300, 30, 0))], + plates=[(1, [("100", 0), ("100", 1)])]), + "nested": _nested(tmp_path, [0, 400]), + "single": fx.bambu_project(tmp_path / "s.3mf", parts=1, items=[("100", fx.tf(300, 60, 0))], + plates=[(1, [("100", 0)])]), + } + + +def test_per_instance_results_match_the_scene_contracts_world_bounds(tmp_path): + for name, path in _fixtures(tmp_path).items(): + sc = scene.build_scene_dict(str(path), REV) + report = pp.assess(str(path)) + by_id = {n["id"]: n for n in sc["nodes"]} + assert report["item_count"] == len([n for n in sc["nodes"] if n["parent_id"] is None]), name + for row in report["items"]: + top = by_id[f"b{row['item_index']}"] + assert top["bounds_mm"]["min"] == pytest.approx(row["bounds_mm"]["min"], abs=1e-3), name + assert top["bounds_mm"]["max"] == pytest.approx(row["bounds_mm"]["max"], abs=1e-3), name + lo, hi = top["bounds_mm"]["min"], top["bounds_mm"]["max"] + assert pp._overhang({"min": lo, "max": hi}, report["bed"]) == row["overhang_mm"], name + assert row["off_plate"] == any(v > 0 for v in row["overhang_mm"].values()), name + + +def test_plates_are_assigned_per_instance_when_the_object_is_repeated(tmp_path): + # one object, two instances, one on each plate + settings = fx.model_settings_xml({"1": [("1", "normal_part")]}, [(1, [("1", 0)]), (2, [("1", 1)])]) + path = fx.three_mf(tmp_path / "mp.3mf", fx.model_xml([fx.cube_object("1", 10)], + [("1", fx.tf(50, 50, 0)), ("1", fx.tf(400, 50, 0))]), {SETTINGS: settings}) + report = pp.assess(str(path)) + assert report["plate_count"] == 2 and report["unresolved_objects"] == [] + assert [(p["plate"], p["item_indexes"]) for p in report["plate_fit"]] == [(1, [0]), (2, [1])] + assert all(p["fits"] for p in report["plate_fit"]) + + +def test_an_oversized_plate_flags_only_its_own_instance(tmp_path): + big = fx.cube_object("1", 300) + settings = fx.model_settings_xml({"1": [("1", "normal_part")], "2": [("2", "normal_part")]}, + [(1, [("1", 0)]), (2, [("2", 0)])]) + path = fx.three_mf(tmp_path / "ov.3mf", fx.model_xml([big, fx.cube_object("2", 10)], + [("1", fx.tf(0, 0, 0)), ("2", fx.tf(400, 0, 0))]), {SETTINGS: settings}) + report = pp.assess(str(path)) + assert [row["item_index"] for row in report["off_plate"]] == [0] + + +def test_an_instance_the_plate_records_cannot_place_is_unresolved_not_guessed(tmp_path): + # the record names the object on a plate without saying which of its two instances + settings = fx.model_settings_xml({"1": [("1", "normal_part")]}, [(1, [("1", None)]), (2, [("2", 0)])]) + settings = settings.replace('', '', 1) + path = fx.three_mf(tmp_path / "amb.3mf", fx.model_xml([fx.cube_object("1", 10), fx.cube_object("2", 10)], + [("1", fx.tf(50, 50, 0)), ("1", fx.tf(80, 50, 0)), ("2", fx.tf(200, 50, 0))]), + {SETTINGS: settings}) + report = pp.assess(str(path)) + assert sorted(u["item_index"] for u in report["unresolved_objects"]) == [0, 1] + + +# --- the fix still works, instance by instance --------------------------------------------------- + +def _item_transforms(path): + with zipfile.ZipFile(path) as archive: + return placement.build_items(archive.read("3D/3dmodel.model").decode("utf-8")) + + +def test_prepare_placed_copy_moves_every_instance_by_the_same_amount(tmp_path): + src = _plain(tmp_path, [("1", fx.tf(-5, 100, 0)), ("1", fx.tf(100, 100, 0))]) + original = src.read_bytes() + result = pp.prepare_placed_copy(str(src), out_dir=str(tmp_path / "out")) + assert result["ok"] is True, result.get("reason") + assert result["objects_moved"] == 2 and result["offset_mm"] == {"x": 6.0, "y": 0.0} + assert result["after"]["off_plate"] == [] + assert src.read_bytes() == original # the original is never modified + before = [placement.parse_transform(i["transform"]) for i in _item_transforms(src)] + after = [placement.parse_transform(i["transform"]) for i in _item_transforms(result["output_path"])] + assert [round(a[3][0] - b[3][0], 4) for a, b in zip(after, before)] == [6.0, 6.0] + assert result["verification"]["passed"] is True + + +def test_prepare_placed_copy_handles_an_item_with_no_transform(tmp_path): + src = _plain(tmp_path, [("1", None), ("1", fx.tf(60, 0.5, 0))]) + report = pp.assess(str(src)) + assert [row["item_index"] for row in report["off_plate"]] == [0, 1] + result = pp.prepare_placed_copy(str(src), out_dir=str(tmp_path / "out")) + assert result["ok"] is True, result.get("reason") + assert result["verification"]["passed"] is True and result["after"]["off_plate"] == [] + + +def test_prepare_placed_copy_refuses_when_one_move_cannot_fix_instances(tmp_path): + src = _plain(tmp_path, [("1", fx.tf(500, 100, 0)), ("1", fx.tf(100, 100, 0))]) + result = pp.prepare_placed_copy(str(src), out_dir=str(tmp_path / "out")) + assert result["ok"] is False and "wider than the plate" in result["reason"] + assert not (tmp_path / "out").exists() or not list((tmp_path / "out").iterdir()) + + +def test_a_millimetre_project_with_one_item_per_object_reports_as_before(tmp_path): + path = fx.plain_cube_3mf(tmp_path / "one.3mf", at=(300, 60, 0)) + report = pp.assess(str(path)) + assert report["summary"].startswith("1 object(s) fall outside") + assert [row["instance_count"] for row in report["items"]] == [1] diff --git a/desktop/src/api.ts b/desktop/src/api.ts index 2be05629..cdc9d1d5 100644 --- a/desktop/src/api.ts +++ b/desktop/src/api.ts @@ -1299,6 +1299,10 @@ export async function ecosystemAdvice( export interface PlacementItem { object_id: string; + // One object can be used by several build items; these tell the instances apart. + item_index?: number; + instance_index?: number; + instance_count?: number; dimensions: { x: number; y: number; z: number }; position: { x: number; y: number }; off_plate: boolean; diff --git a/desktop/src/components/PlacementCard.tsx b/desktop/src/components/PlacementCard.tsx index a15d7a86..3d6a9351 100644 --- a/desktop/src/components/PlacementCard.tsx +++ b/desktop/src/components/PlacementCard.tsx @@ -5,7 +5,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { useToast } from "@/store/toast"; import { placementCheck, preparePlaced } from "@/api"; import type { PlacementCheck, PlacementFix } from "@/api"; -import { blockedReason, overhangText, placementVerdict } from "@/lib/placement"; +import { blockedReason, itemLabel, overhangText, placementVerdict } from "@/lib/placement"; /** * Where the objects actually sit. @@ -77,9 +77,9 @@ export function PlacementCard({ path }: { path: string }) { {check?.available && check.off_plate.length > 0 && (
    {check.off_plate.map((item) => ( -
  • +
  • - Object {item.object_id} · {item.dimensions.x} × {item.dimensions.y} ×{" "} + {itemLabel(item)} · {item.dimensions.x} × {item.dimensions.y} ×{" "} {item.dimensions.z} mm

    {overhangText(item)}

    diff --git a/desktop/src/lib/placement.test.ts b/desktop/src/lib/placement.test.ts index 4197f15b..42a5eb5d 100644 --- a/desktop/src/lib/placement.test.ts +++ b/desktop/src/lib/placement.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { PlacementCheck, PlacementItem } from "@/api"; -import { blockedReason, overhangText, placementVerdict } from "./placement"; +import { blockedReason, itemLabel, overhangText, placementVerdict } from "./placement"; const item = (over: Partial = {}): PlacementItem => ({ object_id: "1", @@ -107,3 +107,26 @@ describe("blockedReason", () => { .toContain("will not guess"); }); }); + +describe("repeated instances", () => { + const second = { ...item({ right: 235 }), item_index: 0, instance_index: 0, instance_count: 2 }; + const first = { ...item(), off_plate: false, item_index: 1, instance_index: 1, instance_count: 2 }; + + it("labels each instance of one object separately", () => { + expect(itemLabel(second)).toBe("Object 1 · instance 1 of 2"); + expect(itemLabel(item())).toBe("Object 1"); + }); + + it("counts placed instances, so an in-bounds one cannot hide an off-plate one", () => { + const verdict = placementVerdict( + check({ items: [second, first], off_plate: [second], item_count: 2, fixable: false }), + ); + expect(verdict.headline).toBe("1 placed instance is outside the U1's printable area."); + expect(verdict.tone).toBe("blocked"); + }); + + it("says placed instances when all are inside", () => { + const verdict = placementVerdict(check({ items: [first, { ...first, item_index: 2 }], item_count: 2 })); + expect(verdict.headline).toBe("Every placed instance sits inside the U1's printable area."); + }); +}); diff --git a/desktop/src/lib/placement.ts b/desktop/src/lib/placement.ts index 9e349dce..c529af78 100644 --- a/desktop/src/lib/placement.ts +++ b/desktop/src/lib/placement.ts @@ -30,18 +30,28 @@ export function placementVerdict(check: PlacementCheck | null): PlacementVerdict canFix: false, }; } + const repeated = check.items.some((entry) => (entry.instance_count ?? 1) > 1); if (check.off_plate.length === 0) { const many = (check.item_count ?? 0) > 1; return { tone: "ok", - headline: many - ? "Every object sits inside the U1's printable area." - : "The object sits inside the U1's printable area.", + headline: repeated + ? "Every placed instance sits inside the U1's printable area." + : many + ? "Every object sits inside the U1's printable area." + : "The object sits inside the U1's printable area.", canFix: false, }; } const count = check.off_plate.length; - const noun = count === 1 ? "object is" : "objects are"; + // A repeated object is one object at several places: count the places. + const noun = repeated + ? count === 1 + ? "placed instance is" + : "placed instances are" + : count === 1 + ? "object is" + : "objects are"; return { tone: check.fixable ? "warn" : "blocked", headline: `${count} ${noun} outside the U1's printable area.`, @@ -49,6 +59,13 @@ export function placementVerdict(check: PlacementCheck | null): PlacementVerdict }; } +/** "Object 1", or "Object 1 · instance 2 of 3" when the same object is placed more than once. */ +export function itemLabel(item: PlacementItem): string { + const total = item.instance_count ?? 1; + if (total <= 1) return `Object ${item.object_id}`; + return `Object ${item.object_id} · instance ${(item.instance_index ?? 0) + 1} of ${total}`; +} + /** How far off, and which way — as a sentence rather than four numbers. */ export function overhangText(item: PlacementItem): string { const parts = (["left", "right", "front", "back"] as const) From 8af1b894b04a2c500eb1251959d0dcd7b436d2f7 Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Sat, 10 Oct 2026 01:02:44 -0400 Subject: [PATCH 02/12] fix(placement): key by part, never treat unjudged instances as fine (#93 repair) - Build items and components that name a p:path are looked up in that part only, and objects are keyed by (part, object id) in build_item_dims and read_objects; an item whose part or object is missing is reported as unresolved instead of borrowing another object that shares the id. - One XML reader (geometry.build_items) numbers build items for every module, so item_index cannot drift (comments, namespace prefixes). - Instances Studio cannot place on a plate or find are counted (not_judged), listed, block the fix and never produce an "All N plates fit" or "inside" summary; the desktop verdict follows. - verify_only_placement_moved compares every build-item attribute except the transform, requires one identical non-zero X/Y move, and prepare_placed_copy checks it is the move that was asked for. Negative tests: swapped p:path, changed attribute, one instance moved, no move. - Summary wording: singular/plural and instance list; fix-ledger titles name the instance; CHANGELOG entry. Verified: backend pytest 3189 passed / 49 skipped; desktop tsc, vitest (821 passed) and build clean. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 + backend/snapstudio_api/service.py | 5 +- backend/snapstudio_core/geometry.py | 103 +++++++--- backend/snapstudio_core/placement.py | 159 +++++++++------- backend/snapstudio_core/plate_placement.py | 142 +++++++++----- .../test_part_paths_and_move_verifier.py | 180 ++++++++++++++++++ .../tests/test_repeated_instance_placement.py | 6 +- desktop/src/api.ts | 4 +- desktop/src/lib/placement.test.ts | 16 ++ desktop/src/lib/placement.ts | 21 +- docs/innovation-fund/JUDGE_WALKTHROUGH.md | 2 +- 11 files changed, 497 insertions(+), 144 deletions(-) create mode 100644 backend/tests/test_part_paths_and_move_verifier.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 16acd56d..5db27852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +### Fixed +- 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). + ## [1.5.0] - 2026-10-06 **Project Materials: pick the real spool, and the real Snapmaker Orca preset, for each colour (#39).** diff --git a/backend/snapstudio_api/service.py b/backend/snapstudio_api/service.py index 0b638749..dc83ef17 100644 --- a/backend/snapstudio_api/service.py +++ b/backend/snapstudio_api/service.py @@ -574,7 +574,10 @@ def prepare_placed(path: str, out_dir: str | None = None) -> dict: changes=[{"key": "object placement", "old": "off the U1 plate", "new": change.get("detail"), "reason": change.get("kept")} for change in (result.get("changes") or [])], - findings=[{"title": f"Object {item.get('object_id')} outside the plate", + findings=[{"title": (f"Object {item.get('object_id')}, instance " + f"{(item.get('instance_index') or 0) + 1} of {item['instance_count']} " + "outside the plate" if (item.get("instance_count") or 1) > 1 + else f"Object {item.get('object_id')} outside the plate"), "detail": item.get("edges") and f"past the {item['edges']} edge"} for item in (before.get("off_plate") or [])], validated=not ((result.get("after") or {}).get("off_plate"))) diff --git a/backend/snapstudio_core/geometry.py b/backend/snapstudio_core/geometry.py index a90c6d94..91099997 100644 --- a/backend/snapstudio_core/geometry.py +++ b/backend/snapstudio_core/geometry.py @@ -103,21 +103,63 @@ def appA(v): t[0] + a[9], t[1] + a[10], t[2] + a[11]] +ROOT_MODEL = "3D/3dmodel.model" + + +def normalize_part(path: str | None) -> str | None: + """A ``p:path`` as an archive entry name (leading slash removed); None when absent.""" + if not path: + return None + return path.lstrip("/") + + +def build_items(root: bytes | str) -> list[dict]: + """Every build item of a root model, in document order, read with the XML parser. + + The ONE reader of build items: ``item_index`` is the ordinal among all ```` + elements, so a commented-out tag or a namespace prefix cannot make two readers number + the items differently. ``part`` is the item's ``p:path`` (None when it has none) and + ``attributes`` is every attribute of the element, by name. + """ + data = root.encode("utf-8") if isinstance(root, str) else root + out = [] + for ordinal, it in enumerate(load_model_settings(data).iter(f"{_3MF_CORE_NS}item")): + out.append({"item_index": ordinal, + "object_id": it.get("objectid"), + "transform": it.get("transform"), + "part": normalize_part(it.get(f"{_3MF_PROD_NS}path")), + "attributes": dict(it.attrib)}) + return out + + def build_item_dims(path: str) -> list[dict]: """Per-build-item bounding-box dimensions (mm), with the 3MF build transform and nested component transforms applied — i.e. each placed object's real on-plate - size. Read-only, exception-safe (returns [] on any failure). Used by the Scale - Doctor size-options ladder for per-plate dimensions.""" + size, one entry per build item (a repeated object appears once per item). + Read-only, exception-safe (returns [] on any failure). Used by the Scale Doctor + size-options ladder for per-plate dimensions. + + 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] + + +def measure_items(path: str) -> tuple[list[dict], list[dict]]: + """``(placed items, unresolved items)``. + + ``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.""" try: tm = ThreeMF.open(path) except Exception: - return [] + return [], [] try: model_files = {p: tm.read_part(p) for p in tm.list_parts() if p.endswith(".model")} if sum(len(b) for b in model_files.values()) > _MAX_BYTES: - return [] + return [], [] - # object_id -> (verts, [(component_objectid, component_path, component_xform)]), per file + # (part, object_id) -> (verts, [(component_objectid, component_part|None, component_xform)]) parsed: dict[str, dict[str, tuple]] = {} unit_scale: dict[str, float] = {} for fname, raw in model_files.items(): @@ -143,18 +185,22 @@ def build_item_dims(path: str) -> list[dict]: if cn is not None: for c in cn.iterfind(f"{_3MF_CORE_NS}component"): comps.append((c.get("objectid"), - c.get(f"{_3MF_PROD_NS}path"), + normalize_part(c.get(f"{_3MF_PROD_NS}path")), _units.scale_translation(_xform(c.get("transform")), scale))) objs[oid] = (verts, comps) parsed[fname] = objs - root_file = "3D/3dmodel.model" + root_file = ROOT_MODEL if root_file not in parsed: - return [] + return [], [] - def find_obj(objid, prefer): - if prefer in parsed and objid in parsed[prefer]: - return prefer + def find_obj(objid, part, explicit): + """The part that holds ``objid``. A named part is the only place looked in; with no + ``p:path`` the current part is preferred and any other part is a legacy fallback.""" + if part in parsed and objid in parsed[part]: + return part + if explicit: + return None for f, oo in parsed.items(): if objid in oo: return f @@ -162,9 +208,10 @@ def find_obj(objid, prefer): budget = [0] - def collect(objid, prefer_file, xform, acc, seen): - f = find_obj(objid, prefer_file) + def collect(objid, part, explicit, xform, acc, seen, bad): + f = find_obj(objid, part, explicit) if f is None: + bad.append(f"object {objid} is not in {part}") return key = (f, objid) if key in seen or len(seen) > 4096: # cycle / runaway-nesting guard @@ -176,16 +223,23 @@ def collect(objid, prefer_file, xform, acc, seen): budget[0] += 1 if budget[0] > _MAX_VERTS: raise _TooLarge() - for cid, cpath, ctf in comps: - collect(cid, cpath or f, _compose(xform, ctf), acc, seen) + for cid, cpart, ctf in comps: + collect(cid, cpart or f, cpart is not None, _compose(xform, ctf), acc, seen, bad) out = [] - for ordinal, it in enumerate(load_model_settings(model_files[root_file]).iter(f"{_3MF_CORE_NS}item")): - oid = it.get("objectid") + unresolved = [] + for it in build_items(model_files[root_file]): + oid = it["object_id"] acc: list = [] - collect(oid, root_file, - _units.scale_translation(_xform(it.get("transform")), unit_scale[root_file]), - acc, frozenset()) + bad: list = [] + part = it["part"] or root_file + collect(oid, part, it["part"] is not None, + _units.scale_translation(_xform(it["transform"]), unit_scale[root_file]), + acc, frozenset(), bad) + if bad: + unresolved.append({"item_index": it["item_index"], "object_id": oid, + "part": part, "reason": bad[0]}) + continue if not acc: continue xs = [p[0] for p in acc]; ys = [p[1] for p in acc]; zs = [p[2] for p in acc] @@ -193,16 +247,17 @@ def collect(objid, prefer_file, xform, acc, seen): # `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, - "item_index": ordinal, + "item_index": it["item_index"], + "part": part, "dimensions": {"x": round(hi[0] - lo[0], 2), "y": round(hi[1] - lo[1], 2), "z": round(hi[2] - lo[2], 2)}, "bounds": {"min": lo, "max": hi}}) - return out + return out, unresolved except _TooLarge: - return [] + return [], [] except Exception: - return [] + return [], [] def load_mesh(path: str) -> Mesh | None: diff --git a/backend/snapstudio_core/placement.py b/backend/snapstudio_core/placement.py index 457ae601..31a5ec8a 100644 --- a/backend/snapstudio_core/placement.py +++ b/backend/snapstudio_core/placement.py @@ -50,10 +50,6 @@ TOLERANCE = 5e-4 _VERTEX = re.compile(r']*x="([^"]*)"[^>]*y="([^"]*)"[^>]*z="([^"]*)"') -_OBJECT_WITH_ID = re.compile(r']* id="([0-9]+)"[^>]*>.*?
', re.S) -_OBJECT_BLOCK = re.compile(r"]*>.*?
", re.S) -_COMPONENT = re.compile(r']* objectid="([0-9]+)"[^>]*' - r'(?: transform="([^"]*)")?[^>]*/>') _BUILD_ITEM = re.compile(r']* objectid="([0-9]+)"[^>]*' r' transform="([^"]*)"') @@ -223,34 +219,37 @@ def read_objects(path: str) -> dict: problem the plate does not have. Whether the target agrees is a question for the target, and it is recorded in the project's own documentation. """ - from . import multipart + from . import geometry, multipart with zipfile.ZipFile(path) as archive: names = archive.namelist() - root = _read(archive, ROOT_MODEL) + root_bytes = archive.read(ROOT_MODEL) if ROOT_MODEL in names else b"" + root = root_bytes.decode("utf-8", "ignore") root_scale = _units.mm_per_unit(root) # the unit the root model declares -> mm settings_text = _read(archive, MODEL_SETTINGS) try: project = json.loads(_read(archive, PROJECT_SETTINGS) or "{}") except json.JSONDecodeError: project = {} - meshes: dict[str, list[tuple]] = {} - for name in sorted(n for n in names if n.startswith(OBJECTS_DIR)): + # (part, object id) -> its points / its components. An object id is only unique within + # its own part: object 1 in one file and object 1 in another are different objects. + parts: dict[str, dict] = {} + for name in sorted(n for n in names if n.endswith(".model")): body = _read(archive, name) - part_scale = _units.mm_per_unit(body) # each part's own declared unit - for mesh_id in re.findall(r'', body, re.S) - if block: - meshes[mesh_id] = _scaled(_points(block.group(0)), part_scale) - if not meshes: - # A project whose geometry is still in the root model. - for mesh_id, block in zip(_OBJECT_WITH_ID.findall(root), - _OBJECT_BLOCK.findall(root)): - meshes[mesh_id] = _scaled(_points(block), root_scale) + scale = _units.mm_per_unit(body) # each part's own declared unit + meshes: dict[str, list[tuple]] = {} + components: dict[str, list] = {} + for block in _OBJECT_BLOCK.findall(body): + tag = block[:block.index(">") + 1] + found = re.search(r'\bid="([0-9]+)"', tag) + if not found: + continue + meshes[found.group(1)] = _scaled(_points(block), scale) + components[found.group(1)] = _components_of(block) + parts[name] = {"scale": scale, "meshes": meshes, "components": components} polygon = polygon_of(project) parts_by_object = multipart._parts_by_object(settings_text) if settings_text else {} - components = _components_by_object(root) # One entry per BUILD ITEM, in document order. Keying by object id would keep only the last # transform of an object that several items use, and every instance would then be reported @@ -258,7 +257,7 @@ def read_objects(path: str) -> dict: objects = [] seen_per_object: dict[str, int] = {} totals: dict[str, int] = {} - build = build_items(root) + build = geometry.build_items(root_bytes) if root_bytes else [] for entry in build: totals[entry["object_id"]] = totals.get(entry["object_id"], 0) + 1 for entry in build: @@ -268,15 +267,20 @@ def read_objects(path: str) -> dict: item = _units.scale_translation(parse_transform(transform_text), root_scale) part_ids = [part_id for part_id, _subtype in parts_by_object.get(object_id, [])] roles = dict(parts_by_object.get(object_id, [])) - own_components = components.get(object_id) or [ - (part_id, None) for part_id in part_ids] + item_part = entry["part"] or ROOT_MODEL + explicit = entry["part"] is not None + top = parts.get(item_part, {}).get("components", {}) printable: list[tuple] = [] every: list[tuple] = [] - for mesh_id, component_transform in _leaf_components(own_components, components, - meshes, root_scale): - points = meshes.get(mesh_id) - if points is None: - continue + resolved = True + if explicit and object_id not in parts.get(item_part, {}).get("meshes", {}): + resolved = False # a named part that does not hold the object: not the root's object + entries = top.get(object_id) or [(None, part_id, None) for part_id in part_ids] + leaves, ok = _leaves(entries, parts, item_part, explicit, root_scale if item_part == ROOT_MODEL + else parts.get(item_part, {}).get("scale", 1.0)) + resolved = resolved and ok + for leaf_part, mesh_id, component_transform in leaves: + points = parts[leaf_part]["meshes"][mesh_id] whole = compose(item, component_transform) moved = [apply(whole, point) for point in points] every.extend(moved) @@ -284,15 +288,17 @@ def read_objects(path: str) -> dict: printable.extend(moved) objects.append({ "object_id": object_id, + "part": item_part, "item_index": entry["item_index"], "instance_index": instance_index, "instance_count": totals[object_id], "name": _name_of(settings_text, object_id), "transform": transform_text, "part_ids": part_ids, - "footprint": _footprint(printable), - "footprint_with_helpers": _footprint(every), - "printable_points": [(x, y) for x, y, _z in printable], + "resolved": resolved, + "footprint": _footprint(printable) if resolved else None, + "footprint_with_helpers": _footprint(every) if resolved else None, + "printable_points": [(x, y) for x, y, _z in printable] if resolved else [], }) return {"schema_version": SCHEMA_VERSION, "objects": objects, @@ -300,43 +306,75 @@ def read_objects(path: str) -> dict: "polygon_source": PROJECT_SETTINGS if polygon else None} -_ITEM_TAG = re.compile(r"]*>") -_OBJECTID_ATTR = re.compile(r'\bobjectid="([^"]*)"') -_TRANSFORM_ATTR = re.compile(r'\btransform="([^"]*)"') +_OBJECT_BLOCK = re.compile(r"]*>.*?", re.S) +_COMPONENT_TAG = re.compile(r"]*/>") +_ATTR_OBJECTID = re.compile(r'\bobjectid="([0-9]+)"') +_ATTR_TRANSFORM = re.compile(r'\btransform="([^"]*)"') +_ATTR_PATH = re.compile(r'\b(?:[A-Za-z_][\w.-]*:)?path="([^"]*)"') _MAX_COMPONENT_DEPTH = 16 def build_items(root: str) -> list[dict]: - """Every build item of a root model, in document order. + """Every build item of a root model, in document order (see ``geometry.build_items``). - `item_index` is the ordinal of the item among ALL `` tags, so it matches - `geometry.build_item_dims` and the scene's build index. `transform` is the item's - text, or None when it has none (the identity). + One reader serves the whole engine, so ``item_index`` means the same thing in every + module. ``transform`` is the item's text, or None when it has none (the identity). """ + from . import geometry + + return geometry.build_items(root) + + +def _components_of(block: str) -> list: + """(part path or None, object id, transform text or None) for each component of an object.""" out = [] - for ordinal, tag in enumerate(_ITEM_TAG.findall(root)): - object_id = _OBJECTID_ATTR.search(tag) - transform = _TRANSFORM_ATTR.search(tag) - out.append({"item_index": ordinal, - "object_id": object_id.group(1) if object_id else "", - "transform": transform.group(1) if transform else None}) + for tag in _COMPONENT_TAG.findall(block): + mesh_id = _ATTR_OBJECTID.search(tag) + if not mesh_id: + continue + transform = _ATTR_TRANSFORM.search(tag) + path = _ATTR_PATH.search(tag) + out.append((path.group(1).lstrip("/") if path else None, mesh_id.group(1), + transform.group(1) if transform else None)) return out -def _leaf_components(entries: list, components: dict, meshes: dict, scale: float, - depth: int = 0, outer: tuple | None = None, seen: frozenset = frozenset()): - """(mesh id, transform in mm) for every mesh an object reaches through its components. +def _leaves(entries: list, parts: dict, part: str, explicit: bool, scale: float, + depth: int = 0, outer: tuple | None = None, seen: frozenset = frozenset()): + """``([(part, mesh id, transform in mm)], resolved)`` for every mesh an object reaches. A component may itself be an object made of components, with its own transform; the - transforms compose down the path rather than being applied one after the other. + transforms compose down the path rather than being applied one after the other. A + component that names a part is looked up in that part and nowhere else: if it is not there + the object is NOT resolved, and nothing is measured from some other part's object that + happens to share the id. """ - for mesh_id, text in entries: + found: list = [] + ok = True + for cpart, oid, text in entries: + here_part = cpart if cpart is not None else part + strict = cpart is not None or explicit + holder = parts.get(here_part) + if holder is None or oid not in holder["meshes"]: + if strict: + ok = False + continue + # no p:path: the object is expected in this part; otherwise in the only part that has it + owners = [name for name, held in parts.items() if oid in held["meshes"]] + if len(owners) != 1: + continue # absent (nothing to measure) or ambiguous (not guessed) + here_part, holder = owners[0], parts[owners[0]] here = compose(outer, _units.scale_translation(parse_transform(text), scale)) - if meshes.get(mesh_id): - yield mesh_id, here - elif mesh_id in components and mesh_id not in seen and depth < _MAX_COMPONENT_DEPTH: - yield from _leaf_components(components[mesh_id], components, meshes, scale, - depth + 1, here, seen | {mesh_id}) + points = holder["meshes"][oid] + if points: + found.append((here_part, oid, here)) + elif holder["components"].get(oid) and (here_part, oid) not in seen \ + and depth < _MAX_COMPONENT_DEPTH: + more, more_ok = _leaves(holder["components"][oid], parts, here_part, False, + holder["scale"], depth + 1, here, seen | {(here_part, oid)}) + found.extend(more) + ok = ok and more_ok + return found, ok def _scaled(points: list[tuple], factor: float) -> list[tuple]: @@ -346,21 +384,6 @@ def _scaled(points: list[tuple], factor: float) -> list[tuple]: return [(x * factor, y * factor, z * factor) for x, y, z in points] -def _components_by_object(root: str) -> dict: - out: dict[str, list] = {} - for match in re.finditer( - r']*>\s*(.*?)', root, re.S): - entries = [] - for component in re.findall(r"]*/>", match.group(2)): - mesh_id = re.search(r'objectid="([0-9]+)"', component) - transform = re.search(r'transform="([^"]*)"', component) - if mesh_id: - entries.append((mesh_id.group(1), - transform.group(1) if transform else None)) - out[match.group(1)] = entries - return out - - def _name_of(settings: str, object_id: str) -> str | None: match = re.search(rf'(.*?)(?=)', settings, re.S) diff --git a/backend/snapstudio_core/plate_placement.py b/backend/snapstudio_core/plate_placement.py index a9f4a665..6f797d48 100644 --- a/backend/snapstudio_core/plate_placement.py +++ b/backend/snapstudio_core/plate_placement.py @@ -114,14 +114,14 @@ def _overhang(bounds: dict, bed: dict) -> dict: def _instance_list(rows: list[dict]) -> str: - """'object 1 instance 1 of 2, object 3' — which placed items a sentence is about.""" + """'object 1, instance 1 of 2; object 3' — which placed items a sentence is about.""" named = [] for row in rows: text = f"object {row['object_id']}" if row.get("instance_count", 1) > 1: - text += f" instance {row['instance_index'] + 1} of {row['instance_count']}" + text += f", instance {row['instance_index'] + 1} of {row['instance_count']}" named.append(text) - return ", ".join(named) + return "; ".join(named) def _edges_text(over: dict) -> str: @@ -297,6 +297,9 @@ def _group_items_by_plate(items: list[dict], plates: list[dict]): totals: dict[str, int] = {} for item in items: totals[str(item["object_id"])] = totals.get(str(item["object_id"]), 0) + 1 + parts_of: dict[str, set] = {} + for item in items: + parts_of.setdefault(str(item["object_id"]), set()).add(item.get("part")) seen: dict[str, int] = {} grouped: dict[int, list[dict]] = {} unresolved: list[dict] = [] @@ -305,7 +308,9 @@ def _group_items_by_plate(items: list[dict], plates: list[dict]): ordinal = seen.get(oid, 0) seen[oid] = ordinal + 1 owners: set = set() - ambiguous = False + # plate records name a BARE object id: when build items in different parts share it, a + # record cannot prove which one it means + ambiguous = len(parts_of[oid]) > 1 for plate in plates: members = plate.get("members") if members is None: # a plate described by object ids alone @@ -375,8 +380,8 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d traits = project_traits.extract(path) plate_count = (traits.get("plate_count") or {}).get("value") or 1 - items = geometry.build_item_dims(path) - items = _printable_only(path, items) + measured, geometry_unresolved = geometry.measure_items(path) + items = _printable_only(path, measured) if not items: return _unavailable( "Studio could not read where the objects sit in this project, so it " @@ -385,15 +390,20 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d source_bed = _source_bed(tm) multi_plate = bool(plate_count and plate_count > 1) - unresolved: list[dict] = [] + # Instances Studio cannot judge are listed, counted and never treated as harmless: a build item + # whose object is not where the file says it is, or that no plate record places. + unresolved: list[dict] = [dict(entry, reason="Studio could not find this item's object: " + + entry["reason"]) for entry in geometry_unresolved] grouped: dict[int, list[dict]] = {} plate_fit: list[dict] = [] if multi_plate: plates = _plates_from_model_settings(tm) grouped, unresolved_items = _group_items_by_plate(items, plates) - unresolved = [{"object_id": i["object_id"], "item_index": i.get("item_index")} - for i in unresolved_items] + unresolved += [{"object_id": i["object_id"], "item_index": i.get("item_index"), + "reason": "no plate record places this instance"} + for i in unresolved_items] plate_fit = _plate_fit(grouped, target, whose) + not_judged = len(unresolved) totals: dict[str, int] = {} for item in items: @@ -446,45 +456,61 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d # Studio never repositions a multi-plate project: the plate spacing is not in # the file, so any move would be a guess. - fixable = (not multi_plate) and bool(off_plate) and would_fit + # Nor is a move offered while some instance could not be judged: the move would carry it + # somewhere nobody has checked. + fixable = (not multi_plate) and bool(off_plate) and would_fit and not not_judged # A repeated object is one object at several places; the sentence counts the places, so the # instance that is off the plate is never averaged away by the one that is not. repeated = any(r["instance_count"] > 1 for r in reported) + k = len(off_plate) if repeated: - count_text = (f"{len(off_plate)} of {len(reported)} placed instance(s) " - f"({_instance_list(off_plate)})") + count_text = f"{k} of {len(reported)} placed instances ({_instance_list(off_plate)})" else: - count_text = f"{len(off_plate)} object(s)" + count_text = f"{k} object" + ("" if k == 1 else "s") + fall = "falls" if k == 1 else "fall" + unjudged = "" + if not_judged: + unjudged = (f" {not_judged} placed instance{'s' if not_judged != 1 else ''} could not be " + "judged, so Studio cannot say whether everything is on the plate.") if multi_plate and not off_plate: - summary = (f"All {len(plate_fit)} plates fit {whose} printable area. Studio does " - "not reposition multi-plate projects — open the project in Snapmaker " - "Orca to arrange the plates.") + if plate_fit: + summary = (f"None of the plates Studio could assign is too big for {whose} printable area." + if not_judged else + f"All {len(plate_fit)} plate{'s' if len(plate_fit) != 1 else ''} fit " + f"{whose} printable area.") + else: + summary = "" + summary = (summary + unjudged + " Studio does not reposition multi-plate projects — open " + "the project in Snapmaker Orca to arrange the plates.").strip() elif multi_plate and oversized_plates: names = ", ".join(str(p["plate"]) for p in oversized_plates) summary = (f"Plate {names} does not fit {whose} printable area: " - f"{oversized_plates[0]['reason']}. Scale it down or split it. " - + MULTI_PLATE_REFUSAL) + f"{oversized_plates[0]['reason']}. Scale it down or split it." + + unjudged + " " + MULTI_PLATE_REFUSAL) elif multi_plate: - summary = MULTI_PLATE_REFUSAL + summary = (MULTI_PLATE_REFUSAL + unjudged).strip() elif not off_plate: - summary = (f"Every placed instance sits inside {whose} printable area." - if repeated else - f"Every object sits inside {whose} printable area." - if len(reported) > 1 else - f"The object sits inside {whose} printable area.") + if not_judged: + summary = f"Every placed instance Studio could measure sits inside {whose} printable area.{unjudged}" + else: + summary = (f"Every placed instance sits inside {whose} printable area." + if repeated else + f"Every object sits inside {whose} printable area." + if len(reported) > 1 else + f"The object sits inside {whose} printable area.") elif too_wide: - summary = (f"{count_text} fall outside {whose} plate, and the " + summary = (f"{count_text} {fall} outside {whose} plate, and the " "whole arrangement is wider than the plate — moving it cannot fix " - "this. Scale it down or split it across plates.") - elif would_fit: - summary = (f"{count_text} fall outside {whose} plate, but the " + "this. Scale it down or split it across plates." + unjudged) + elif would_fit and not not_judged: + summary = (f"{count_text} {fall} outside {whose} plate, but the " "whole arrangement fits — moving it as one piece brings everything " "back on, keeping the creator's layout, rotation and scale.") else: - summary = (f"{count_text} fall outside {whose} plate and one " - "move will not fix it. Open it in Snapmaker Orca and use Arrange.") + summary = (f"{count_text} {fall} outside {whose} plate and one " + "move will not fix it. Open it in Snapmaker Orca and use Arrange." + unjudged) return { "schema_version": SCHEMA_VERSION, @@ -501,6 +527,7 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d "plate_fit": plate_fit, "oversized_plates": oversized_plates, "unresolved_objects": unresolved, + "not_judged": not_judged, "fixable": fixable, "summary": summary, } @@ -586,6 +613,15 @@ def _unique_output(src: Path, out_dir: Path | None) -> Path: return out +#: The numbers a build item carries are written to six significant digits, so two items moved by the +#: same amount can differ by a few thousandths of a millimetre. Anything within this is "the same". +MOVE_TOLERANCE_MM = 0.01 + + +def _without_transform(attributes: dict) -> dict: + return {key: value for key, value in attributes.items() if key != "transform"} + + def verify_only_placement_moved(source: str, moved: str) -> dict: """Prove the copy differs from the original in where the objects sit, and in nothing else. @@ -596,7 +632,7 @@ def verify_only_placement_moved(source: str, moved: str) -> dict: """ import zipfile - from . import multipart, painted_color, placement + from . import geometry, multipart, painted_color, placement checks: list[dict] = [] @@ -620,13 +656,19 @@ def check(name: str, ok: bool, detail: str = "") -> bool: check("the root model's geometry and components are unchanged", strip(root_before) == strip(root_after)) - # Per build item, in order: an object used by two items is two items to compare. - items_before = placement.build_items(root_before) - items_after = placement.build_items(root_after) - check("every object is still placed", - [i["object_id"] for i in items_before] == [i["object_id"] for i in items_after]) - - deltas = set() + # Per build item, in order: an object used by two items is two items to compare. Everything + # about an item except where it sits (its object, its p:path, its printable flag, anything + # else it carries) must be unchanged, and its turn and scale with it. + items_before = geometry.build_items(root_before) + items_after = geometry.build_items(root_after) + same = len(items_before) == len(items_after) and all( + _without_transform(one["attributes"]) == _without_transform(two["attributes"]) + for one, two in zip(items_before, items_after)) + check("every build item is still there, unchanged apart from where it sits", same, + f"{len(items_before)} item(s) before, {len(items_after)} after") + + root_scale = _units.mm_per_unit(root_before) + deltas = [] same_basis = len(items_before) == len(items_after) for before_item, after_item in zip(items_before, items_after): # an item with no transform is at the identity @@ -640,12 +682,17 @@ def check(name: str, ok: bool, detail: str = "") -> bool: if one[0] != two[0] or one[1] != two[1] or one[2] != two[2]: same_basis = False # a rotation or a scale crept in break - deltas.add((round(two[3][0] - one[3][0], 4), round(two[3][1] - one[3][1], 4), - round(two[3][2] - one[3][2], 4))) + deltas.append(tuple((two[3][axis] - one[3][axis]) * root_scale for axis in range(3))) check("nothing was rotated or rescaled", same_basis) + # What "moved" means: every item's translation changed by the SAME non-zero amount in X and Y + # (to within what the written numbers can carry) and not at all in Z. A copy that moved one + # instance, or none, is not a moved copy. + uniform = bool(deltas) and all( + all(abs(d[axis] - deltas[0][axis]) <= MOVE_TOLERANCE_MM for axis in range(3)) for d in deltas) + moved_xy = bool(deltas) and (abs(deltas[0][0]) > MOVE_TOLERANCE_MM or abs(deltas[0][1]) > MOVE_TOLERANCE_MM) check("every object moved by the same amount, in X and Y only", - len(deltas) == 1 and abs(next(iter(deltas))[2]) <= 1e-6 if deltas else False, - str(sorted(deltas))) + uniform and moved_xy and abs(deltas[0][2]) <= MOVE_TOLERANCE_MM, + str([tuple(round(v, 4) for v in d) for d in deltas])) structure = multipart.validate_archive(ThreeMF.open(moved)) check("the moved copy still describes itself consistently", @@ -657,7 +704,9 @@ def check(name: str, ok: bool, detail: str = "") -> bool: one_paint.get("painted_triangle_count") == two_paint.get("painted_triangle_count") and one_paint.get("slots_referenced") == two_paint.get("slots_referenced")) - return {"passed": all(entry["pass"] for entry in checks), "checks": checks} + return {"passed": all(entry["pass"] for entry in checks), "checks": checks, + "delta_mm": ({"x": round(deltas[0][0], 4), "y": round(deltas[0][1], 4)} + if deltas and uniform else None)} def prepare_placed_copy(path: str, out_dir: str | None = None, @@ -722,6 +771,13 @@ def prepare_placed_copy(path: str, out_dir: str | None = None, # actually written — not against what the code intended to write. after = assess(str(out), bed=bed) proof = verify_only_placement_moved(str(src), str(out)) + delta = proof.get("delta_mm") + if delta is not None: + # the copy must have moved by what was asked, not merely by something uniform + asked = abs(delta["x"] - offset["x"]) <= MOVE_TOLERANCE_MM and abs(delta["y"] - offset["y"]) <= MOVE_TOLERANCE_MM + proof["checks"].append({"check": "the move is the one that was asked for", "pass": asked, + "detail": f"moved {delta}, asked {offset}"}) + proof["passed"] = proof["passed"] and asked if not proof["passed"]: out.unlink(missing_ok=True) return { diff --git a/backend/tests/test_part_paths_and_move_verifier.py b/backend/tests/test_part_paths_and_move_verifier.py new file mode 100644 index 00000000..410c646f --- /dev/null +++ b/backend/tests/test_part_paths_and_move_verifier.py @@ -0,0 +1,180 @@ +"""Build items and components that name a part (p:path), plate records that cannot place an instance, +and the verifier that proves a moved copy changed only where things sit (issue 93 repair round).""" +from __future__ import annotations + +import zipfile + +from snapstudio_core import geometry, placement, plate_placement as pp +from tests import scene_fixtures as fx + +SETTINGS = "Metadata/model_settings.config" +A, B = "3D/Objects/a.model", "3D/Objects/b.model" +MOVE = "every object moved by the same amount, in X and Y only" +KEPT = "every build item is still there, unchanged apart from where it sits" + + +def _rows(report): + return {row["item_index"]: row for row in report["items"]} + + +def _two_parts(tmp_path, items, objects=(), settings=None, a_size=10, b_size=50, name="ext.3mf"): + extra = {A: fx.sub_model_xml([fx.cube_object("1", a_size)]), + B: fx.sub_model_xml([fx.cube_object("1", b_size)])} + if settings: + extra[SETTINGS] = settings + return fx.three_mf(tmp_path / name, fx.model_xml(list(objects), items), extra) + + +# --- keyed by (part, object id) ------------------------------------------------------------------------ + +def test_object_one_in_two_parts_stays_two_objects(tmp_path): + path = _two_parts(tmp_path, [("1", fx.tf(100, 100, 0), "/" + A), ("1", fx.tf(500, 100, 0), "/" + B)]) + report = pp.assess(str(path)) + rows = _rows(report) + assert (rows[0]["bounds_mm"]["min"][0], rows[0]["bounds_mm"]["max"][0]) == (100.0, 110.0) + assert (rows[1]["bounds_mm"]["min"][0], rows[1]["bounds_mm"]["max"][0]) == (500.0, 550.0) + assert [row["item_index"] for row in report["off_plate"]] == [1] + + +def test_the_engine_reads_each_part_object_separately(tmp_path): + settings = fx.model_settings_xml({"1": [("1", "normal_part")]}, [(1, [("1", 0), ("1", 1)])]) + path = _two_parts(tmp_path, [("1", fx.tf(100, 100, 0), "/" + A), ("1", fx.tf(500, 100, 0), "/" + B)], + settings=settings) + objects = placement.read_objects(str(path))["objects"] + assert [(o["part"], o["footprint"]["min_x"], o["footprint"]["max_x"]) for o in objects] == [ + (A, 100.0, 110.0), (B, 500.0, 550.0)] + + +def test_component_references_across_files_are_keyed_by_part(tmp_path): + comp = fx.composite_object("5", [("1", "/" + A, None), ("1", "/" + B, fx.tf(100, 0, 0))]) + path = _two_parts(tmp_path, [("5", fx.tf(20, 20, 0))], objects=[comp], name="comp.3mf") + [item] = pp.assess(str(path))["items"] + # a.model's cube is 0..10; b.model's is 0..50 moved 100 by its component transform + assert (item["bounds_mm"]["min"][0], item["bounds_mm"]["max"][0]) == (20.0, 170.0) + [obj] = placement.read_objects(str(path))["objects"] + assert (obj["footprint"]["min_x"], obj["footprint"]["max_x"]) == (20.0, 170.0) + + +def test_an_unresolvable_part_path_is_unresolved_not_the_root_object(tmp_path): + # a decoy object 1 in the root model must NOT stand in for the missing part + 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")]) + report = pp.assess(str(fx.three_mf(tmp_path / "m.3mf", root))) + assert [r["item_index"] for r in report["items"]] == [0] + assert [u["item_index"] for u in report["unresolved_objects"]] == [1] and report["not_judged"] == 1 + assert "1 placed instance could not be judged" in report["summary"] + assert report["summary"].startswith("Every placed instance Studio could measure") and report["fixable"] is False + only = fx.three_mf(tmp_path / "o.3mf", fx.model_xml( + [fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0), "/3D/Objects/missing.model")])) + assert pp.assess(str(only))["available"] is False + assert [u["item_index"] for u in geometry.measure_items(str(only))[1]] == [0] + + +def test_item_numbering_ignores_commented_out_items(tmp_path): + root = fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(500, 100, 0)), ("1", fx.tf(100, 100, 0))]) + root = root.replace("", '') + path = fx.three_mf(tmp_path / "c.3mf", root) + assert [i["item_index"] for i in geometry.build_item_dims(str(path))] == [0, 1] + assert [o["item_index"] for o in placement.read_objects(str(path))["objects"]] == [0, 1] + + +# --- instances Studio cannot place on a plate are never harmless ------------------------------------------ + +def _plated(tmp_path, items, plates, objects=None, name="pl.3mf"): + objects = objects or {"1": [("1", "normal_part")]} + settings = fx.model_settings_xml(objects, plates) + return fx.three_mf(tmp_path / name, fx.model_xml([fx.cube_object(o, 10) for o in objects], items), + {SETTINGS: settings}) + + +def test_instances_with_no_instance_id_are_not_judged_and_never_reported_as_fitting(tmp_path): + path = _plated(tmp_path, [("1", fx.tf(50, 50, 0)), ("1", fx.tf(400, 50, 0))], + [(1, [("1", None)]), (2, [("1", None)])]) + report = pp.assess(str(path)) + assert report["not_judged"] == 2 and len(report["unresolved_objects"]) == 2 + assert "2 placed instances could not be judged" in report["summary"] + assert "All" not in report["summary"] and "fit" not in report["summary"].lower().replace("fits", "") + assert report["plate_fit"] == [] and report["fixable"] is False + + +def test_some_instances_judged_and_some_not_is_said_plainly(tmp_path): + objects = {"1": [("1", "normal_part")], "2": [("2", "normal_part")]} + path = _plated(tmp_path, [("1", fx.tf(50, 50, 0)), ("1", fx.tf(60, 50, 0)), ("2", fx.tf(400, 50, 0))], + [(1, [("1", None)]), (2, [("2", 0)])], objects=objects) + report = pp.assess(str(path)) + assert report["not_judged"] == 2 and "All" not in report["summary"] + assert report["summary"].startswith("None of the plates Studio could assign is too big") + assert "2 placed instances could not be judged" in report["summary"] + + +# --- the verifier: negative tests -------------------------------------------------------------------------- + +def _mutate(src, dest, edit): + with zipfile.ZipFile(src) as archive: + entries = {name: archive.read(name) for name in archive.namelist()} + entries["3D/3dmodel.model"] = edit(entries["3D/3dmodel.model"].decode("utf-8")).encode("utf-8") + return str(fx.write_zip(dest, entries)) + + +def _source(tmp_path): + return str(_two_parts(tmp_path, [("1", fx.tf(-5, 100, 0), "/" + A), ("1", fx.tf(100, 100, 0), "/" + B)], + name="v.3mf")) + + +def _moved(text): + return text.replace("1 -5 100 0", "1 1 100 0").replace("1 100 100 0", "1 106 100 0") + + +def _checks(result): + return {c["check"]: c["pass"] for c in result["checks"]} + + +def test_a_faithful_uniform_move_passes_the_verifier(tmp_path): + src = _source(tmp_path) + result = pp.verify_only_placement_moved(src, _mutate(src, tmp_path / "ok.3mf", _moved)) + assert result["passed"] is True and result["delta_mm"] == {"x": 6.0, "y": 0.0} + + +def test_switching_an_items_part_path_fails_the_verifier(tmp_path): + src = _source(tmp_path) + + def swap(text): + return _moved(text).replace("/" + A, "/@").replace("/" + B, "/" + A).replace("/@", "/" + B) + + result = pp.verify_only_placement_moved(src, _mutate(src, tmp_path / "swap.3mf", swap)) + assert result["passed"] is False and _checks(result)[KEPT] is False + + +def test_changing_another_item_attribute_fails_the_verifier(tmp_path): + src = _source(tmp_path) + result = pp.verify_only_placement_moved( + src, _mutate(src, tmp_path / "attr.3mf", lambda t: _moved(t).replace(" { const verdict = placementVerdict(check({ items: [first, { ...first, item_index: 2 }], item_count: 2 })); expect(verdict.headline).toBe("Every placed instance sits inside the U1's printable area."); }); + + it("never shows the ok headline while an instance could not be judged", () => { + const verdict = placementVerdict( + check({ items: [first], item_count: 1, off_plate: [], not_judged: 2, unresolved_objects: [{ object_id: "1" }, { object_id: "1" }] }), + ); + expect(verdict.tone).toBe("blocked"); + expect(verdict.headline).toBe( + "2 placed instances could not be judged, so Studio cannot say whether everything is inside the U1's printable area.", + ); + expect(verdict.canFix).toBe(false); + }); + + it("explains unjudged instances even when nothing is off the plate", () => { + const reason = blockedReason(check({ off_plate: [], not_judged: 1, unresolved_objects: [{ object_id: "1" }] })); + expect(reason).toContain("could not be found or are not listed on any plate"); + }); }); diff --git a/desktop/src/lib/placement.ts b/desktop/src/lib/placement.ts index c529af78..46938dc0 100644 --- a/desktop/src/lib/placement.ts +++ b/desktop/src/lib/placement.ts @@ -31,6 +31,15 @@ export function placementVerdict(check: PlacementCheck | null): PlacementVerdict }; } const repeated = check.items.some((entry) => (entry.instance_count ?? 1) > 1); + const unjudged = unjudgedCount(check); + if (check.off_plate.length === 0 && unjudged > 0) { + // Not "inside": something could not be judged at all. + return { + tone: "blocked", + headline: `${unjudged} placed ${unjudged === 1 ? "instance" : "instances"} could not be judged, so Studio cannot say whether everything is inside the U1's printable area.`, + canFix: false, + }; + } if (check.off_plate.length === 0) { const many = (check.item_count ?? 0) > 1; return { @@ -66,6 +75,11 @@ export function itemLabel(item: PlacementItem): string { return `Object ${item.object_id} · instance ${(item.instance_index ?? 0) + 1} of ${total}`; } +/** Placed instances Studio could not judge. */ +export function unjudgedCount(check: PlacementCheck): number { + return check.not_judged ?? check.unresolved_objects?.length ?? 0; +} + /** How far off, and which way — as a sentence rather than four numbers. */ export function overhangText(item: PlacementItem): string { const parts = (["left", "right", "front", "back"] as const) @@ -78,10 +92,11 @@ export function overhangText(item: PlacementItem): string { /** Why Studio is refusing to move things, when it is. */ export function blockedReason(check: PlacementCheck | null): string | null { - if (!check?.available || check.fixable || check.off_plate.length === 0) return null; - if (check.unresolved_objects?.length) { - return "Some objects are not listed on any plate, so Studio cannot tell where they belong."; + if (!check?.available) return null; + if (unjudgedCount(check) > 0) { + return "Some placed instances could not be found or are not listed on any plate, so Studio cannot tell whether they are on the plate."; } + if (check.fixable || check.off_plate.length === 0) return null; if (check.skipped_plates?.length) { return "At least one plate will not fit a U1 plate. Studio moves every plate or none."; } diff --git a/docs/innovation-fund/JUDGE_WALKTHROUGH.md b/docs/innovation-fund/JUDGE_WALKTHROUGH.md index aaa4587e..1e5df086 100644 --- a/docs/innovation-fund/JUDGE_WALKTHROUGH.md +++ b/docs/innovation-fund/JUDGE_WALKTHROUGH.md @@ -71,7 +71,7 @@ u1convert placement ../examples/demo_offplate_foreign.3mf ``` ``` -1 object(s) fall outside the U1's plate, but the whole arrangement fits — +1 object falls outside the U1's plate, but the whole arrangement fits — moving it as one piece brings everything back on, keeping the creator's layout, rotation and scale. ``` From 5481f6ff22d48b0046e0dd75e04b17879e68ebde Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Sat, 10 Oct 2026 02:12:58 -0400 Subject: [PATCH 03/12] fix(placement): bound the footprint read; number instances per (part, object) (#93 repair 2) - placement.read_objects reads through the bounded ThreeMF reader and counts vertices while collecting (read and moved), so a huge or highly compressed project is refused early instead of materialised; assess then reports it as unavailable, never as fine. Footprints are kept as a running extent; per-vertex outlines only when asked for. - Instance index/count (engine and plate check) count uses of the same (part, object id), so two parts that both hold object 1 are not "instance 1 of 2". Verified: backend pytest 3192 passed / 49 skipped. Co-Authored-By: Claude Sonnet 5.5 --- backend/snapstudio_core/placement.py | 163 +++++++++++++----- backend/snapstudio_core/plate_placement.py | 10 +- .../test_part_paths_and_move_verifier.py | 42 +++++ 3 files changed, 166 insertions(+), 49 deletions(-) diff --git a/backend/snapstudio_core/placement.py b/backend/snapstudio_core/placement.py index 31a5ec8a..d5eda019 100644 --- a/backend/snapstudio_core/placement.py +++ b/backend/snapstudio_core/placement.py @@ -210,7 +210,7 @@ def _read(archive: zipfile.ZipFile, name: str) -> str: return "" -def read_objects(path: str) -> dict: +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 @@ -220,33 +220,43 @@ def read_objects(path: str) -> dict: the target, and it is recorded in the project's own documentation. """ from . import geometry, multipart - - with zipfile.ZipFile(path) as archive: - names = archive.namelist() - root_bytes = archive.read(ROOT_MODEL) if ROOT_MODEL in names else b"" - root = root_bytes.decode("utf-8", "ignore") - root_scale = _units.mm_per_unit(root) # the unit the root model declares -> mm - settings_text = _read(archive, MODEL_SETTINGS) - try: - project = json.loads(_read(archive, PROJECT_SETTINGS) or "{}") - except json.JSONDecodeError: - project = {} - # (part, object id) -> its points / its components. An object id is only unique within - # its own part: object 1 in one file and object 1 in another are different objects. - parts: dict[str, dict] = {} - for name in sorted(n for n in names if n.endswith(".model")): - body = _read(archive, name) - scale = _units.mm_per_unit(body) # each part's own declared unit - meshes: dict[str, list[tuple]] = {} - components: dict[str, list] = {} - for block in _OBJECT_BLOCK.findall(body): - tag = block[:block.index(">") + 1] - found = re.search(r'\bid="([0-9]+)"', tag) - if not found: - continue - meshes[found.group(1)] = _scaled(_points(block), scale) - components[found.group(1)] = _components_of(block) - parts[name] = {"scale": scale, "meshes": meshes, "components": components} + from .container import ThreeMF + + # The same bounded reader and limits as the rest of the engine: a hostile or merely huge archive + # is refused (with an exception the callers turn into "could not measure"), never read whole. + tm = ThreeMF.open(path) + names = tm.list_parts() + if sum(len(tm.read_part(n)) for n in names if n.endswith(".model")) > geometry._MAX_BYTES: + raise TooLargeToMeasure("the model data is larger than Studio will measure") + budget = _Budget(geometry._MAX_VERTS) + + def _text(name): + return tm.read_part(name).decode("utf-8", "ignore") if name in names else "" + + root_bytes = tm.read_part(ROOT_MODEL) if ROOT_MODEL in names else b"" + root = root_bytes.decode("utf-8", "ignore") + root_scale = _units.mm_per_unit(root) # the unit the root model declares -> mm + settings_text = _text(MODEL_SETTINGS) + try: + project = json.loads(_text(PROJECT_SETTINGS) or "{}") + except json.JSONDecodeError: + project = {} + # (part, object id) -> its points / its components. An object id is only unique within + # its own part: object 1 in one file and object 1 in another are different objects. + parts: dict[str, dict] = {} + for name in sorted(n for n in names if n.endswith(".model")): + body = _text(name) + scale = _units.mm_per_unit(body) # each part's own declared unit + meshes: dict[str, list[tuple]] = {} + components: dict[str, list] = {} + for block in _OBJECT_BLOCK.findall(body): + tag = block[:block.index(">") + 1] + found = re.search(r'\bid="([0-9]+)"', tag) + if not found: + continue + meshes[found.group(1)] = _scaled(_points(block, budget), scale) + components[found.group(1)] = _components_of(block) + parts[name] = {"scale": scale, "meshes": meshes, "components": components} polygon = polygon_of(project) parts_by_object = multipart._parts_by_object(settings_text) if settings_text else {} @@ -255,23 +265,26 @@ def read_objects(path: str) -> dict: # transform of an object that several items use, and every instance would then be reported # at the last one's position. objects = [] - seen_per_object: dict[str, int] = {} - totals: dict[str, int] = {} + seen_per_object: dict[tuple, int] = {} + totals: dict[tuple, int] = {} build = geometry.build_items(root_bytes) if root_bytes else [] + # An object id is only unique within its part: instance 1 of 2 means two uses of the SAME object. for entry in build: - totals[entry["object_id"]] = totals.get(entry["object_id"], 0) + 1 + who = (entry["part"] or ROOT_MODEL, entry["object_id"]) + totals[who] = totals.get(who, 0) + 1 for entry in build: object_id, transform_text = entry["object_id"], entry["transform"] - instance_index = seen_per_object.get(object_id, 0) - seen_per_object[object_id] = instance_index + 1 + who = (entry["part"] or ROOT_MODEL, object_id) + instance_index = seen_per_object.get(who, 0) + seen_per_object[who] = instance_index + 1 item = _units.scale_translation(parse_transform(transform_text), root_scale) part_ids = [part_id for part_id, _subtype in parts_by_object.get(object_id, [])] roles = dict(parts_by_object.get(object_id, [])) item_part = entry["part"] or ROOT_MODEL explicit = entry["part"] is not None top = parts.get(item_part, {}).get("components", {}) - printable: list[tuple] = [] - every: list[tuple] = [] + printable = _Extent(keep_points) + every = _Extent(False) resolved = True if explicit and object_id not in parts.get(item_part, {}).get("meshes", {}): resolved = False # a named part that does not hold the object: not the root's object @@ -281,24 +294,27 @@ def read_objects(path: str) -> dict: resolved = resolved and ok for leaf_part, mesh_id, component_transform in leaves: points = parts[leaf_part]["meshes"][mesh_id] + budget.take_moved(len(points)) whole = compose(item, component_transform) - moved = [apply(whole, point) for point in points] - every.extend(moved) - if roles.get(mesh_id, "normal_part") == "normal_part": - printable.extend(moved) + is_part = roles.get(mesh_id, "normal_part") == "normal_part" + for point in points: + moved = apply(whole, point) + every.add(moved) + if is_part: + printable.add(moved) objects.append({ "object_id": object_id, "part": item_part, "item_index": entry["item_index"], "instance_index": instance_index, - "instance_count": totals[object_id], + "instance_count": totals[who], "name": _name_of(settings_text, object_id), "transform": transform_text, "part_ids": part_ids, "resolved": resolved, - "footprint": _footprint(printable) if resolved else None, - "footprint_with_helpers": _footprint(every) if resolved else None, - "printable_points": [(x, y) for x, y, _z in printable] if resolved else [], + "footprint": printable.footprint() if resolved else None, + "footprint_with_helpers": every.footprint() if resolved else None, + "printable_points": printable.xy() if resolved else [], }) return {"schema_version": SCHEMA_VERSION, "objects": objects, @@ -393,8 +409,65 @@ def _name_of(settings: str, object_id: str) -> str | None: return found.group(1) if found else None -def _points(block: str) -> list[tuple]: - return [(float(x), float(y), float(z)) for x, y, z in _VERTEX.findall(block)] +class TooLargeToMeasure(Exception): + """A project holds more geometry than Studio will measure.""" + + +class _Budget: + """Vertices read from meshes plus vertices moved onto the plate, counted while they are collected, + so a huge file stops early instead of being materialised and then dropped.""" + + def __init__(self, limit: int) -> None: + self.limit, self.read, self.moved = limit, 0, 0 + + def take_read(self, n: int = 1) -> None: + self.read += n + if self.read > self.limit: + raise TooLargeToMeasure("too many vertices to measure") + + def take_moved(self, n: int) -> None: + self.moved += n + if self.moved > self.limit: + raise TooLargeToMeasure("too many vertices to measure") + + +def _points(block: str, budget: "_Budget | None" = None) -> list[tuple]: + out = [] + for x, y, z in _VERTEX.findall(block): + if budget is not None: + budget.take_read() + out.append((float(x), float(y), float(z))) + return out + + +class _Extent: + """The footprint of the points added to it, kept as a running min/max (and, only when asked, the + points themselves).""" + + def __init__(self, keep: bool) -> None: + self.keep, self.points = keep, [] + self.lo_x = self.lo_y = float("inf") + self.hi_x = self.hi_y = float("-inf") + self.n = 0 + + def add(self, point: tuple) -> None: + x, y = point[0], point[1] + if x < self.lo_x: self.lo_x = x + if x > self.hi_x: self.hi_x = x + if y < self.lo_y: self.lo_y = y + if y > self.hi_y: self.hi_y = y + self.n += 1 + if self.keep: + self.points.append((x, y)) + + def xy(self) -> list[tuple]: + return list(self.points) + + def footprint(self) -> dict | None: + if not self.n: + return None + return {"min_x": self.lo_x, "min_y": self.lo_y, "max_x": self.hi_x, "max_y": self.hi_y, + "width": self.hi_x - self.lo_x, "depth": self.hi_y - self.lo_y} def _footprint(points: list[tuple]) -> dict | None: diff --git a/backend/snapstudio_core/plate_placement.py b/backend/snapstudio_core/plate_placement.py index 6f797d48..1d459460 100644 --- a/backend/snapstudio_core/plate_placement.py +++ b/backend/snapstudio_core/plate_placement.py @@ -405,14 +405,16 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d plate_fit = _plate_fit(grouped, target, whose) not_judged = len(unresolved) - totals: dict[str, int] = {} + # instance numbers count uses of the SAME object: an id is only unique within its part + totals: dict[tuple, int] = {} for item in items: - totals[str(item["object_id"])] = totals.get(str(item["object_id"]), 0) + 1 - seen: dict[str, int] = {} + who = (item.get("part"), str(item["object_id"])) + totals[who] = totals.get(who, 0) + 1 + seen: dict[tuple, int] = {} reported = [] for item in items: lo, hi = item["bounds"]["min"], item["bounds"]["max"] - oid = str(item["object_id"]) + oid = (item.get("part"), str(item["object_id"])) instance_index = seen.get(oid, 0) seen[oid] = instance_index + 1 if multi_plate: diff --git a/backend/tests/test_part_paths_and_move_verifier.py b/backend/tests/test_part_paths_and_move_verifier.py index 410c646f..bc9593e9 100644 --- a/backend/tests/test_part_paths_and_move_verifier.py +++ b/backend/tests/test_part_paths_and_move_verifier.py @@ -178,3 +178,45 @@ def lying(a, b): monkeypatch.setattr(pp, "verify_only_placement_moved", lying) result = pp.prepare_placed_copy(str(src), out_dir=str(tmp_path / "out")) assert result["ok"] is False and "was not kept" in result["reason"] + + +# --- bounded reading, and instance numbers that mean the same object ------------------------------------- + +def test_two_parts_sharing_an_object_id_are_not_instances_of_each_other(tmp_path): + path = _two_parts(tmp_path, [("1", fx.tf(100, 100, 0), "/" + A), ("1", fx.tf(500, 100, 0), "/" + B)], name="lbl.3mf") + report = pp.assess(str(path)) + assert [(r["instance_index"], r["instance_count"]) for r in report["items"]] == [(0, 1), (0, 1)] + assert "instance" not in report["summary"] + assert [(o["instance_index"], o["instance_count"]) for o in placement.read_objects(str(path))["objects"]] == [(0, 1), (0, 1)] + + +def test_too_many_vertices_is_refused_while_collecting_never_reported_fine(tmp_path, monkeypatch): + settings = fx.model_settings_xml({"1": [("1", "normal_part")]}, [(1, [("1", 0), ("1", 1)])]) + path = str(fx.three_mf(tmp_path / "v.3mf", fx.model_xml( + [fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0)), ("1", fx.tf(500, 100, 0))]), {SETTINGS: settings})) + monkeypatch.setattr(geometry, "_MAX_VERTS", 10) # the file holds 8 vertices, 16 once placed twice + import pytest + with pytest.raises(placement.TooLargeToMeasure): + placement.read_objects(path) + report = pp.assess(path) + assert report["available"] is False # a clear "could not measure", not an OK + + +def test_a_highly_compressed_archive_is_refused_not_expanded(tmp_path): + import time + big = fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))]) + padded = big.replace("", "" + "") + path = tmp_path / "bomb.3mf" + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("_rels/.rels", fx.rels_xml()) + z.writestr("3D/3dmodel.model", padded) + assert path.stat().st_size < 1_000_000 + started = time.perf_counter() + report = pp.assess(str(path)) + assert report["available"] is False + assert time.perf_counter() - started < 20 + try: + placement.read_objects(str(path)) + raise AssertionError("expected a refusal") + except placement.TooLargeToMeasure: + pass From d75ce0c20e0b7aca701ade92f63b5f8ba3d020fd Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Sat, 10 Oct 2026 08:39:59 -0400 Subject: [PATCH 04/12] fix(placement): printable area not travel, no OK for unjudged or tall, safe move writer (#93 repair 3) - Preflight and Ready Now judge placement against the printable area: the U1 polygon for a printer that identifies as the U1, the profile's recorded volume for another identified printer, and UNKNOWN for one Studio cannot identify. A printer's reported axis travel (about 271 x 335 on a U1) is never used as a printable rectangle. - bed.fit is UNKNOWN when any instance could not be judged, ATTENTION for an object above the printable height (placed Z, including scale), and its OK says X and Y only when height data is absent. None of these can reach Ready Now. A placement that was unavailable is not cached as done. - An empty, relative or climbing p:path is INVALID and unresolved, never a quiet fallback to the root model. - The move writer rewrites only the X/Y translation tokens and keeps every other token verbatim (a 0.7071067812 rotation survives); the centering offset is no longer rounded to a value that falls just short of the margin. Verified: backend pytest 3204 passed / 49 skipped; desktop tsc and vitest (821 passed) clean. Co-Authored-By: Claude Sonnet 5.5 --- backend/snapstudio_api/service.py | 73 ++++++++++----- backend/snapstudio_core/geometry.py | 21 ++++- backend/snapstudio_core/placement.py | 8 +- backend/snapstudio_core/plate_placement.py | 36 ++++++-- backend/snapstudio_core/preflight.py | 54 +++++++++-- backend/tests/test_preflight.py | 48 ++++++++-- backend/tests/test_readiness.py | 16 ++++ backend/tests/test_ready_now_api.py | 11 +-- backend/tests/test_second_printer.py | 7 +- backend/tests/test_second_printer_failures.py | 6 +- backend/tests/test_travel_and_move_writer.py | 90 +++++++++++++++++++ 11 files changed, 309 insertions(+), 61 deletions(-) create mode 100644 backend/tests/test_travel_and_move_writer.py diff --git a/backend/snapstudio_api/service.py b/backend/snapstudio_api/service.py index dc83ef17..c254464a 100644 --- a/backend/snapstudio_api/service.py +++ b/backend/snapstudio_api/service.py @@ -770,17 +770,8 @@ def preflight(path: str, host: str | None = None, port: int = 7125, facts["nozzle_revision"] = resolved["revision"] facts["nozzle_storage_error"] = resolved["storage_error"] - bed = None - dims = facts.get("bed_mm") or {} - if dims.get("x") and dims.get("y"): - bed = {"min_x": 0.0, "min_y": 0.0, - "max_x": float(dims["x"]), "max_y": float(dims["y"])} try: - # Name the plate after whatever supplied it, so a summary never describes a - # live bed as though it were the U1's. - placement = plate_placement.assess( - path, bed=bed, - bed_name=("this printer's" if bed else None)) + placement = _assess_against(path, _placement_target(facts)) except Exception: placement = None @@ -2584,7 +2575,43 @@ def batch_status(job_id: str) -> dict | None: _ready_cache_lock = threading.Lock() -def _ready_analysis(path: str, bed: dict | None, +def _placement_target(facts: dict) -> dict | None: + """The printable area to judge placement against, or None when it is not known. + + A connected printer reports the toolhead's AXIS TRAVEL (about 271 x 335 on a U1, whose plate is + 270 x 270), which is not a printable area and is never used as one. What is used instead: + the U1's printable polygon when the machine identifies itself as the U1, or the recorded + printable volume of the profile it identifies as. A printer Studio cannot identify has no known + printable area: None, and the bed check says so rather than guessing. + """ + from snapstudio_core import printer_profiles + + printer_id = (facts.get("identity") or {}).get("printer_id") + if not printer_id: + return None + if printer_id == printer_profiles.PREPARE_TARGET_ID: + return {"key": ("profile", printer_id), "bed": None, "name": None, "height": None} + try: + volume = printer_profiles.load(printer_id).get("build_volume_mm") or {} + except KeyError: + return None + if not (volume.get("x") and volume.get("y")): + return None + return {"key": ("profile", printer_id, float(volume["x"]), float(volume["y"])), + "bed": {"min_x": 0.0, "min_y": 0.0, "max_x": float(volume["x"]), "max_y": float(volume["y"])}, + "name": "this printer's", "height": float(volume["z"]) if volume.get("z") else None} + + +def _assess_against(path: str, target: dict | None) -> dict | None: + from snapstudio_core import plate_placement + + if target is None: + return None + return plate_placement.assess(path, bed=target["bed"], bed_name=target["name"], + height_mm=target["height"]) + + +def _ready_analysis(path: str, target: dict | None, need_placement: bool = True) -> tuple[str, dict | None, dict | None]: """(file_state, traits, placement) for one file, cached by what was read. @@ -2601,8 +2628,7 @@ def _ready_analysis(path: str, bed: dict | None, st = os.stat(path) except OSError: return "missing", None, None - bed_key = (bed["max_x"], bed["max_y"]) if bed else None - key = (path, st.st_mtime_ns, st.st_size, bed_key) + key = (path, st.st_mtime_ns, st.st_size, target["key"] if target else None) with _ready_cache_lock: hit = _READY_CACHE.get(key) if hit is None: @@ -2610,13 +2636,14 @@ def _ready_analysis(path: str, bed: dict | None, hit = {"state": "ok" if traits.get("readable") else "unreadable", "traits": traits, "placement": None, "placed": False} foreign = (hit["traits"].get("foreign_printer") or {}).get("value") is True - if need_placement and not hit["placed"] and hit["state"] == "ok" and bed and not foreign: + if need_placement and not hit["placed"] and hit["state"] == "ok" and target and not foreign: # Work on a copy so concurrent scans never mutate a shared entry. A failed read is # not cached as done: it is unknown this time and tried again next scan. hit = dict(hit) try: - hit["placement"] = plate_placement.assess(path, bed=bed, bed_name="this printer's") - hit["placed"] = True + hit["placement"] = _assess_against(path, target) + # an unavailable result (unreadable, too large) is not "done": it is tried again next scan + hit["placed"] = bool(hit["placement"] and hit["placement"].get("available")) except Exception: hit["placement"] = None with _ready_cache_lock: @@ -2631,21 +2658,21 @@ def _ready_analysis(path: str, bed: dict | None, _PLACEMENT_MATTERS = ("cant_determine", "one_change_away", "ready_now") -def _ready_one(row: dict, printer: dict, bed: dict | None) -> dict: +def _ready_one(row: dict, printer: dict, target: dict | None) -> dict: from snapstudio_core import preflight as pf from snapstudio_core import readiness project = {"path": row.get("source_path"), "name": row.get("name")} try: - state, traits, placement = _ready_analysis(project["path"], bed, need_placement=False) + state, traits, placement = _ready_analysis(project["path"], target, need_placement=False) foreign = ((traits or {}).get("foreign_printer") or {}).get("value") is True usable = state == "ok" and printer.get("reachable") and not foreign pre = pf.evaluate(traits, printer, placement) if usable else None result = readiness.classify_project(project, traits, printer, pre, state) - if usable and bed and result.get("bucket") in _PLACEMENT_MATTERS: + if usable and target and result.get("bucket") in _PLACEMENT_MATTERS: # Only now is the (slow) geometry read worth it, and it can only make the # result stricter, never claim more than the cheap pass did. - state, traits, placement = _ready_analysis(project["path"], bed, need_placement=True) + state, traits, placement = _ready_analysis(project["path"], target, need_placement=True) pre = pf.evaluate(traits, printer, placement) result = readiness.classify_project(project, traits, printer, pre, state) return result @@ -2691,13 +2718,11 @@ def worker() -> None: printer = printer_facts(host, port) if host else {"reachable": False} printer = _with_providers(printer, host, port, url, slot_map, slot_base, kind, provider_key) - dims = printer.get("bed_mm") or {} - bed = ({"min_x": 0.0, "min_y": 0.0, "max_x": float(dims["x"]), - "max_y": float(dims["y"])} if dims.get("x") and dims.get("y") else None) + target = _placement_target(printer) results: list[dict] = [] for row in rows: - results.append(_ready_one(row, printer, bed)) + results.append(_ready_one(row, printer, target)) publish(progress={"done": len(results), "total": len(rows)}, result={"results": list(results)}) results.sort(key=readiness.sort_key) # stable: newest first within a bucket diff --git a/backend/snapstudio_core/geometry.py b/backend/snapstudio_core/geometry.py index 91099997..fae52466 100644 --- a/backend/snapstudio_core/geometry.py +++ b/backend/snapstudio_core/geometry.py @@ -106,11 +106,26 @@ def appA(v): ROOT_MODEL = "3D/3dmodel.model" +#: What a malformed ``p:path`` becomes. It names no archive entry, so an item or component that carries +#: one is unresolved; it is never read as "no path" and so never quietly falls back to the root model. +INVALID_PART = "" + + def normalize_part(path: str | None) -> str | None: - """A ``p:path`` as an archive entry name (leading slash removed); None when absent.""" - if not path: + """A ``p:path`` as an archive entry name; None only when the attribute is absent. + + Like the scene reader, a production-extension path must be an absolute package path: an empty, + relative, drive-lettered, scheme-carrying or ``..``-climbing value is ``INVALID_PART``. + """ + if path is None: return None - return path.lstrip("/") + n = path.replace("\\", "/") + if not n.startswith("/") or any(ord(c) < 32 for c in n): + return INVALID_PART + segments = [s for s in n.split("/") if s not in ("", ".")] + if not segments or ".." in segments: + return INVALID_PART + return "/".join(segments) def build_items(root: bytes | str) -> list[dict]: diff --git a/backend/snapstudio_core/placement.py b/backend/snapstudio_core/placement.py index d5eda019..f35b241c 100644 --- a/backend/snapstudio_core/placement.py +++ b/backend/snapstudio_core/placement.py @@ -341,6 +341,12 @@ def build_items(root: str) -> list[dict]: return geometry.build_items(root) +def geometry_normalize(path: str) -> str: + from . import geometry + + return geometry.normalize_part(path) + + def _components_of(block: str) -> list: """(part path or None, object id, transform text or None) for each component of an object.""" out = [] @@ -350,7 +356,7 @@ def _components_of(block: str) -> list: continue transform = _ATTR_TRANSFORM.search(tag) path = _ATTR_PATH.search(tag) - out.append((path.group(1).lstrip("/") if path else None, mesh_id.group(1), + out.append((geometry_normalize(path.group(1)) if path else None, mesh_id.group(1), transform.group(1) if transform else None)) return out diff --git a/backend/snapstudio_core/plate_placement.py b/backend/snapstudio_core/plate_placement.py index 1d459460..7cfceefb 100644 --- a/backend/snapstudio_core/plate_placement.py +++ b/backend/snapstudio_core/plate_placement.py @@ -55,6 +55,17 @@ def _u1_printable_area() -> list[str]: return template.get("printable_area") or [] +def u1_printable_height() -> float | None: + """The U1's printable height (mm) from Studio's own U1 profile template; None if it records none.""" + try: + template = json.loads( + (files("snapstudio_core.data") / "templates" / "u1_base_project_settings.json") + .read_text("utf-8")) + return float(template.get("printable_height")) + except Exception: + return None + + def parse_printable_area(area) -> dict | None: """Turn a slicer's printable_area polygon into an axis-aligned rectangle. @@ -151,8 +162,8 @@ def _centering_offset(cluster: dict, bed: dict) -> dict: high_x = bed["max_x"] - EDGE_MARGIN_MM - cluster["max_x"] low_y = bed["min_y"] + EDGE_MARGIN_MM - cluster["min_y"] high_y = bed["max_y"] - EDGE_MARGIN_MM - cluster["max_y"] - return {"x": round(_nearest_to_zero(low_x, high_x), 3), - "y": round(_nearest_to_zero(low_y, high_y), 3)} + return {"x": round(_nearest_to_zero(low_x, high_x), 6), + "y": round(_nearest_to_zero(low_y, high_y), 6)} def _nearest_to_zero(low: float, high: float) -> float: @@ -357,7 +368,8 @@ def _plate_fit(grouped: dict[int, list[dict]], bed: dict, return out -def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> dict: +def assess(path: str, bed: dict | None = None, bed_name: str | None = None, + height_mm: float | None = None) -> dict: """Where every object sits relative to the bed. Read-only, never raises. `bed` is the printer's real printable rectangle when one has been read from a @@ -518,6 +530,7 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d "schema_version": SCHEMA_VERSION, "available": True, "bed": target, + "bed_height_mm": height_mm if height_mm is not None else (u1_printable_height() if not bed else None), "source_bed": source_bed, "source_printer": (traits.get("target_printer") or {}).get("value"), "plate_count": plate_count, @@ -541,8 +554,9 @@ def _shift_transform(value: str, dx: float, dy: float) -> str | None: """Add (dx, dy) to a 3MF transform's translation. None if not a 3x4 matrix. 3MF stores the matrix row-major with the translation as the final row, so - only entries 10 and 11 (1-indexed 12-value form) move. Everything else — - rotation, scale, shear — is copied through unchanged. + only entries 10 and 11 (1-indexed 12-value form) move. Every other token — + rotation, scale, shear, the Z translation — is copied through VERBATIM, not + re-formatted, so a rotation such as 0.7071067812 is not rounded. """ parts = value.split() if len(parts) != 12: @@ -551,9 +565,15 @@ def _shift_transform(value: str, dx: float, dy: float) -> str | None: nums = [float(p) for p in parts] except ValueError: return None - nums[9] += dx - nums[10] += dy - return " ".join(f"{n:.6g}" for n in nums) + parts[9] = _written(nums[9] + dx) + parts[10] = _written(nums[10] + dy) + return " ".join(parts) + + +def _written(value: float) -> str: + """A translation to a micron, without trailing zeros.""" + text = format(round(value, 6), ".6f").rstrip("0").rstrip(".") + return "0" if text in ("", "-0") else text def _identity_shifted(dx: float, dy: float) -> str: diff --git a/backend/snapstudio_core/preflight.py b/backend/snapstudio_core/preflight.py index bd6264da..f66ebc60 100644 --- a/backend/snapstudio_core/preflight.py +++ b/backend/snapstudio_core/preflight.py @@ -354,26 +354,64 @@ def _bed(project: dict, printer: dict, placement: dict | None) -> dict: f"{_fallback_name()} instead."), action="Connect the printer to check against its real bed.", source="Klipper toolhead axis limits") - size = f"{bed.get('x')} × {bed.get('y')} × {bed.get('z')} mm" + # The printer's reported bed is the toolhead's AXIS TRAVEL (about 271 x 335 on a U1), which is not + # a printable area. Placement is judged against the printable area of the machine Studio prepares + # for, and the evidence names that area, not the travel figure. + plate = placement.get("bed") if placement else None + area = (f"{plate['max_x'] - plate['min_x']:g} × {plate['max_y'] - plate['min_y']:g} mm printable area" + if plate else "printable area") if not placement or not placement.get("available"): return _check( "bed.fit", "Fits the printer's bed", UNKNOWN, - evidence=f"printer bed is {size}; Studio could not read where the objects sit", + evidence=("Studio could not read where the objects sit, or does not know this printer's " + "printable area (a printer reports its axis travel, which is not a printable area)"), confidence=INFORMATIONAL, consequence="Studio cannot confirm the objects land on this printer's plate.", action="Open the project in the slicer to see the plate.", - source="Klipper toolhead axis limits") + source="project geometry vs the printer's printable area") off = placement.get("off_plate") or [] + height = placement.get("bed_height_mm") # the printable height, when it is known + tops = [(item.get("bounds_mm") or {}).get("max") for item in placement.get("items") or []] + tops = [t[2] for t in tops if t and len(t) > 2] + tall = [t for t in tops if height and t > height + 1e-6] + if not off and placement.get("not_judged"): + n = placement["not_judged"] + return _check( + "bed.fit", "Fits the printer's bed", UNKNOWN, + evidence=(f"{n} placed instance{'s' if n != 1 else ''} could not be judged, so Studio " + "cannot say whether everything is on the plate"), + confidence=INFORMATIONAL, + consequence="Studio cannot confirm the objects land on this printer's plate.", + action="Open the project in Snapmaker Orca and use Arrange.", + source="project geometry vs the U1 printable area") + if not off and tall: + return _check( + "bed.fit", "Objects are taller than this printer can print", ATTENTION, + evidence=(f"{len(tall)} placed instance{'s' if len(tall) != 1 else ''} stand up to " + f"{max(tall):g} mm, above the {height:g} mm height limit"), + confidence=CONFIRMED, + consequence="The slicer will refuse to slice it, or the print will not fit.", + action="Scale the model down or split it in Snapmaker Orca.", + source="project geometry vs the U1 printable height") if not off: + if tops and height: + return _check( + "bed.fit", "Fits the printer's bed", OK, + evidence=f"every placed instance sits inside the {area} and under the " + f"{height:g} mm height limit", + confidence=CONFIRMED, + consequence="Nothing is placed off the plate or above its height.", + source="project geometry vs the U1 printable area and height") return _check( "bed.fit", "Fits the printer's bed", OK, - evidence=f"every object sits inside this printer's {size} area", - confidence=CONFIRMED, - consequence="Nothing is placed off the plate.", - source="project geometry vs printer bed") + evidence=f"every placed instance sits inside the {area} (X and Y only; " + "height was not checked)", + confidence=INFORMATIONAL, + consequence="Nothing is placed off the plate sideways.", + source="project geometry vs the U1 printable area") return _check( "bed.fit", "Objects sit outside this printer's bed", ATTENTION, - evidence=f"{len(off)} object(s) fall outside this printer's {size} area", + evidence=f"{len(off)} object(s) fall outside this printer's {area}", confidence=CONFIRMED, consequence=("The slicer will refuse to slice, or the print will start off the " "plate."), diff --git a/backend/tests/test_preflight.py b/backend/tests/test_preflight.py index 7c769399..388b85cd 100644 --- a/backend/tests/test_preflight.py +++ b/backend/tests/test_preflight.py @@ -28,9 +28,18 @@ def printer(**values): return base -def placement(off=0, fixable=True, available=True): - return {"available": available, "fixable": fixable, - "off_plate": [{"object_id": str(i)} for i in range(off)]} +def placement(off=0, fixable=True, available=True, **more): + out = {"available": available, "fixable": fixable, + "off_plate": [{"object_id": str(i)} for i in range(off)]} + out.update(more) + return out + + +U1_PLATE = {"min_x": 0.5, "min_y": 1.0, "max_x": 270.5, "max_y": 271.0} + + +def item(top): + return {"object_id": "1", "bounds_mm": {"min": [10, 10, 0], "max": [20, 20, top]}} def by_id(result, check_id): @@ -225,12 +234,35 @@ def test_without_the_ordered_trait_falls_back_to_todays_set_comparison(): # --- the bed ---------------------------------------------------------------- -def test_bed_uses_the_printers_real_dimensions_in_its_evidence(): - out = pf.evaluate(traits(), printer(bed_mm={"x": 270, "y": 270, "z": 270}), - placement=placement(off=0)) +def test_bed_evidence_names_the_printable_area_not_the_travel_figure(): + out = pf.evaluate(traits(), printer(bed_mm={"x": 271, "y": 335, "z": 275}), + placement=placement(off=0, bed=U1_PLATE, bed_height_mm=270.05, items=[item(20)])) check = by_id(out, "bed.fit") - assert check["result"] == pf.OK - assert "270" in check["evidence"] + assert check["result"] == pf.OK and check["confidence"] == pf.CONFIRMED + assert "270 × 270 mm printable area" in check["evidence"] and "335" not in check["evidence"] + assert "height limit" in check["evidence"] + + +def test_a_tall_object_is_not_confirmed_to_fit(): + out = pf.evaluate(traits(), printer(), + placement=placement(off=0, bed=U1_PLATE, bed_height_mm=270.05, items=[item(300)])) + check = by_id(out, "bed.fit") + assert check["result"] == pf.ATTENTION and "300" in check["evidence"] and "height limit" in check["evidence"] + + +def test_without_height_data_the_claim_is_narrowed_to_x_and_y(): + out = pf.evaluate(traits(), printer(), placement=placement(off=0, bed=U1_PLATE)) + check = by_id(out, "bed.fit") + assert check["result"] == pf.OK and check["confidence"] == pf.INFORMATIONAL + assert "X and Y only" in check["evidence"] and "height was not checked" in check["evidence"] + + +def test_instances_that_could_not_be_judged_are_never_confirmed(): + out = pf.evaluate(traits(), printer(), + placement=placement(off=0, bed=U1_PLATE, bed_height_mm=270.05, items=[item(20)], not_judged=2)) + check = by_id(out, "bed.fit") + assert check["result"] == pf.UNKNOWN and check["confidence"] != pf.CONFIRMED + assert "2 placed instances could not be judged" in check["evidence"] def test_objects_off_the_real_bed_need_attention_and_point_at_the_fix(): diff --git a/backend/tests/test_readiness.py b/backend/tests/test_readiness.py index 9aa5390a..1c822689 100644 --- a/backend/tests/test_readiness.py +++ b/backend/tests/test_readiness.py @@ -418,3 +418,19 @@ def test_colour_note_does_not_hide_a_possible_shortage(): assert r["colour_notes"] assert any("short" in u.lower() for u in r["unknowns"]) assert r["top_reason"] != rd.COLOUR_NOTE + + +def test_unjudged_instances_never_reach_ready_now(): + placement = {"available": True, "off_plate": [], "not_judged": 1, + "bed": {"min_x": 0.5, "min_y": 1.0, "max_x": 270.5, "max_y": 271.0}, + "bed_height_mm": 270.05, "items": []} + r = classify(traits(), printer([spool()]), placement=placement) + assert r["bucket"] == rd.CANT_DETERMINE and r["bucket"] != rd.READY_NOW + + +def test_a_tall_object_does_not_reach_ready_now(): + placement = {"available": True, "off_plate": [], "bed_height_mm": 270.05, + "bed": {"min_x": 0.5, "min_y": 1.0, "max_x": 270.5, "max_y": 271.0}, + "items": [{"object_id": "1", "bounds_mm": {"min": [10, 10, 0], "max": [20, 20, 300]}}]} + r = classify(traits(), printer([spool()]), placement=placement) + assert r["bucket"] != rd.READY_NOW diff --git a/backend/tests/test_ready_now_api.py b/backend/tests/test_ready_now_api.py index db95d943..f941c261 100644 --- a/backend/tests/test_ready_now_api.py +++ b/backend/tests/test_ready_now_api.py @@ -45,7 +45,8 @@ def _printer(self, host=None, port=7125): return {"reachable": True, "host": host, "port": port, "toolhead_count": 4, "bed_mm": {"x": 270, "y": 270, "z": 270}, "nozzle_diameters": [0.4] * 4, "nozzle_confirmed_by": "printer", "print_state": self.state, - "klipper_objects": [], "loaded_filaments": None} + "klipper_objects": [], "loaded_filaments": None, + "identity": {"printer_id": "snapmaker_u1"}} def _provider(self, kind, url, slot_map=None, timeout=4.0, slot_base=None, key=None): self.provider_reads += 1 @@ -62,7 +63,7 @@ def _extract(self, path): name = path.replace("\\", "/").rsplit("/", 1)[-1] return self.traits_by_name.get(name) or traits() - def _assess(self, path, bed=None, bed_name=None): + def _assess(self, path, bed=None, bed_name=None, height_mm=None): self.placements += 1 return {"available": True, "off_plate": []} @@ -207,7 +208,7 @@ def test_second_scan_hits_the_cache_and_an_edited_file_does_not(tmp_path, monkey assert fakes.printer_reads == 3 -def test_cache_is_keyed_by_bed_size(tmp_path, monkeypatch): +def test_cache_is_keyed_by_the_printable_area(tmp_path, monkeypatch): fakes = Fakes(monkeypatch) add_project(tmp_path, "a.3mf") scan(provider_url=PROVIDER_URL) @@ -215,7 +216,7 @@ def test_cache_is_keyed_by_bed_size(tmp_path, monkeypatch): "reachable": True, "host": host, "port": port, "toolhead_count": 4, "bed_mm": {"x": 350, "y": 350, "z": 350}, "nozzle_diameters": [0.4] * 4, "nozzle_confirmed_by": "printer", "print_state": "standby", "klipper_objects": [], - "loaded_filaments": None}) + "loaded_filaments": None, "identity": {"printer_id": "voron_2_4_250"}}) monkeypatch.setattr(service, "printer_facts", fakes._printer) scan(provider_url=PROVIDER_URL) assert fakes.placements == 2 @@ -422,7 +423,7 @@ def boom(*a, **k): raise RuntimeError("transient") monkeypatch.setattr(plate_placement, "assess", boom) - bed = {"min_x": 0.0, "min_y": 0.0, "max_x": 270.0, "max_y": 270.0} + bed = {"key": ("test",), "bed": None, "name": None, "height": None} state1, _t, placement1 = service._ready_analysis(str(f), bed, need_placement=True) state2, _t, placement2 = service._ready_analysis(str(f), bed, need_placement=True) assert state1 == state2 == "ok" diff --git a/backend/tests/test_second_printer.py b/backend/tests/test_second_printer.py index fd132b75..b8f37739 100644 --- a/backend/tests/test_second_printer.py +++ b/backend/tests/test_second_printer.py @@ -212,10 +212,13 @@ def test_preflight_compares_the_project_to_this_printers_real_numbers(): def test_bed_check_uses_this_printers_bed_not_the_u1s(): project = {"filament_count": {"value": 1, "confidence": "confirmed", "evidence": "t"}} - placement = {"available": True, "off_plate": [], "fixable": False} + # the service judges a Voron against ITS recorded printable volume, and says which area that was + placement = {"available": True, "off_plate": [], "fixable": False, "bed_height_mm": 252.0, + "bed": {"min_x": 0.0, "min_y": 0.0, "max_x": 250.0, "max_y": 250.0}, + "items": [{"object_id": "1", "bounds_mm": {"min": [10, 10, 0], "max": [20, 20, 30]}}]} out = pf.evaluate(project, voron_facts(), placement=placement) bed = next(c for c in out["checks"] if c["id"] == "bed.fit") - assert "250.0 × 250.0 × 252.0 mm" in bed["evidence"] + assert "250 × 250 mm printable area" in bed["evidence"] and "252" in bed["evidence"] assert "270" not in bed["evidence"] diff --git a/backend/tests/test_second_printer_failures.py b/backend/tests/test_second_printer_failures.py index 9116233b..72beea5b 100644 --- a/backend/tests/test_second_printer_failures.py +++ b/backend/tests/test_second_printer_failures.py @@ -57,9 +57,11 @@ def test_a_printer_reporting_fewer_tools_than_the_profile_is_believed(): def test_a_much_bigger_bed_is_used_as_reported(): 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": []}) + 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": []}) bed = next(c for c in out["checks"] if c["id"] == "bed.fit") - assert bed["result"] == pf.OK and "1000.0" in bed["evidence"] + assert bed["result"] == pf.OK and "1000 × 1000 mm printable area" in bed["evidence"] def test_a_zero_tool_printer_does_not_divide_by_it(): diff --git a/backend/tests/test_travel_and_move_writer.py b/backend/tests/test_travel_and_move_writer.py new file mode 100644 index 00000000..d0a0f0ba --- /dev/null +++ b/backend/tests/test_travel_and_move_writer.py @@ -0,0 +1,90 @@ +"""Review round 3: travel extents are not a printable area; malformed p:path fails closed; the move writer +leaves every token but the X/Y translation alone.""" +from __future__ import annotations + +import zipfile + +from snapstudio_api import service +from snapstudio_core import geometry, placement, plate_placement as pp +from tests import scene_fixtures as fx + +A = "3D/Objects/a.model" + + +def _facts(identity): + return lambda host=None, port=7125: { + "reachable": True, "host": "h", "port": 7125, "toolhead_count": 4, "klipper_objects": [], + "bed_mm": {"x": 271, "y": 335, "z": 275}, "print_state": "standby", "identity": identity} + + +def _y300(tmp_path): + return str(fx.three_mf(tmp_path / "y.3mf", fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(100, 300, 0))]))) + + +def test_a_u1_is_judged_against_its_printable_area_not_its_travel(tmp_path, monkeypatch): + seen = {} + real = pp.assess + + def spy(path, bed=None, bed_name=None, height_mm=None): + seen["bed"] = bed + return real(path, bed=bed, bed_name=bed_name, height_mm=height_mm) + + monkeypatch.setattr(pp, "assess", spy) + monkeypatch.setattr(service, "printer_facts", _facts({"printer_id": "snapmaker_u1"})) + out = service.preflight(_y300(tmp_path), "h") + assert seen["bed"] is None # the profile polygon, not 271 x 335 + check = next(c for c in out["checks"] if c["id"] == "bed.fit") + assert check["result"] == "attention" and "335" not in check["evidence"] + + +def test_a_printer_that_cannot_be_identified_has_an_unknown_printable_area(tmp_path, monkeypatch): + monkeypatch.setattr(service, "printer_facts", _facts({"printer_id": None})) + out = service.preflight(_y300(tmp_path), "h") + check = next(c for c in out["checks"] if c["id"] == "bed.fit") + assert check["result"] == "unknown" and check["confidence"] != "confirmed" + assert "printable area" in check["evidence"] + + +def test_a_malformed_part_path_is_unresolved_never_the_root_object(tmp_path): + for bad in ("", "3D/Objects/a.model", "/../x.model", "http://x/a.model"): + root = fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0)), ("1", fx.tf(100, 100, 0), bad)]) + path = str(fx.three_mf(tmp_path / "bad.3mf", root)) + items, unresolved = geometry.measure_items(path) + assert [i["item_index"] for i in items] == [0], bad + assert [u["item_index"] for u in unresolved] == [1], bad + + +def test_a_malformed_component_path_is_unresolved(tmp_path): + for bad in ("", "3D/Objects/a.model"): + comp = fx.composite_object("5", [("1", bad, None)]) + extra = {A: fx.sub_model_xml([fx.cube_object("1", 10)])} + path = str(fx.three_mf(tmp_path / "c.3mf", fx.model_xml([fx.cube_object("1", 10), comp], [("5", fx.tf(10, 10, 0))]), extra)) + assert [u["item_index"] for u in geometry.measure_items(path)[1]] == [0], bad + assert placement.read_objects(path)["objects"][0]["resolved"] is False, bad + + +def test_the_move_writer_keeps_every_other_token_verbatim(): + out = pp._shift_transform("0.7071067812 0.7071067812 0 -0.7071067812 0.7071067812 0 0 0 1 100.123456789 200 5", 6, 0) + tokens = out.split() + assert tokens[:9] == ["0.7071067812", "0.7071067812", "0", "-0.7071067812", "0.7071067812", "0", "0", "0", "1"] + assert tokens[9] == "106.123457" and tokens[10] == "200" and tokens[11] == "5" + + +def test_a_rotated_project_is_moved_and_verified(tmp_path): + rot = "0.7071067812 0.7071067812 0 -0.7071067812 0.7071067812 0 0 0 1" + src = fx.three_mf(tmp_path / "rot.3mf", fx.model_xml( + [fx.cube_object("1", 10)], [("1", f"{rot} -5 100 0"), ("1", f"{rot} 100 100 0")])) + result = pp.prepare_placed_copy(str(src), out_dir=str(tmp_path / "out")) + assert result["ok"] is True, result.get("reason") + assert result["verification"]["passed"] is True + with zipfile.ZipFile(result["output_path"]) as z: + assert z.read("3D/3dmodel.model").decode().count(rot) == 2 + + +def test_a_tall_object_is_not_confirmed_by_preflight_even_when_it_is_on_the_plate(tmp_path, monkeypatch): + path = str(fx.three_mf(tmp_path / "tall.3mf", fx.model_xml( + [fx.cube_object("1", 10).replace('z="10.0"', 'z="300.0"')], [("1", fx.tf(100, 100, 0))]))) + monkeypatch.setattr(service, "printer_facts", _facts({"printer_id": "snapmaker_u1"})) + check = next(c for c in service.preflight(path, "h")["checks"] if c["id"] == "bed.fit") + assert check["result"] == "attention" and "height limit" in check["evidence"] + assert check["confidence"] == "confirmed" and "300" in check["evidence"] From f86484645a1ef0ad638f75dd8201753613e5ede3 Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Sat, 10 Oct 2026 09:43:11 -0400 Subject: [PATCH 05/12] fix(placement): content-keyed Ready Now cache, height from printing parts only (#93 repair 4) - The Ready Now cache is keyed by a content fingerprint (a zip's member names, sizes and CRCs; other files hashed whole) as well as path, size and mtime, so a same-size replacement that kept its timestamp is analysed again. - The placed Z bounds come from the parts that print, like X and Y: a 300 mm modifier or support blocker no longer makes a 10 mm part over height. plate_placement.over_height is the one rule preflight (and the bed-fit check) use. Verified: backend pytest 3206 passed / 49 skipped. Co-Authored-By: Claude Sonnet 5.5 --- backend/snapstudio_api/service.py | 9 +++- backend/snapstudio_core/fileid.py | 46 ++++++++++++++++++++ backend/snapstudio_core/placement.py | 10 +++-- backend/snapstudio_core/plate_placement.py | 29 ++++++++++-- backend/snapstudio_core/preflight.py | 4 +- backend/tests/test_ready_now_api.py | 27 ++++++++++++ backend/tests/test_travel_and_move_writer.py | 16 +++++++ 7 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 backend/snapstudio_core/fileid.py diff --git a/backend/snapstudio_api/service.py b/backend/snapstudio_api/service.py index c254464a..dac88a64 100644 --- a/backend/snapstudio_api/service.py +++ b/backend/snapstudio_api/service.py @@ -2622,13 +2622,18 @@ def _ready_analysis(path: str, target: dict | None, is only done when asked for: `need_placement=False` returns the cheap traits and leaves placement None, and a later `True` call fills it in on the same cache entry. """ - from snapstudio_core import plate_placement, project_traits + from snapstudio_core import fileid, plate_placement, project_traits try: st = os.stat(path) except OSError: return "missing", None, None - key = (path, st.st_mtime_ns, st.st_size, target["key"] if target else None) + # the content fingerprint is part of the key: a same-size replacement that kept its timestamp + # must not be served a placement computed for the old content + identity = fileid.file_identity(path) + if identity is None: + return "missing", None, None + key = (identity, target["key"] if target else None) with _ready_cache_lock: hit = _READY_CACHE.get(key) if hit is None: diff --git a/backend/snapstudio_core/fileid.py b/backend/snapstudio_core/fileid.py new file mode 100644 index 00000000..314793a0 --- /dev/null +++ b/backend/snapstudio_core/fileid.py @@ -0,0 +1,46 @@ +"""What identifies a file's CONTENT for anything that remembers a result about it. + +A remembered result is only as good as its key. Size and modification time alone miss a same-size +replacement that kept its timestamp (a copy that preserves times, a tool that rewrites in place), so the +key also carries a fingerprint of the bytes, and a result is only stored if the file is unchanged after it +was computed. +""" +from __future__ import annotations + +import hashlib +import zipfile +from pathlib import Path + + +def stat_identity(path: str): + """``(resolved path, mtime, size)`` or None.""" + try: + resolved = Path(path).resolve() + st = resolved.stat() + except OSError: + return None + return (str(resolved), st.st_mtime_ns, st.st_size) + + +def file_identity(path: str): + """The stat identity PLUS a fingerprint of the content, or None if the file cannot be read. + + For a zip (every 3MF) the fingerprint is taken from the archive's own directory: each member's name, + size and CRC-32, which changes whenever any member's bytes do and costs a read of the directory only, + not of a 40 MB project. Anything else is hashed whole.""" + base = stat_identity(path) + if base is None: + return None + digest = hashlib.blake2b(digest_size=16) + try: + try: + with zipfile.ZipFile(path) as archive: + for info in archive.infolist(): + digest.update(f"{info.filename}|{info.file_size}|{info.CRC}|".encode("utf-8", "replace")) + except zipfile.BadZipFile: + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + digest.update(chunk) + except OSError: + return None + return base + (digest.hexdigest(),) diff --git a/backend/snapstudio_core/placement.py b/backend/snapstudio_core/placement.py index f35b241c..978178c2 100644 --- a/backend/snapstudio_core/placement.py +++ b/backend/snapstudio_core/placement.py @@ -452,12 +452,15 @@ class _Extent: def __init__(self, keep: bool) -> None: self.keep, self.points = keep, [] - self.lo_x = self.lo_y = float("inf") - self.hi_x = self.hi_y = float("-inf") + self.lo_x = self.lo_y = self.lo_z = float("inf") + self.hi_x = self.hi_y = self.hi_z = float("-inf") self.n = 0 def add(self, point: tuple) -> None: x, y = point[0], point[1] + z = point[2] if len(point) > 2 else 0.0 + if z < self.lo_z: self.lo_z = z + if z > self.hi_z: self.hi_z = z if x < self.lo_x: self.lo_x = x if x > self.hi_x: self.hi_x = x if y < self.lo_y: self.lo_y = y @@ -473,7 +476,8 @@ def footprint(self) -> dict | None: if not self.n: return None return {"min_x": self.lo_x, "min_y": self.lo_y, "max_x": self.hi_x, "max_y": self.hi_y, - "width": self.hi_x - self.lo_x, "depth": self.hi_y - self.lo_y} + "width": self.hi_x - self.lo_x, "depth": self.hi_y - self.lo_y, + "min_z": self.lo_z, "max_z": self.hi_z} def _footprint(points: list[tuple]) -> dict | None: diff --git a/backend/snapstudio_core/plate_placement.py b/backend/snapstudio_core/plate_placement.py index 7cfceefb..e5130054 100644 --- a/backend/snapstudio_core/plate_placement.py +++ b/backend/snapstudio_core/plate_placement.py @@ -55,6 +55,24 @@ def _u1_printable_area() -> list[str]: return template.get("printable_area") or [] +def over_height(placement: dict | None) -> list[dict]: + """The placed instances whose top is above the printable height, from a ``assess`` result. + + The one rule for "too tall": preflight and the bed-fit check both use it, so neither can read + a tall instance as fine because it only looked sideways. Empty when the height is not known.""" + if not placement or not placement.get("available"): + return [] + limit = placement.get("bed_height_mm") + if not limit: + return [] + out = [] + for row in placement.get("items") or []: + top = ((row.get("bounds_mm") or {}).get("max") or [None, None, None])[2] + if top is not None and top > limit + 1e-6: + out.append(row) + return out + + def u1_printable_height() -> float | None: """The U1's printable height (mm) from Studio's own U1 profile template; None if it records none.""" try: @@ -217,10 +235,13 @@ def _printable_only(path: str, items: list[dict]) -> list[dict]: continue low, high = item["bounds"]["min"], item["bounds"]["max"] entry = dict(item) - entry["bounds"] = {"min": (box["min_x"], box["min_y"], low[2]), - "max": (box["max_x"], box["max_y"], high[2])} - entry["dimensions"] = dict(item["dimensions"], - x=round(box["width"], 3), y=round(box["depth"], 3)) + # X, Y AND Z come from the parts that print: a tall modifier, negative volume or support + # blocker is an instruction to the slicer, not height the print has. + z_low, z_high = box.get("min_z", low[2]), box.get("max_z", high[2]) + entry["bounds"] = {"min": (box["min_x"], box["min_y"], z_low), + "max": (box["max_x"], box["max_y"], z_high)} + entry["dimensions"] = dict(item["dimensions"], x=round(box["width"], 3), + y=round(box["depth"], 3), z=round(z_high - z_low, 3)) out.append(entry) return out diff --git a/backend/snapstudio_core/preflight.py b/backend/snapstudio_core/preflight.py index f66ebc60..349a98f7 100644 --- a/backend/snapstudio_core/preflight.py +++ b/backend/snapstudio_core/preflight.py @@ -371,9 +371,11 @@ def _bed(project: dict, printer: dict, placement: dict | None) -> dict: source="project geometry vs the printer's printable area") off = placement.get("off_plate") or [] height = placement.get("bed_height_mm") # the printable height, when it is known + from .plate_placement import over_height + tops = [(item.get("bounds_mm") or {}).get("max") for item in placement.get("items") or []] tops = [t[2] for t in tops if t and len(t) > 2] - tall = [t for t in tops if height and t > height + 1e-6] + tall = [row["bounds_mm"]["max"][2] for row in over_height(placement)] if not off and placement.get("not_judged"): n = placement["not_judged"] return _check( diff --git a/backend/tests/test_ready_now_api.py b/backend/tests/test_ready_now_api.py index f941c261..9f439adf 100644 --- a/backend/tests/test_ready_now_api.py +++ b/backend/tests/test_ready_now_api.py @@ -429,3 +429,30 @@ def boom(*a, **k): assert state1 == state2 == "ok" assert placement1 is None and placement2 is None assert calls["n"] == 2 + + +def test_a_same_size_replacement_with_the_same_timestamp_is_not_served_stale(tmp_path, monkeypatch): + import os + from snapstudio_api import service + from snapstudio_core import plate_placement + from tests import scene_fixtures as fx + path = tmp_path / "a.3mf" + fx.three_mf(path, fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))])) + before = os.stat(path) + service._READY_CACHE.clear() + seen = [] + + def spy(p, *a, **k): + seen.append(p) + return {"available": True, "off_plate": [], "items": [], "tag": len(seen)} + + monkeypatch.setattr(plate_placement, "assess", spy) + from snapstudio_core import project_traits + monkeypatch.setattr(project_traits, "extract", lambda p: {"readable": True}) + target = {"key": ("test",), "bed": None, "name": None, "height": None} + _s, _t, first = service._ready_analysis(str(path), target, need_placement=True) + fx.three_mf(path, fx.model_xml([fx.cube_object("1", 30)], [("1", fx.tf(100, 100, 0))])) + os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns)) + assert os.stat(path).st_size == before.st_size and os.stat(path).st_mtime_ns == before.st_mtime_ns + _s, _t, second = service._ready_analysis(str(path), target, need_placement=True) + assert first["tag"] == 1 and second["tag"] == 2 # analysed again, not served from the cache diff --git a/backend/tests/test_travel_and_move_writer.py b/backend/tests/test_travel_and_move_writer.py index d0a0f0ba..6ab3c8dd 100644 --- a/backend/tests/test_travel_and_move_writer.py +++ b/backend/tests/test_travel_and_move_writer.py @@ -88,3 +88,19 @@ def test_a_tall_object_is_not_confirmed_by_preflight_even_when_it_is_on_the_plat check = next(c for c in service.preflight(path, "h")["checks"] if c["id"] == "bed.fit") assert check["result"] == "attention" and "height limit" in check["evidence"] assert check["confidence"] == "confirmed" and "300" in check["evidence"] + + +def test_a_tall_modifier_does_not_make_a_short_part_over_height(tmp_path): + # a 10 mm printable part and a 300 mm modifier volume on 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, "Metadata/model_settings.config": settings})) + report = pp.assess(path) + assert report["available"] and report["items"][0]["bounds_mm"]["max"][2] == 10.0 + assert pp.over_height(report) == [] + # and a tall PRINTABLE part is over height + settings2 = fx.model_settings_xml({"100": [("1", "normal_part"), ("2", "normal_part")]}, [(1, [("100", 0)])]) + path2 = str(fx.three_mf(tmp_path / "tall2.3mf", root, {"3D/Objects/o.model": sub, "Metadata/model_settings.config": settings2})) + assert len(pp.over_height(pp.assess(path2))) == 1 From 7a66d7bf51500a8cbea89435ac815957ee4cd631 Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Sat, 10 Oct 2026 11:27:47 -0400 Subject: [PATCH 06/12] fix(placement): below-plate parts, torn Ready Now reads, named printer (#93 repair 5) - A placed part that reaches below the build plane (placed Z under zero, build-item scale and translation included) is not confirmed to fit: plate_placement.below_plate, and preflight reports it as a likely attention item. Nothing in the project says the sunk part is meant to be cut off, so Studio leaves that to the person. - Ready Now identifies the file before and after reading it; a file replaced in between is read again (up to three times), never cached or returned as a mix of two versions, and one that never holds still is reported unreadable. - The bed check names the printer it judged against in its source. Verified: backend pytest 3211 passed / 49 skipped. Co-Authored-By: Claude Sonnet 5.5 --- backend/snapstudio_api/service.py | 28 ++++++++++-- backend/snapstudio_core/plate_placement.py | 22 ++++++++++ backend/snapstudio_core/preflight.py | 22 +++++++--- backend/tests/test_ready_now_api.py | 45 ++++++++++++++++++++ backend/tests/test_travel_and_move_writer.py | 27 ++++++++++++ 5 files changed, 135 insertions(+), 9 deletions(-) diff --git a/backend/snapstudio_api/service.py b/backend/snapstudio_api/service.py index dac88a64..00876fec 100644 --- a/backend/snapstudio_api/service.py +++ b/backend/snapstudio_api/service.py @@ -2592,14 +2592,15 @@ def _placement_target(facts: dict) -> dict | None: if printer_id == printer_profiles.PREPARE_TARGET_ID: return {"key": ("profile", printer_id), "bed": None, "name": None, "height": None} try: - volume = printer_profiles.load(printer_id).get("build_volume_mm") or {} + profile = printer_profiles.load(printer_id) + volume = profile.get("build_volume_mm") or {} except KeyError: return None if not (volume.get("x") and volume.get("y")): return None return {"key": ("profile", printer_id, float(volume["x"]), float(volume["y"])), "bed": {"min_x": 0.0, "min_y": 0.0, "max_x": float(volume["x"]), "max_y": float(volume["y"])}, - "name": "this printer's", "height": float(volume["z"]) if volume.get("z") else None} + "name": f"the {printer_profiles.display_name(profile)}'s", "height": float(volume["z"]) if volume.get("z") else None} def _assess_against(path: str, target: dict | None) -> dict | None: @@ -2611,12 +2612,29 @@ def _assess_against(path: str, target: dict | None) -> dict | None: height_mm=target["height"]) +_READY_ATTEMPTS = 3 + + def _ready_analysis(path: str, target: dict | None, need_placement: bool = True) -> tuple[str, dict | None, dict | None]: """(file_state, traits, placement) for one file, cached by what was read. - Keyed by path, mtime_ns, size and the bed it was placed against, so an edited - file or a different printer bed is analysed again and nothing else is. + The file is identified before it is read and again after: if it changed in between, what was + read may mix two versions, so it is discarded and read again (a few times at most). A file that + will not hold still is reported as unreadable this time and nothing about it is cached. + """ + for _attempt in range(_READY_ATTEMPTS): + outcome = _ready_analysis_once(path, target, need_placement) + if outcome is not None: + return outcome + return "unreadable", None, None + + +def _ready_analysis_once(path: str, target: dict | None, need_placement: bool): + """One pass of `_ready_analysis`; None when the file changed while it was being read. + + Keyed by the file's content fingerprint (and the printable area it was placed against), so an + edited file or a different printer is analysed again and nothing else is. Placement (a full read of the model's geometry) is by far the slowest step, so it is only done when asked for: `need_placement=False` returns the cheap traits and @@ -2651,6 +2669,8 @@ def _ready_analysis(path: str, target: dict | None, hit["placed"] = bool(hit["placement"] and hit["placement"].get("available")) except Exception: hit["placement"] = None + if fileid.file_identity(path) != identity: + return None # replaced while it was being read: never cache or return a mix with _ready_cache_lock: if key not in _READY_CACHE and len(_READY_CACHE) >= _READY_CACHE_MAX: _READY_CACHE.pop(next(iter(_READY_CACHE)), None) # oldest first diff --git a/backend/snapstudio_core/plate_placement.py b/backend/snapstudio_core/plate_placement.py index e5130054..1243539d 100644 --- a/backend/snapstudio_core/plate_placement.py +++ b/backend/snapstudio_core/plate_placement.py @@ -73,6 +73,27 @@ def over_height(placement: dict | None) -> list[dict]: return out +#: Below this a placed part is under the build plane (float noise stays above it). +BELOW_PLATE_MM = -0.01 + + +def below_plate(placement: dict | None) -> list[dict]: + """The placed instances that reach below the build plane (placed Z under zero). + + Studio does not treat a sunk part as fitting. A slicer prints what is above the plane, and nothing in + the project says the parts under it are meant to be cut off, so "fits" cannot be confirmed: the + instance is reported instead, and the person decides. (Z is placed Z: the build item's own scale and + translation count.)""" + if not placement or not placement.get("available"): + return [] + out = [] + for row in placement.get("items") or []: + low = ((row.get("bounds_mm") or {}).get("min") or [None, None, None])[2] + if low is not None and low < BELOW_PLATE_MM: + out.append(row) + return out + + def u1_printable_height() -> float | None: """The U1's printable height (mm) from Studio's own U1 profile template; None if it records none.""" try: @@ -551,6 +572,7 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None, "schema_version": SCHEMA_VERSION, "available": True, "bed": target, + "bed_name": whose, "bed_height_mm": height_mm if height_mm is not None else (u1_printable_height() if not bed else None), "source_bed": source_bed, "source_printer": (traits.get("target_printer") or {}).get("value"), diff --git a/backend/snapstudio_core/preflight.py b/backend/snapstudio_core/preflight.py index 349a98f7..199023ed 100644 --- a/backend/snapstudio_core/preflight.py +++ b/backend/snapstudio_core/preflight.py @@ -358,6 +358,7 @@ def _bed(project: dict, printer: dict, placement: dict | None) -> dict: # a printable area. Placement is judged against the printable area of the machine Studio prepares # for, and the evidence names that area, not the travel figure. plate = placement.get("bed") if placement else None + owner = (placement.get("bed_name") if placement else None) or "the printer's" area = (f"{plate['max_x'] - plate['min_x']:g} × {plate['max_y'] - plate['min_y']:g} mm printable area" if plate else "printable area") if not placement or not placement.get("available"): @@ -371,7 +372,7 @@ def _bed(project: dict, printer: dict, placement: dict | None) -> dict: source="project geometry vs the printer's printable area") off = placement.get("off_plate") or [] height = placement.get("bed_height_mm") # the printable height, when it is known - from .plate_placement import over_height + from .plate_placement import below_plate, over_height tops = [(item.get("bounds_mm") or {}).get("max") for item in placement.get("items") or []] tops = [t[2] for t in tops if t and len(t) > 2] @@ -385,7 +386,7 @@ def _bed(project: dict, printer: dict, placement: dict | None) -> dict: confidence=INFORMATIONAL, consequence="Studio cannot confirm the objects land on this printer's plate.", action="Open the project in Snapmaker Orca and use Arrange.", - source="project geometry vs the U1 printable area") + source=f"project geometry vs {owner} printable area") if not off and tall: return _check( "bed.fit", "Objects are taller than this printer can print", ATTENTION, @@ -394,7 +395,18 @@ def _bed(project: dict, printer: dict, placement: dict | None) -> dict: confidence=CONFIRMED, consequence="The slicer will refuse to slice it, or the print will not fit.", action="Scale the model down or split it in Snapmaker Orca.", - source="project geometry vs the U1 printable height") + source=f"project geometry vs {owner} printable height") + sunk = below_plate(placement) + if not off and sunk: + low = min(row["bounds_mm"]["min"][2] for row in sunk) + return _check( + "bed.fit", "Objects reach below the build plate", ATTENTION, + evidence=(f"{len(sunk)} placed instance{'s' if len(sunk) != 1 else ''} reach down to " + f"{low:g} mm, below the build plate"), + confidence=LIKELY, + consequence="A slicer prints what is above the plate; Studio cannot confirm this prints as modelled.", + action="Check the height of these objects in Snapmaker Orca.", + source="project geometry vs the build plane") if not off: if tops and height: return _check( @@ -403,14 +415,14 @@ def _bed(project: dict, printer: dict, placement: dict | None) -> dict: f"{height:g} mm height limit", confidence=CONFIRMED, consequence="Nothing is placed off the plate or above its height.", - source="project geometry vs the U1 printable area and height") + source=f"project geometry vs {owner} printable area and height") return _check( "bed.fit", "Fits the printer's bed", OK, evidence=f"every placed instance sits inside the {area} (X and Y only; " "height was not checked)", confidence=INFORMATIONAL, consequence="Nothing is placed off the plate sideways.", - source="project geometry vs the U1 printable area") + source=f"project geometry vs {owner} printable area") return _check( "bed.fit", "Objects sit outside this printer's bed", ATTENTION, evidence=f"{len(off)} object(s) fall outside this printer's {area}", diff --git a/backend/tests/test_ready_now_api.py b/backend/tests/test_ready_now_api.py index 9f439adf..b15a9c4f 100644 --- a/backend/tests/test_ready_now_api.py +++ b/backend/tests/test_ready_now_api.py @@ -456,3 +456,48 @@ def spy(p, *a, **k): assert os.stat(path).st_size == before.st_size and os.stat(path).st_mtime_ns == before.st_mtime_ns _s, _t, second = service._ready_analysis(str(path), target, need_placement=True) assert first["tag"] == 1 and second["tag"] == 2 # analysed again, not served from the cache + + +def test_a_file_replaced_while_it_is_being_read_is_read_again_never_mixed(tmp_path, monkeypatch): + import os + from snapstudio_api import service + from snapstudio_core import plate_placement, project_traits + from tests import scene_fixtures as fx + path = tmp_path / "a.3mf" + fx.three_mf(path, fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))])) + service._READY_CACHE.clear() + reads = [] + + def extract(p): + reads.append(1) + if len(reads) == 1: # the file is replaced after Studio looked at version A + fx.three_mf(path, fx.model_xml([fx.cube_object("1", 30)], [("1", fx.tf(100, 100, 0))])) + return {"readable": True, "tag": "A"} + return {"readable": True, "tag": "B"} + + monkeypatch.setattr(project_traits, "extract", extract) + monkeypatch.setattr(plate_placement, "assess", lambda p, *a, **k: {"available": True, "off_plate": [], "items": []}) + target = {"key": ("test",), "bed": None, "name": None, "height": None} + state, traits, _placement = service._ready_analysis(str(path), target, need_placement=True) + assert state == "ok" and traits["tag"] == "B" and len(reads) == 2 + assert len(service._READY_CACHE) == 1 # only the consistent B read was cached + + +def test_a_file_that_never_holds_still_is_unreadable_and_not_cached(tmp_path, monkeypatch): + from snapstudio_api import service + from snapstudio_core import project_traits + from tests import scene_fixtures as fx + path = tmp_path / "a.3mf" + fx.three_mf(path, fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))])) + service._READY_CACHE.clear() + count = {"n": 0} + + def extract(p): + count["n"] += 1 + fx.three_mf(path, fx.model_xml([fx.cube_object("1", 10 + count["n"])], [("1", fx.tf(100, 100, 0))])) + return {"readable": True} + + monkeypatch.setattr(project_traits, "extract", extract) + target = {"key": ("test",), "bed": None, "name": None, "height": None} + assert service._ready_analysis(str(path), target, need_placement=False) == ("unreadable", None, None) + assert count["n"] == service._READY_ATTEMPTS and len(service._READY_CACHE) == 0 diff --git a/backend/tests/test_travel_and_move_writer.py b/backend/tests/test_travel_and_move_writer.py index 6ab3c8dd..03670df7 100644 --- a/backend/tests/test_travel_and_move_writer.py +++ b/backend/tests/test_travel_and_move_writer.py @@ -104,3 +104,30 @@ def test_a_tall_modifier_does_not_make_a_short_part_over_height(tmp_path): settings2 = fx.model_settings_xml({"100": [("1", "normal_part"), ("2", "normal_part")]}, [(1, [("100", 0)])]) path2 = str(fx.three_mf(tmp_path / "tall2.3mf", root, {"3D/Objects/o.model": sub, "Metadata/model_settings.config": settings2})) assert len(pp.over_height(pp.assess(path2))) == 1 + + +def test_a_part_sunk_below_the_build_plane_is_not_confirmed_to_fit(tmp_path, monkeypatch): + # a 10 mm cube scaled 30x in Z and moved to Z=-100 spans -100..200: under 270, but below the plate + path = str(fx.three_mf(tmp_path / "sunk.3mf", fx.model_xml( + [fx.cube_object("1", 10)], [("1", "1 0 0 0 1 0 0 0 30 100 100 -100")]))) + assert len(pp.below_plate(pp.assess(path))) == 1 and pp.over_height(pp.assess(path)) == [] + monkeypatch.setattr(service, "printer_facts", _facts({"printer_id": "snapmaker_u1"})) + check = next(c for c in service.preflight(path, "h")["checks"] if c["id"] == "bed.fit") + assert check["result"] == "attention" and check["confidence"] == "likely" + assert "below the build plate" in check["evidence"] and "-100" in check["evidence"] + + +def test_a_part_resting_on_the_plate_is_not_below_it(tmp_path): + path = str(fx.three_mf(tmp_path / "flat.3mf", fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))]))) + assert pp.below_plate(pp.assess(path)) == [] + + +def test_the_bed_check_source_names_the_printer_it_judged_against(): + from snapstudio_core import preflight as pf + placement = {"available": True, "off_plate": [], "bed_name": "the Voron 2.4 250's", "bed_height_mm": 250.0, + "bed": {"min_x": 0.0, "min_y": 0.0, "max_x": 250.0, "max_y": 250.0}, + "items": [{"object_id": "1", "bounds_mm": {"min": [10, 10, 0], "max": [20, 20, 30]}}]} + out = pf.evaluate({}, {"reachable": True, "toolhead_count": 1, "bed_mm": {"x": 250, "y": 250, "z": 250}, + "klipper_objects": [], "print_state": "standby"}, placement=placement) + source = next(c for c in out["checks"] if c["id"] == "bed.fit")["source"] + assert "Voron" in source and "U1" not in source From 32bb3325d767ff5c6e9c3cdcf347555b9a133698 Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Sat, 10 Oct 2026 12:29:16 -0400 Subject: [PATCH 07/12] fix(placement): printing roles propagate down through assemblies (#93 repair 6) One role rule (roles.py) now serves the scene and the placed footprint and height: - A non-printing role (modifier, negative, support blocker/enforcer) set on an assembly applies to every descendant; a child's own record is resolved against what it inherits and cannot print again. - Roles on nested objects are consulted at every level, not only for the top object. - A component the records do not mention is a normal part; conflicting records and unrecognised words are unknown, which counts toward the footprint (never falsely small) while the scene reports unknown. - The scene's role resolution and conflict merging now call the shared functions. Tests go through preflight, the Ready Now analysis and the scene for each of the four helper roles on a nested assembly with a printable sibling (role on the assembly and on the nested object's own record), a missing role, conflicting records, and a child that tries to print beneath a modifier. Verified: backend pytest 3226 passed / 49 skipped; desktop tsc, vitest (821 passed) and build clean. Co-Authored-By: Claude Sonnet 5.5 --- backend/snapstudio_core/placement.py | 32 +++-- backend/snapstudio_core/roles.py | 89 ++++++++++++++ backend/snapstudio_core/scene.py | 20 ++-- .../tests/test_role_inheritance_placement.py | 111 ++++++++++++++++++ 4 files changed, 231 insertions(+), 21 deletions(-) create mode 100644 backend/snapstudio_core/roles.py create mode 100644 backend/tests/test_role_inheritance_placement.py diff --git a/backend/snapstudio_core/placement.py b/backend/snapstudio_core/placement.py index 978178c2..148eedca 100644 --- a/backend/snapstudio_core/placement.py +++ b/backend/snapstudio_core/placement.py @@ -219,7 +219,7 @@ def read_objects(path: str, keep_points: bool = True) -> dict: problem the plate does not have. Whether the target agrees is a question for the target, and it is recorded in the project's own documentation. """ - from . import geometry, multipart + from . import geometry, multipart, roles from .container import ThreeMF # The same bounded reader and limits as the rest of the engine: a hostile or merely huge archive @@ -260,6 +260,7 @@ def _text(name): polygon = polygon_of(project) parts_by_object = multipart._parts_by_object(settings_text) if settings_text else {} + records = roles.read_records(settings_text) # One entry per BUILD ITEM, in document order. Keying by object id would keep only the last # transform of an object that several items use, and every instance would then be reported @@ -279,7 +280,6 @@ def _text(name): seen_per_object[who] = instance_index + 1 item = _units.scale_translation(parse_transform(transform_text), root_scale) part_ids = [part_id for part_id, _subtype in parts_by_object.get(object_id, [])] - roles = dict(parts_by_object.get(object_id, [])) item_part = entry["part"] or ROOT_MODEL explicit = entry["part"] is not None top = parts.get(item_part, {}).get("components", {}) @@ -290,13 +290,16 @@ def _text(name): resolved = False # a named part that does not hold the object: not the root's object entries = top.get(object_id) or [(None, part_id, None) for part_id in part_ids] leaves, ok = _leaves(entries, parts, item_part, explicit, root_scale if item_part == ROOT_MODEL - else parts.get(item_part, {}).get("scale", 1.0)) + else parts.get(item_part, {}).get("scale", 1.0), + owner=(item_part, object_id), records=records) resolved = resolved and ok - for leaf_part, mesh_id, component_transform in leaves: + for leaf_part, mesh_id, component_transform, role in leaves: points = parts[leaf_part]["meshes"][mesh_id] budget.take_moved(len(points)) whole = compose(item, component_transform) - is_part = roles.get(mesh_id, "normal_part") == "normal_part" + # the one role rule (see ``roles``): a volume counts toward the footprint unless a + # non-printing role, its own or inherited from an assembly above it, says it does not print + is_part = roles.counts_toward_size(role) for point in points: moved = apply(whole, point) every.add(moved) @@ -362,8 +365,12 @@ def _components_of(block: str) -> list: def _leaves(entries: list, parts: dict, part: str, explicit: bool, scale: float, - depth: int = 0, outer: tuple | None = None, seen: frozenset = frozenset()): - """``([(part, mesh id, transform in mm)], resolved)`` for every mesh an object reaches. + depth: int = 0, outer: tuple | None = None, seen: frozenset = frozenset(), + owner: tuple | None = None, records=None, role=None): + """``([(part, mesh id, transform in mm, role)], resolved)`` for every mesh an object reaches. + + ``role`` is what the volume inherits from the assemblies above it combined with the part record + for it under ``owner`` (the (part, id) of the object whose components ``entries`` are): see ``roles``. A component may itself be an object made of components, with its own transform; the transforms compose down the path rather than being applied one after the other. A @@ -387,13 +394,20 @@ def _leaves(entries: list, parts: dict, part: str, explicit: bool, scale: float, continue # absent (nothing to measure) or ambiguous (not guessed) here_part, holder = owners[0], parts[owners[0]] here = compose(outer, _units.scale_translation(parse_transform(text), scale)) + child_role = role + if records is not None and owner is not None and owner[0] == ROOT_MODEL: + from . import roles as _roles + + child_role = _roles.inherit(role, _roles.own_role( + records.part_subtypes, records.metadata_objects, owner[1], oid, missing=None)) points = holder["meshes"][oid] if points: - found.append((here_part, oid, here)) + found.append((here_part, oid, here, child_role)) elif holder["components"].get(oid) and (here_part, oid) not in seen \ and depth < _MAX_COMPONENT_DEPTH: more, more_ok = _leaves(holder["components"][oid], parts, here_part, False, - holder["scale"], depth + 1, here, seen | {(here_part, oid)}) + holder["scale"], depth + 1, here, seen | {(here_part, oid)}, + owner=(here_part, oid), records=records, role=child_role) found.extend(more) ok = ok and more_ok return found, ok diff --git a/backend/snapstudio_core/roles.py b/backend/snapstudio_core/roles.py new file mode 100644 index 00000000..58aa1fa0 --- /dev/null +++ b/backend/snapstudio_core/roles.py @@ -0,0 +1,89 @@ +"""Which volumes print: ONE rule for the scene, the sizes and the placed bounds. + +A project says what each part of an object is for in its ``model_settings.config`` part records +(``normal_part``, ``modifier_part``, ``negative_part``, ``support_blocker``, ``support_enforcer``). Only a +normal part prints; the others are instructions to the slicer. The rule, in one place so that the scene, an +object's size, its placed footprint and its height cannot disagree: + +* **A part record belongs to one (object, component) pair.** The record for component C under object O says + what C is *for the purpose of O*. +* **A non-printing role is inherited by everything beneath it.** A modifier set on an assembly applies to + every descendant; a descendant cannot print again by being a plain part. A child's own record is resolved + against what it inherits: ``inherit``. +* **No record means a normal part** when the project has no record for that component (``missing`` says + what the caller wants instead, the scene reports it as unknown). An object with no part records at all + passes its inherited role down unchanged. +* **Conflicting records** (two records for one (object, part) that disagree) and **unrecognised words** are + role ``unknown``. The scene reports unknown. A size or a footprint cannot prove such a volume does not + print, so it COUNTS (``counts_toward_size``): the answer can be too big, never falsely small. +* Part records are matched against objects of the root model. A build item whose object lives in another part + (``p:path``) has no provable records; its volumes inherit nothing and count. +""" +from __future__ import annotations + +#: Stands in for a subtype when two records for one (object, part) disagree; ``role_of`` reads it as unknown. +CONFLICTING_SUBTYPE = "conflicting_records" + + +def merge_record(parts: dict, part_id: str, subtype: str | None) -> None: + """Record ``part_id``'s subtype; a second, different record makes it the conflicting marker.""" + if part_id in parts and parts[part_id] != subtype: + subtype = CONFLICTING_SUBTYPE + parts[part_id] = subtype + + +class Records: + """The part records of a settings file: ``subtypes[object id][part id]`` and the objects that have any.""" + + def __init__(self) -> None: + self.part_subtypes: dict[str, dict[str, str | None]] = {} + self.metadata_objects: set[str] = set() + + +def read_records(settings_xml: str) -> Records: + """Part records from the text of a ``model_settings.config`` (empty if it cannot be read).""" + from .config_io import load_model_settings + + out = Records() + if not settings_xml: + return out + try: + root = load_model_settings(settings_xml.encode("utf-8")) + for obj in root.iter("object"): + oid = obj.get("id") or "" + parts = out.part_subtypes.setdefault(oid, {}) + for part in obj.iterchildren("part"): + out.metadata_objects.add(oid) + pid = part.get("id") + if pid is not None: + merge_record(parts, pid, part.get("subtype")) + except Exception: # noqa: BLE001 - unreadable settings mean no records, never a crash + return Records() + return out + + +def own_role(part_subtypes: dict, metadata_objects, object_id: str, via: str, *, missing): + """The role the project's records give component ``via`` of object ``object_id``, or None if the + object has no part records. ``missing`` is returned for an object that HAS records but none for ``via``.""" + from .assignments import role_of + + subtypes = part_subtypes.get(object_id) + if object_id in metadata_objects and subtypes is not None: + return role_of(subtypes[via]) if via in subtypes else missing + return None + + +def inherit(inherited, own): + """The role of a node from the role it inherits and its own record: a non-printing inherited role wins.""" + from .assignments import PART + + if inherited not in (None, PART): + return inherited + return own if own is not None else inherited + + +def counts_toward_size(role) -> bool: + """Whether a volume with this role counts when measuring what prints (see the module docstring).""" + from .assignments import PART, ROLE_UNKNOWN + + return role in (None, PART, ROLE_UNKNOWN) diff --git a/backend/snapstudio_core/scene.py b/backend/snapstudio_core/scene.py index b4a61810..b08f9773 100644 --- a/backend/snapstudio_core/scene.py +++ b/backend/snapstudio_core/scene.py @@ -46,6 +46,7 @@ from lxml import etree from . import scene_limits as L +from . import roles as _roles from . import units as _units from .errors import SnapStudioError @@ -627,7 +628,8 @@ def find_root_model(arc: Archive) -> str: #: Stands in for a subtype when two records for one (object, part) disagree; ``role_of`` reads it as unknown. -CONFLICTING_SUBTYPE = "conflicting_records" +#: Defined once, in ``roles``, with the rest of the role rule the sizes and footprints share. +CONFLICTING_SUBTYPE = _roles.CONFLICTING_SUBTYPE class SettingsInfo: @@ -665,10 +667,7 @@ def parse_settings(arc: Archive, ctl: Control) -> SettingsInfo: pid = p.get("id") if pid is None: continue - subtype = p.get("subtype") - if pid in parts and parts[pid] != subtype: - subtype = CONFLICTING_SUBTYPE - parts[pid] = subtype + _roles.merge_record(parts, pid, p.get("subtype")) _free(elem) elif elem.tag == "plate": if len(info.plates) >= 256: @@ -818,13 +817,10 @@ def _context_role(self, parent: Node | None, via: str | None, inherited: str | N return "unknown" own = None if parent is not None and via is not None and self.settings.dialect == "bambu" and parent.resource[0] == self.root_part: - subtypes = self.settings.part_subtypes.get(parent.resource[1]) - if parent.resource[1] in self.settings.metadata_objects and subtypes is not None: - from .assignments import role_of - own = role_of(subtypes[via]) if via in subtypes else "unknown" - if inherited not in (None, "part"): - return inherited - return own if own is not None else inherited + # the one shared role rule; here a component an object's records do not mention is unknown + own = _roles.own_role(self.settings.part_subtypes, self.settings.metadata_objects, + parent.resource[1], via, missing="unknown") + return _roles.inherit(inherited, own) def _expand(self, key, local, parent: Node | None, path: list[int], ctx: TopContext, stack: tuple, via: str | None = None, inherited: str | None = None) -> Node: diff --git a/backend/tests/test_role_inheritance_placement.py b/backend/tests/test_role_inheritance_placement.py new file mode 100644 index 00000000..87430f1e --- /dev/null +++ b/backend/tests/test_role_inheritance_placement.py @@ -0,0 +1,111 @@ +"""Printing roles propagate down through assemblies (one rule, ``roles``), seen through the service entry +points: preflight, Ready Now analysis, and the scene, for the placed footprint and height. + +A 300 mm column sits under a nested assembly next to a 10 mm printable sibling. If the volume's role says it +does not print, it must not make the object look too tall; if no role says so (missing record), or the +records conflict, it counts.""" +from __future__ import annotations + +import pytest + +from snapstudio_api import service +from snapstudio_core import plate_placement as pp, scene +from tests import scene_fixtures as fx +from tests.test_travel_and_move_writer import _facts + +SETTINGS = "Metadata/model_settings.config" +HELPER_ROLES = ["negative_part", "modifier_part", "support_blocker", "support_enforcer"] +REV = "ab" * 32 + + +def pillar(oid, height, origin=(40.0, 0.0, 0.0)): + """A 10 x 10 mm column `height` tall: tall, but small enough to sit on the plate sideways.""" + ox, oy, oz = origin + verts = [(ox, oy, oz), (ox + 10, oy, oz), (ox + 10, oy + 10, oz), (ox, oy + 10, oz), + (ox, oy, oz + height), (ox + 10, oy, oz + height), (ox + 10, oy + 10, oz + height), + (ox, oy + 10, oz + height)] + return f'{fx.mesh_xml(verts, fx.CUBE_TRIS)}' + + +def nested_assembly(tmp_path, records: dict, name="nested.3mf"): + """Root object 100 = printable mesh 1 (10 mm) + assembly 5; assembly 5 = mesh 3 (300 mm). + ``records`` is the settings objects: {object id: [(part id, subtype), ...]}.""" + sub = fx.sub_model_xml([fx.cube_object("1", 10), pillar("3", 300)]) + assembly = fx.composite_object("5", [("3", "/3D/Objects/o.model", None)]) + top = fx.composite_object("100", [("1", "/3D/Objects/o.model", None), ("5", None, None)]) + root = fx.model_xml([assembly, top], [("100", fx.tf(20, 20, 0))]) + settings = fx.model_settings_xml(records, [(1, [("100", 0)])]) + return str(fx.three_mf(tmp_path / name, root, {"3D/Objects/o.model": sub, SETTINGS: settings})) + + +def on_assembly(role): + return {"100": [("1", "normal_part"), ("5", role)]} + + +def on_nested_record(role): + return {"100": [("1", "normal_part"), ("5", "normal_part")], "5": [("3", role)]} + + +U1 = {"printer_id": "snapmaker_u1"} + + +def bed_fit_check(path, monkeypatch): + monkeypatch.setattr(service, "printer_facts", _facts(U1)) + return next(c for c in service.preflight(path, "h")["checks"] if c["id"] == "bed.fit") + + +def ready_placement(path): + target = service._placement_target({"identity": U1}) + service._READY_CACHE.clear() + return service._ready_analysis(path, target, need_placement=True)[2] + + +@pytest.mark.parametrize("where", [on_assembly, on_nested_record], ids=["on_assembly", "on_nested_record"]) +@pytest.mark.parametrize("role", HELPER_ROLES) +def test_a_helper_role_applies_to_everything_beneath_it(tmp_path, monkeypatch, role, where): + path = nested_assembly(tmp_path, where(role)) + # preflight: the 300 mm helper volume is not height the print has + check = bed_fit_check(path, monkeypatch) + assert check["result"] == "ok" and check["confidence"] == "confirmed", check["evidence"] + # Ready Now: the same placement, from the cached analysis + placement = ready_placement(path) + [row] = placement["items"] + assert row["bounds_mm"]["max"][2] == 10.0 and row["bounds_mm"]["max"][0] == 30.0 + assert pp.over_height(placement) == [] and placement["off_plate"] == [] + + +@pytest.mark.parametrize("role", HELPER_ROLES) +def test_the_scene_and_the_footprint_agree_that_the_volume_does_not_print(tmp_path, role): + path = nested_assembly(tmp_path, on_assembly(role)) + sc = scene.build_scene_dict(path, REV) + leaf = next(n for n in sc["nodes"] if n["resource"]["object_id"] == "3") + assert leaf["printable"] is False and leaf["role_context"] not in (None, "part") + top = next(n for n in sc["nodes"] if n["id"] == "b0") + assert top["bounds_mm"]["max"][2] == 10.0 # the scene's world bounds: printing parts only + [row] = pp.assess(path)["items"] + assert row["bounds_mm"]["max"] == top["bounds_mm"]["max"] + + +def test_a_missing_role_is_a_normal_part_and_counts(tmp_path, monkeypatch): + path = nested_assembly(tmp_path, {"100": [("1", "normal_part")]}) # no record for the assembly + check = bed_fit_check(path, monkeypatch) + assert check["result"] == "attention" and "300" in check["evidence"] and "height limit" in check["evidence"] + assert pp.over_height(ready_placement(path)) + + +def test_conflicting_records_are_unknown_and_count_never_falsely_small(tmp_path, monkeypatch): + path = nested_assembly(tmp_path, {"100": [("1", "normal_part"), ("5", "modifier_part"), ("5", "normal_part")]}) + check = bed_fit_check(path, monkeypatch) + assert check["result"] == "attention" and "300" in check["evidence"] + sc = scene.build_scene_dict(path, REV) + leaf = next(n for n in sc["nodes"] if n["resource"]["object_id"] == "3") + assert leaf["printable"] is None # the scene reports unknown + assert pp.over_height(ready_placement(path)) # the footprint cannot prove it away + + +def test_a_child_cannot_print_again_beneath_a_non_printing_assembly(tmp_path, monkeypatch): + # the assembly is a modifier; the record for its child says normal_part: the modifier still wins + records = {"100": [("1", "normal_part"), ("5", "modifier_part")], "5": [("3", "normal_part")]} + path = nested_assembly(tmp_path, records) + assert bed_fit_check(path, monkeypatch)["result"] == "ok" + assert pp.assess(path)["items"][0]["bounds_mm"]["max"][2] == 10.0 From ac6d446b19a824b8020285d98f760cfb1cc6e183 Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Sat, 10 Oct 2026 13:18:54 -0400 Subject: [PATCH 08/12] fix(placement): role-aware overall extents; Orca precise-seam helper roles (#93 repair 7) - The model's overall extents (project_info.dimensions_mm, used by the Bed-Fit API, strategies, canonical) skip meshes that are only reached as non-printing volumes, by the same role rule as the footprint and size (geometry.nonprinting_meshes). The placed item bounds that Layout and Scale Doctor read are those of what prints too. Free when no role other than a normal part is recorded. - Orca's precise_seam_* subtypes are known non-printing helper roles (the modifier role) instead of unknown. - One fixture, every consumer: extents, placed items, layout, preflight, Ready Now and the scene agree; a missing record and an unrecognised word still count. Audited and left as they were: mesh_diagnostics / load_mesh analyse the whole mesh (volume, overhang, stability), not bed fit. Verified: backend pytest 3244 passed / 49 skipped; desktop tsc, vitest (821 passed) and build clean. Co-Authored-By: Claude Sonnet 5.5 --- backend/snapstudio_core/assignments.py | 10 ++ backend/snapstudio_core/geometry.py | 103 +++++++++++++++++- backend/snapstudio_core/intelligence.py | 20 +++- .../tests/test_role_inheritance_placement.py | 44 ++++++++ 4 files changed, 171 insertions(+), 6 deletions(-) diff --git a/backend/snapstudio_core/assignments.py b/backend/snapstudio_core/assignments.py index 09a94b3d..dcccc0e1 100644 --- a/backend/snapstudio_core/assignments.py +++ b/backend/snapstudio_core/assignments.py @@ -66,6 +66,16 @@ "negative_part": NEGATIVE, "support_enforcer": SUPPORT_ENFORCER, "support_blocker": SUPPORT_BLOCKER, + # Orca's precise-seam helper volumes (written by Orca 2.4.2 and later). Like a modifier they steer the + # slicer and print nothing, so they are the modifier role. The strings are taken from Orca's own + # subtype list as reported to us; Snapmaker Orca 2.4.0 may not write them, and they are treated as + # known helper roles regardless. Any other word stays unknown (and so counts), see ``roles``. + "precise_seam_center": MODIFIER, + "precise_seam_left": MODIFIER, + "precise_seam_right": MODIFIER, + "precise_seam_enforced": MODIFIER, + "precise_seam_blocked": MODIFIER, + "precise_seam_neutral": MODIFIER, } #: Object-level keys that are the assignment itself or the object's identity, diff --git a/backend/snapstudio_core/geometry.py b/backend/snapstudio_core/geometry.py index fae52466..997ecf8e 100644 --- a/backend/snapstudio_core/geometry.py +++ b/backend/snapstudio_core/geometry.py @@ -9,9 +9,11 @@ of stalling. """ from __future__ import annotations +import re from dataclasses import dataclass from pathlib import Path +from . import roles as _roles from .container import ThreeMF from .config_io import load_model_settings from . import units as _units @@ -104,6 +106,7 @@ def appA(v): ROOT_MODEL = "3D/3dmodel.model" +SETTINGS_PART = "Metadata/model_settings.config" #: What a malformed ``p:path`` becomes. It names no archive entry, so an item or component that carries @@ -147,6 +150,72 @@ def build_items(root: bytes | str) -> list[dict]: return out +_OBJECT_TAG = re.compile(rb"]*>||]*/>| set: + """``{(part, object id)}`` of mesh objects that are reached ONLY as non-printing volumes. + + The same role rule as the footprint and the sizes (``roles``), for the callers that read meshes + straight from the archive (``intelligence``'s overall extents). A mesh reached by any printing path + still counts. Costs nothing when the project records no part role other than a normal part.""" + from .assignments import PART, role_of + + try: + settings = tm.read_part(SETTINGS_PART).decode("utf-8", "ignore") if tm.has_part(SETTINGS_PART) else "" + records = _roles.read_records(settings) + if not any(role_of(sub) != PART for subs in records.part_subtypes.values() for sub in subs.values()): + return set() + objects: dict = {} + for name in tm.list_parts(): + if not name.endswith(".model"): + continue + current = None + table = objects.setdefault(name, {}) + for m in _OBJECT_TAG.finditer(tm.read_part(name)): + token = m.group(0) + if token.startswith(b"": + current = None + elif token.startswith(b" 64: + return + if obj["mesh"]: + (printing if _roles.counts_toward_size(role) else helper).add((part, oid)) + for cpart, cid in obj["comps"]: + child = role + if part == ROOT_MODEL: + child = _roles.inherit(role, _roles.own_role( + records.part_subtypes, records.metadata_objects, oid, cid, missing=None)) + walk(cpart or part, cid, child, trail | {(part, oid)}) + + for item in build_items(tm.read_part(ROOT_MODEL)): + walk(item["part"] or ROOT_MODEL, item["object_id"], None, frozenset()) + return helper - printing + except Exception: # noqa: BLE001 - never make a size query fail; fall back to counting everything + return set() + + def build_item_dims(path: str) -> list[dict]: """Per-build-item bounding-box dimensions (mm), with the 3MF build transform and nested component transforms applied — i.e. each placed object's real on-plate @@ -223,7 +292,7 @@ def find_obj(objid, part, explicit): budget = [0] - def collect(objid, part, explicit, xform, acc, seen, bad): + def collect(objid, part, explicit, xform, acc, seen, bad, printing=None, role=None, records=None): f = find_obj(objid, part, explicit) if f is None: bad.append(f"object {objid} is not in {part}") @@ -233,31 +302,55 @@ def collect(objid, part, explicit, xform, acc, seen, bad): return seen = seen | {key} verts, comps = parsed[f][objid] + # a volume whose role (its own, or inherited from an assembly above it) is a modifier, negative + # volume or support helper is an instruction to the slicer, not something that prints: it is in + # `acc` but not `printing`. One rule for this everywhere: see ``roles``. + prints = _roles.counts_toward_size(role) for v in verts: - acc.append(_apply(v, xform)) + moved = _apply(v, xform) + acc.append(moved) + if printing is not None and prints: + printing.append(moved) budget[0] += 1 if budget[0] > _MAX_VERTS: raise _TooLarge() for cid, cpart, ctf in comps: - collect(cid, cpart or f, cpart is not None, _compose(xform, ctf), acc, seen, bad) + child_role = role + if records is not None and f == root_file: + # part records are matched against root-model objects; a missing record is a normal part + child_role = _roles.inherit(role, _roles.own_role( + records.part_subtypes, records.metadata_objects, objid, cid, missing=None)) + collect(cid, cpart or f, cpart is not None, _compose(xform, ctf), acc, seen, bad, + printing, child_role, records) + + try: + 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() out = [] unresolved = [] for it in build_items(model_files[root_file]): oid = it["object_id"] acc: list = [] + printing: list = [] bad: list = [] part = it["part"] or root_file collect(oid, part, it["part"] is not None, _units.scale_translation(_xform(it["transform"]), unit_scale[root_file]), - acc, frozenset(), bad) + acc, frozenset(), bad, printing, None, records) if bad: unresolved.append({"item_index": it["item_index"], "object_id": oid, "part": part, "reason": bad[0]}) continue if not acc: continue - xs = [p[0] for p in acc]; ys = [p[1] for p in acc]; zs = [p[2] for p in acc] + # 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)) # `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). diff --git a/backend/snapstudio_core/intelligence.py b/backend/snapstudio_core/intelligence.py index 0dd30bec..18aca0cc 100644 --- a/backend/snapstudio_core/intelligence.py +++ b/backend/snapstudio_core/intelligence.py @@ -21,6 +21,13 @@ _VERT_RE = re.compile(rb']*\bx="(-?[\d.eE+]+)"[^>]*\by="(-?[\d.eE+]+)"[^>]*\bz="(-?[\d.eE+]+)"') _TRI_RE = re.compile(rb"]*>.*?", re.S) +_ID_RE = re.compile(rb'\bid="([^"]*)"') + + +def _object_id(block: bytes) -> str: + found = _ID_RE.search(block[:block.index(b">") + 1]) + return found.group(1).decode("utf-8", "replace") if found else "" def _bbox_and_triangles(tm: ThreeMF): @@ -36,12 +43,23 @@ def _bbox_and_triangles(tm: ThreeMF): lo = [float("inf")] * 3 hi = [float("-inf")] * 3 seen = False + # Meshes that are only ever non-printing volumes (modifiers, negative volumes, support helpers) are not + # part of the model's extents: the same role rule as the placed bounds and the sizes (see ``roles``). + from . import geometry + + helpers = geometry.nonprinting_meshes(tm) for p in model_parts: raw = tm.read_part(p) # Each part's coordinates are in the unit its own header declares; the result is # millimetres. Millimetre parts (the default) are multiplied by exactly 1.0. scale = _units.mm_per_unit(raw) - for m in _VERT_RE.finditer(raw): + if any(part == p for part, _oid in helpers): + matches = (m for block in _OBJECT_BLOCK_RE.finditer(raw) + if (p, _object_id(block.group(0))) not in helpers + for m in _VERT_RE.finditer(block.group(0))) + else: + matches = _VERT_RE.finditer(raw) + for m in matches: seen = True for i in range(3): v = float(m.group(i + 1)) * scale diff --git a/backend/tests/test_role_inheritance_placement.py b/backend/tests/test_role_inheritance_placement.py index 87430f1e..8beaf5c0 100644 --- a/backend/tests/test_role_inheritance_placement.py +++ b/backend/tests/test_role_inheritance_placement.py @@ -109,3 +109,47 @@ def test_a_child_cannot_print_again_beneath_a_non_printing_assembly(tmp_path, mo path = nested_assembly(tmp_path, records) assert bed_fit_check(path, monkeypatch)["result"] == "ok" assert pp.assess(path)["items"][0]["bounds_mm"]["max"][2] == 10.0 + + +# --- whole-model extents and the other size consumers share the rule ------------------------------------- + +SEAM_WORDS = ["precise_seam_center", "precise_seam_left", "precise_seam_right", + "precise_seam_enforced", "precise_seam_blocked", "precise_seam_neutral"] + + +@pytest.mark.parametrize("role", HELPER_ROLES + SEAM_WORDS) +def test_every_consumer_of_the_extents_agrees_that_a_helper_volume_does_not_count(tmp_path, monkeypatch, role): + from snapstudio_core import geometry, layout + from snapstudio_core.intelligence import project_info + path = nested_assembly(tmp_path, on_assembly(role)) + assert project_info(path)["dimensions_mm"] == {"x": 10.0, "y": 10.0, "z": 10.0} # overall extents + assert [i["dimensions"]["z"] for i in geometry.build_item_dims(path)] == [10.0] # placed items + assert layout.assess_layout(path)["status"] == "pass" # plate-fit layout + check = bed_fit_check(path, monkeypatch) # preflight + assert check["result"] == "ok" and check["confidence"] == "confirmed" + assert pp.over_height(ready_placement(path)) == [] # Ready Now + sc = scene.build_scene_dict(path, REV) # scene + assert next(n for n in sc["nodes"] if n["resource"]["object_id"] == "3")["printable"] is False + + +def test_the_overall_extents_still_count_everything_when_no_role_says_otherwise(tmp_path): + from snapstudio_core.intelligence import project_info + path = nested_assembly(tmp_path, {"100": [("1", "normal_part")]}) + assert project_info(path)["dimensions_mm"]["z"] == 300.0 + + +@pytest.mark.parametrize("word", SEAM_WORDS) +def test_orcas_precise_seam_helpers_are_known_non_printing_roles(tmp_path, monkeypatch, word): + from snapstudio_core.assignments import MODIFIER, ROLE_UNKNOWN, role_of + assert role_of(word) == MODIFIER and role_of(word) != ROLE_UNKNOWN + path = nested_assembly(tmp_path, on_nested_record(word)) + assert bed_fit_check(path, monkeypatch)["result"] == "ok" + sc = scene.build_scene_dict(path, REV) + assert "UNKNOWN_VOLUME_ROLE" not in [l["code"] for l in sc["limitations"]] + + +def test_an_unrecognised_role_word_stays_conservative(tmp_path, monkeypatch): + path = nested_assembly(tmp_path, on_assembly("precise_seam_sideways")) + assert bed_fit_check(path, monkeypatch)["result"] == "attention" # counts: 300 mm tall + from snapstudio_core.intelligence import project_info + assert project_info(path)["dimensions_mm"]["z"] == 300.0 From cabbbdf0c0bf9399f8dc1a057e9fed773ce4709e Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Wed, 14 Oct 2026 04:15:52 -0400 Subject: [PATCH 09/12] test(placement): the cross-consumer test also drives service.bed_fit and the validation report (#93) Co-Authored-By: Claude Sonnet 5.5 --- backend/tests/test_role_inheritance_placement.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/tests/test_role_inheritance_placement.py b/backend/tests/test_role_inheritance_placement.py index 8beaf5c0..b5c1556f 100644 --- a/backend/tests/test_role_inheritance_placement.py +++ b/backend/tests/test_role_inheritance_placement.py @@ -119,10 +119,20 @@ def test_a_child_cannot_print_again_beneath_a_non_printing_assembly(tmp_path, mo @pytest.mark.parametrize("role", HELPER_ROLES + SEAM_WORDS) def test_every_consumer_of_the_extents_agrees_that_a_helper_volume_does_not_count(tmp_path, monkeypatch, role): + """One fixture (a 10 mm printable part beside a 300 mm helper volume under a nested assembly), every + consumer through its own service entry point: the overall extents, the placed items, the layout, preflight, + Ready Now, the Bed-Fit API (``service.bed_fit``), the validation report (``service.report``) and the scene. + At this head the Bed-Fit API and the report read the overall extents (``project_info``), which are + role-aware; the per-object size path is #91's and is asserted there.""" from snapstudio_core import geometry, layout from snapstudio_core.intelligence import project_info path = nested_assembly(tmp_path, on_assembly(role)) assert project_info(path)["dimensions_mm"] == {"x": 10.0, "y": 10.0, "z": 10.0} # overall extents + fit = service.bed_fit(path) # Bed-Fit API + assert fit["overall_level"] == "ok", fit["overall_text"] + assert fit["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"] # validation report assert [i["dimensions"]["z"] for i in geometry.build_item_dims(path)] == [10.0] # placed items assert layout.assess_layout(path)["status"] == "pass" # plate-fit layout check = bed_fit_check(path, monkeypatch) # preflight @@ -136,6 +146,9 @@ def test_the_overall_extents_still_count_everything_when_no_role_says_otherwise( from snapstudio_core.intelligence import project_info path = nested_assembly(tmp_path, {"100": [("1", "normal_part")]}) assert project_info(path)["dimensions_mm"]["z"] == 300.0 + assert service.bed_fit(path)["overall_level"] == "risk" # the control + report = next(c for c in service.report(path)["checks"] if c["name"] == "Fits the print bed") + assert report["status"] == "warn" @pytest.mark.parametrize("word", SEAM_WORDS) From 8b8ddcaa31d701ba68e1798bf90df0993ba54533 Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Wed, 21 Oct 2026 18:19:52 -0400 Subject: [PATCH 10/12] fix(placement): role-aware overall extents without unit conversion; valid fixture parts Overall extents skip non-printing helper volumes again (intelligence.py) while keeping main's geometry_basis guard. The shared scene fixture writes [Content_Types].xml so the projects it builds pass Studio's readability gate. Co-Authored-By: Claude Sonnet 5.5 --- backend/snapstudio_core/intelligence.py | 9 ++++++++- backend/tests/scene_fixtures.py | 10 +++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/backend/snapstudio_core/intelligence.py b/backend/snapstudio_core/intelligence.py index 3b2f314f..2df111ac 100644 --- a/backend/snapstudio_core/intelligence.py +++ b/backend/snapstudio_core/intelligence.py @@ -49,7 +49,14 @@ def _bbox_and_triangles(tm: ThreeMF): helpers = geometry.nonprinting_meshes(tm) for p in model_parts: - for m in _VERT_RE.finditer(tm.read_part(p)): + raw = tm.read_part(p) + if any(part == p for part, _oid in helpers): + matches = (m for block in _OBJECT_BLOCK_RE.finditer(raw) + if (p, _object_id(block.group(0))) not in helpers + for m in _VERT_RE.finditer(block.group(0))) + else: + matches = _VERT_RE.finditer(raw) + for m in matches: seen = True for i in range(3): v = float(m.group(i + 1)) diff --git a/backend/tests/scene_fixtures.py b/backend/tests/scene_fixtures.py index a4c30b64..2086b8b5 100644 --- a/backend/tests/scene_fixtures.py +++ b/backend/tests/scene_fixtures.py @@ -82,8 +82,16 @@ def write_zip(path, entries: dict, order=None) -> Path: return path +# The part every readable 3MF project carries; Studio's readability gate (eligibility.is_readable) requires it. +CONTENT_TYPES_XML = ('' + '' + '' + '' + '') + + def three_mf(path, root_model: str, extra: dict | None = None, rels: str | None = None) -> Path: - entries = {"_rels/.rels": rels or rels_xml(), "3D/3dmodel.model": root_model} + entries = {"[Content_Types].xml": CONTENT_TYPES_XML, "_rels/.rels": rels or rels_xml(), "3D/3dmodel.model": root_model} entries.update(extra or {}) return write_zip(path, entries) From 4c5a07c6c4c6f450f9d9f4d56a5e08ef5c710a99 Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Wed, 21 Oct 2026 22:22:53 -0400 Subject: [PATCH 11/12] fix(placement): no placement, height or plate verdict without an established size The placement path (plate_placement.assess, placement.assess and so the preflight bed check, ready-now and the move writer) read raw file numbers against the plate with no geometry-basis gate. Both now return not checked, with the shared reason and no numbers, when the size is not established. Scaled or rotated items are no longer moved; their tests now pin the refusal. Adds translation-only height and below-plate tests and an inch project test. Verified: backend pytest 3548 passed, 49 skipped. Co-Authored-By: Claude Sonnet 5.5 --- backend/snapstudio_core/placement.py | 6 +++ backend/snapstudio_core/plate_placement.py | 8 +++- .../test_part_paths_and_move_verifier.py | 10 +++++ backend/tests/test_plate_placement.py | 13 +++--- backend/tests/test_travel_and_move_writer.py | 44 +++++++++++++++---- 5 files changed, 65 insertions(+), 16 deletions(-) diff --git a/backend/snapstudio_core/placement.py b/backend/snapstudio_core/placement.py index 831e84cf..03b15235 100644 --- a/backend/snapstudio_core/placement.py +++ b/backend/snapstudio_core/placement.py @@ -623,6 +623,12 @@ def assess(path: str, printer: str = "Snapmaker U1") -> dict: Where an object sits and whether the target can print it there are two facts, and this returns both. """ + from . import geometry_basis + + basis = geometry_basis.assess(path) + if not geometry_basis.is_established(basis): + return dict({"schema_version": SCHEMA_VERSION, "available": False, "reason": basis["summary"]}, + **geometry_basis.not_checked(basis, "placement check")) try: read = read_objects(path) except Exception as exc: # noqa: BLE001 — a report, never a crash diff --git a/backend/snapstudio_core/plate_placement.py b/backend/snapstudio_core/plate_placement.py index 0661cfe7..73d458ef 100644 --- a/backend/snapstudio_core/plate_placement.py +++ b/backend/snapstudio_core/plate_placement.py @@ -420,7 +420,7 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None, measuring against a rectangle a different printer reported is describing the wrong machine. """ - from . import geometry, project_traits + from . import geometry, geometry_basis, project_traits target = bed or u1_bed_rect() whose = bed_name or ("this printer's" if bed else f"the {_prepare_target_name()}'s") @@ -430,6 +430,12 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None, except Exception: return _unavailable("Studio could not open this file as a 3MF project.") + basis = geometry_basis.assess(path) + if not geometry_basis.is_established(basis): + # Positions and heights are in the file's own numbers; with no established physical size they + # are never read against the plate, so there is no on-plate / over-height / below-plate verdict. + return dict(_unavailable(basis["summary"]), **geometry_basis.not_checked(basis, "placement check")) + traits = project_traits.extract(path) plate_count = (traits.get("plate_count") or {}).get("value") or 1 diff --git a/backend/tests/test_part_paths_and_move_verifier.py b/backend/tests/test_part_paths_and_move_verifier.py index bc9593e9..3bd450c1 100644 --- a/backend/tests/test_part_paths_and_move_verifier.py +++ b/backend/tests/test_part_paths_and_move_verifier.py @@ -4,6 +4,8 @@ import zipfile +import pytest + from snapstudio_core import geometry, placement, plate_placement as pp from tests import scene_fixtures as fx @@ -25,6 +27,14 @@ def _two_parts(tmp_path, items, objects=(), settings=None, a_size=10, b_size=50, return fx.three_mf(tmp_path / name, fx.model_xml(list(objects), items), extra) +@pytest.fixture(autouse=True) +def _keying_not_the_gate(monkeypatch): + """These tests pin how objects are keyed by (part, id). The geometry-basis gate cannot follow a + build item that names its part, so it is stood in for here; its behaviour is tested elsewhere.""" + from snapstudio_core import geometry_basis + monkeypatch.setattr(geometry_basis, "assess", lambda path: {"established": True}) + + # --- keyed by (part, object id) ------------------------------------------------------------------------ def test_object_one_in_two_parts_stays_two_objects(tmp_path): diff --git a/backend/tests/test_plate_placement.py b/backend/tests/test_plate_placement.py index 3296b0eb..aa23a4ca 100644 --- a/backend/tests/test_plate_placement.py +++ b/backend/tests/test_plate_placement.py @@ -172,17 +172,16 @@ def test_fix_never_modifies_the_original(tmp_path): assert open(p, "rb").read() == before -def test_fix_preserves_rotation_and_scale(tmp_path): - """A creator's rotated, scaled object must come back rotated and scaled.""" +def test_a_rotated_scaled_object_is_not_moved_because_its_size_is_not_established(tmp_path): + """A creator's rotated, scaled object is left alone: the move is withheld, not guessed.""" rotated_scaled = "0 2 0 -2 0 0 0 0 2 320 100 0" p = _write(tmp_path, "rot.3mf", [(1, rotated_scaled)], printable_area=["0x0", "350x0", "350x350", "0x350"]) + # RC2: a scaled or rotated item has no established physical size, so Studio does not move it + # (and so cannot rewrite its matrix); it says why and writes nothing. res = pp.prepare_placed_copy(p, out_dir=str(tmp_path / "out")) - assert res["ok"] is True - with zipfile.ZipFile(res["output_path"]) as z: - model = z.read("3D/3dmodel.model").decode() - matrix = model.split('transform="')[1].split('"')[0].split() - assert matrix[:9] == ["0", "2", "0", "-2", "0", "0", "0", "0", "2"] + assert res["ok"] is False and res["before"]["status"] == "not_checked" + assert not (tmp_path / "out").exists() def test_fix_preserves_relative_layout_between_objects(tmp_path): diff --git a/backend/tests/test_travel_and_move_writer.py b/backend/tests/test_travel_and_move_writer.py index 03670df7..90c523e9 100644 --- a/backend/tests/test_travel_and_move_writer.py +++ b/backend/tests/test_travel_and_move_writer.py @@ -70,15 +70,13 @@ def test_the_move_writer_keeps_every_other_token_verbatim(): assert tokens[9] == "106.123457" and tokens[10] == "200" and tokens[11] == "5" -def test_a_rotated_project_is_moved_and_verified(tmp_path): +def test_a_rotated_project_is_not_moved_because_its_size_is_not_established(tmp_path): rot = "0.7071067812 0.7071067812 0 -0.7071067812 0.7071067812 0 0 0 1" src = fx.three_mf(tmp_path / "rot.3mf", fx.model_xml( [fx.cube_object("1", 10)], [("1", f"{rot} -5 100 0"), ("1", f"{rot} 100 100 0")])) result = pp.prepare_placed_copy(str(src), out_dir=str(tmp_path / "out")) - assert result["ok"] is True, result.get("reason") - assert result["verification"]["passed"] is True - with zipfile.ZipFile(result["output_path"]) as z: - assert z.read("3D/3dmodel.model").decode().count(rot) == 2 + assert result["ok"] is False and result["before"]["status"] == "not_checked" + assert not (tmp_path / "out").exists() def test_a_tall_object_is_not_confirmed_by_preflight_even_when_it_is_on_the_plate(tmp_path, monkeypatch): @@ -106,10 +104,21 @@ def test_a_tall_modifier_does_not_make_a_short_part_over_height(tmp_path): assert len(pp.over_height(pp.assess(path2))) == 1 -def test_a_part_sunk_below_the_build_plane_is_not_confirmed_to_fit(tmp_path, monkeypatch): - # a 10 mm cube scaled 30x in Z and moved to Z=-100 spans -100..200: under 270, but below the plate - path = str(fx.three_mf(tmp_path / "sunk.3mf", fx.model_xml( +def test_a_scaled_part_is_not_judged_for_height_or_plate_at_all(tmp_path, monkeypatch): + path = str(fx.three_mf(tmp_path / "scaled.3mf", fx.model_xml( [fx.cube_object("1", 10)], [("1", "1 0 0 0 1 0 0 0 30 100 100 -100")]))) + report = pp.assess(path) + assert report["available"] is False and report["status"] == "not_checked" + assert pp.below_plate(report) == [] and pp.over_height(report) == [] + monkeypatch.setattr(service, "printer_facts", _facts({"printer_id": "snapmaker_u1"})) + check = next(c for c in service.preflight(path, "h")["checks"] if c["id"] == "bed.fit") + assert check["confidence"] != "confirmed" and "270" not in check["evidence"] + + +def test_a_translated_part_below_the_plate_is_a_point_to_check(tmp_path, monkeypatch): + # translation only, so the size is established: a 10 mm cube moved to Z=-100 sits below the plate + path = str(fx.three_mf(tmp_path / "sunk.3mf", fx.model_xml( + [fx.cube_object("1", 10)], [("1", fx.tf(100, 100, -100))]))) assert len(pp.below_plate(pp.assess(path))) == 1 and pp.over_height(pp.assess(path)) == [] monkeypatch.setattr(service, "printer_facts", _facts({"printer_id": "snapmaker_u1"})) check = next(c for c in service.preflight(path, "h")["checks"] if c["id"] == "bed.fit") @@ -117,6 +126,12 @@ def test_a_part_sunk_below_the_build_plane_is_not_confirmed_to_fit(tmp_path, mon assert "below the build plate" in check["evidence"] and "-100" in check["evidence"] +def test_a_translated_part_above_the_height_limit_is_over_height(tmp_path): + path = str(fx.three_mf(tmp_path / "high.3mf", fx.model_xml( + [fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 300))]))) + assert len(pp.over_height(pp.assess(path))) == 1 + + def test_a_part_resting_on_the_plate_is_not_below_it(tmp_path): path = str(fx.three_mf(tmp_path / "flat.3mf", fx.model_xml([fx.cube_object("1", 10)], [("1", fx.tf(100, 100, 0))]))) assert pp.below_plate(pp.assess(path)) == [] @@ -131,3 +146,16 @@ def test_the_bed_check_source_names_the_printer_it_judged_against(): "klipper_objects": [], "print_state": "standby"}, placement=placement) source = next(c for c in out["checks"] if c["id"] == "bed.fit")["source"] assert "Voron" in source and "U1" not in source + + +def test_an_inch_project_gets_no_placement_or_preflight_bed_verdict(tmp_path, monkeypatch): + # a 12-unit cube is 304.8 mm if the file means inches: no on-plate claim, no number, no move + path = str(fx.three_mf(tmp_path / "inch.3mf", fx.model_xml( + [fx.cube_object("1", 12)], [("1", fx.tf(100, 100, 0))], unit="inch"))) + report = pp.assess(path) + assert report["available"] is False and report["status"] == "not_checked" + assert placement.assess(path)["available"] is False + assert pp.prepare_placed_copy(path, out_dir=str(tmp_path / "out"))["ok"] is False + monkeypatch.setattr(service, "printer_facts", _facts({"printer_id": "snapmaker_u1"})) + check = next(c for c in service.preflight(path, "h")["checks"] if c["id"] == "bed.fit") + assert check["confidence"] != "confirmed" and "inside" not in check["evidence"] From 21f0abb893e9ae8cd883e5c34b75a27541b56de5 Mon Sep 17 00:00:00 2001 From: DeadlyVirusIn Date: Thu, 22 Oct 2026 06:11:37 -0400 Subject: [PATCH 12/12] fix(layout): plate-fit layout check is not checked when physical size is not established assess_layout read placed bounds directly and could report 'fit within the U1 build plate' for an inch project. It now returns unknown with the shared geometry_basis reason and no dimensional verdict. Established millimetre projects keep the per-object fit/off-plate/oversized checks (test_layout.py, test_role_inheritance_placement.py unchanged and passing). Verified: backend pytest 3549 passed, 49 skipped on this tree. Co-Authored-By: Claude Sonnet 5.5 --- backend/snapstudio_core/layout.py | 15 +++++++++++++++ backend/tests/test_travel_and_move_writer.py | 10 ++++++++++ 2 files changed, 25 insertions(+) diff --git a/backend/snapstudio_core/layout.py b/backend/snapstudio_core/layout.py index b458abde..1c42bb69 100644 --- a/backend/snapstudio_core/layout.py +++ b/backend/snapstudio_core/layout.py @@ -11,6 +11,7 @@ """ from __future__ import annotations +from . import geometry_basis from .geometry import build_item_dims from .intelligence import project_info @@ -51,6 +52,20 @@ def assess_layout(path: str) -> dict: - unknown : no placement data could be read. - pass : every object read fits within one plate. """ + basis = geometry_basis.assess(path) + if not geometry_basis.is_established(basis): + # Size not established (non-mm unit, rotated/scaled item, unfollowable structure): + # no fit verdict, no number, no geometry-based remedy. + return { + "status": "unknown", + "plates": 0, + "object_count": 0, + "messages": [ + f"Layout not checked. {basis['summary']} Check placement in Snapmaker Orca " + "before slicing." + ], + "geometry_basis": basis, + } items = build_item_dims(path) info = project_info(path) plates = int(info.get("plates") or 1) diff --git a/backend/tests/test_travel_and_move_writer.py b/backend/tests/test_travel_and_move_writer.py index 90c523e9..024d5f7b 100644 --- a/backend/tests/test_travel_and_move_writer.py +++ b/backend/tests/test_travel_and_move_writer.py @@ -159,3 +159,13 @@ def test_an_inch_project_gets_no_placement_or_preflight_bed_verdict(tmp_path, mo monkeypatch.setattr(service, "printer_facts", _facts({"printer_id": "snapmaker_u1"})) check = next(c for c in service.preflight(path, "h")["checks"] if c["id"] == "bed.fit") assert check["confidence"] != "confirmed" and "inside" not in check["evidence"] + + +def test_an_inch_project_gets_no_layout_fit_claim(tmp_path): + from snapstudio_core import layout + path = str(fx.three_mf(tmp_path / "inch_layout.3mf", fx.model_xml( + [fx.cube_object("1", 12)], [("1", fx.tf(100, 100, 0))], unit="inch"))) + out = layout.assess_layout(path) + assert out["status"] == "unknown" and out["geometry_basis"]["established"] is False + assert "Layout not checked" in out["messages"][0] + assert not any("fit within" in m or "build plate" in m for m in out["messages"])