diff --git a/CHANGELOG.md b/CHANGELOG.md index 85fbcdb1..e0ff6ec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ All notable changes to this project are documented here. The format is based on ### Changed - **Print risk signals replace the readiness percentage.** The "Print readiness (estimate)" percentage, the star rating and the Studio score are gone. Studio now lists each risk it found, once per condition, with what it means and what to do, and says what it did and did not check. Community guidance attaches only to the conditions it actually fits. +### Fixed +- Object placement judges each placed copy of a repeated object on its own, so a copy that hangs off the plate is no longer hidden by one that does not (#93). +- Placement and plate-fit checks are withheld (reported as not checked) for projects whose physical size the file does not establish: a non-millimetre unit, or a rotated or scaled object. + ## [1.6.0] - 2026-10-21 **A job you can pick up again, sizes Studio will not guess, and more projects that prepare.** diff --git a/backend/snapstudio_api/service.py b/backend/snapstudio_api/service.py index 489b76f1..ca499a55 100644 --- a/backend/snapstudio_api/service.py +++ b/backend/snapstudio_api/service.py @@ -585,7 +585,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"))) @@ -778,17 +781,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 @@ -2697,25 +2691,83 @@ 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: + 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": 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: + 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"]) + + +_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 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 - bed_key = (bed["max_x"], bed["max_y"]) if bed else None - key = (path, st.st_mtime_ns, st.st_size, bed_key) + # 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: @@ -2723,15 +2775,18 @@ 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 + 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 @@ -2744,21 +2799,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 @@ -2804,13 +2859,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/assignments.py b/backend/snapstudio_core/assignments.py index 6ae7120d..a369a5c1 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/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/geometry.py b/backend/snapstudio_core/geometry.py index ea5eb845..05ac19cf 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 @@ -97,21 +99,145 @@ def appA(v): t[0] + a[9], t[1] + a[10], t[2] + a[11]] +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 +#: 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; 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 + 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]: + """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 + + +_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 - 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]] = {} for fname, raw in model_files.items(): root = load_model_settings(raw) @@ -130,18 +256,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")), _xform(c.get("transform")))) 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 @@ -149,42 +279,79 @@ 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, 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}") return key = (f, objid) if key in seen or len(seen) > 4096: # cycle / runaway-nesting guard 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, cpath, ctf in comps: - collect(cid, cpath or f, _compose(xform, ctf), acc, seen) + for cid, cpart, ctf in comps: + child_role = role + if records is not None and f == root_file: + # part records are matched against root-model objects; a missing record is a normal part + child_role = _roles.inherit(role, _roles.own_role( + records.part_subtypes, records.metadata_objects, objid, cid, missing=None)) + collect(cid, cpart or f, cpart is not None, _compose(xform, ctf), acc, seen, bad, + printing, 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 = [] - for it in 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, _xform(it.get("transform")), acc, frozenset()) + printing: list = [] + bad: list = [] + part = it["part"] or root_file + collect(oid, part, it["part"] is not None, _xform(it["transform"]), + acc, frozenset(), bad, printing, None, records) + 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). out.append({"object_id": oid, + "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/intelligence.py b/backend/snapstudio_core/intelligence.py index 9070e6ea..2df111ac 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,8 +43,20 @@ 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: - 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/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/snapstudio_core/placement.py b/backend/snapstudio_core/placement.py index 8f2915c2..03b15235 100644 --- a/backend/snapstudio_core/placement.py +++ b/backend/snapstudio_core/placement.py @@ -48,10 +48,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="([^"]*)"') @@ -212,7 +208,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 @@ -221,60 +217,105 @@ 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 - - with zipfile.ZipFile(path) as archive: - names = archive.namelist() - root = _read(archive, ROOT_MODEL) - settings_text = _read(archive, MODEL_SETTINGS) - try: - project = json.loads(_read(archive, PROJECT_SETTINGS) or "{}") - except json.JSONDecodeError: - project = {} + 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 + # 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 = 1.0 # Studio does not convert units: geometry_basis reports a non-millimetre project as not checked + 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 = 1.0 meshes: dict[str, list[tuple]] = {} - for name in sorted(n for n in names if n.startswith(OBJECTS_DIR)): - body = _read(archive, name) - for mesh_id in re.findall(r'', body, re.S) - if block: - meshes[mesh_id] = _points(block.group(0)) - 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] = _points(block) + 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 {} - placements = dict(_BUILD_ITEM.findall(root)) - components = _components_by_object(root) + 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 + # at the last one's position. objects = [] - for object_id, transform_text in placements.items(): + 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: + 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"] + who = (entry["part"] or ROOT_MODEL, object_id) + instance_index = seen_per_object.get(who, 0) + seen_per_object[who] = instance_index + 1 item = parse_transform(transform_text) 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] - printable: list[tuple] = [] - every: list[tuple] = [] - for mesh_id, component_transform in own_components: - points = meshes.get(mesh_id) - if points is None: - continue - whole = compose(item, parse_transform(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) + item_part = entry["part"] or ROOT_MODEL + explicit = entry["part"] is not None + top = parts.get(item_part, {}).get("components", {}) + 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 + 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), + owner=(item_part, object_id), records=records) + resolved = resolved and ok + 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) + # 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) + 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[who], "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": 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, @@ -282,21 +323,101 @@ def read_objects(path: str) -> dict: "polygon_source": PROJECT_SETTINGS if polygon else None} -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 +_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 (see ``geometry.build_items``). + + 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 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 = [] + 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((geometry_normalize(path.group(1)) if path else None, mesh_id.group(1), + transform.group(1) if transform else None)) return out +def _leaves(entries: list, parts: dict, part: str, explicit: bool, scale: float, + 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 + 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. + """ + 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, parse_transform(text)) + 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, 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)}, + owner=(here_part, oid), records=records, role=child_role) + found.extend(more) + ok = ok and more_ok + return found, ok + + +def _scaled(points: list[tuple], factor: float) -> list[tuple]: + """Mesh points in millimetres. A millimetre part is returned untouched.""" + if factor == 1.0: + return points + return [(x * factor, y * factor, z * factor) for x, y, z in points] + + def _name_of(settings: str, object_id: str) -> str | None: match = re.search(rf'(.*?)(?=)', settings, re.S) @@ -306,8 +427,69 @@ 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 = 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 + 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, + "min_z": self.lo_z, "max_z": self.hi_z} def _footprint(points: list[tuple]) -> dict | None: @@ -441,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 @@ -456,15 +644,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 574fe055..73d458ef 100644 --- a/backend/snapstudio_core/plate_placement.py +++ b/backend/snapstudio_core/plate_placement.py @@ -54,6 +54,56 @@ 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 + + +#: 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: + 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. @@ -112,6 +162,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) @@ -139,8 +200,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: @@ -178,7 +239,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: @@ -186,16 +249,19 @@ 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 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 @@ -235,15 +301,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 [] @@ -251,21 +340,47 @@ 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 + 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] = [] 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() + # 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 + 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 @@ -286,6 +401,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"), @@ -293,7 +409,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 @@ -303,7 +420,7 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d 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") @@ -313,11 +430,17 @@ def assess(path: str, bed: dict | None = None, bed_name: str | None = None) -> d 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 - 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 " @@ -326,18 +449,33 @@ 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"]} 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) + # instance numbers count uses of the SAME object: an id is only unique within its part + totals: dict[tuple, int] = {} + for item in items: + who = (item.get("part"), str(item["object_id"])) + totals[who] = totals.get(who, 0) + 1 + seen: dict[tuple, int] = {} reported = [] for item in items: lo, hi = item["bounds"]["min"], item["bounds"]["max"] + oid = (item.get("part"), 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*. @@ -348,6 +486,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)}, @@ -358,9 +500,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"]] @@ -375,39 +517,68 @@ 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"{k} of {len(reported)} placed instances ({_instance_list(off_plate)})" + else: + 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 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"{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 " + "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"{len(off_plate)} object(s) 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, "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"), "plate_count": plate_count, @@ -419,6 +590,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, } @@ -430,8 +602,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: @@ -440,9 +613,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: @@ -504,6 +683,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. @@ -514,7 +702,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] = [] @@ -538,27 +726,43 @@ 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)) - - 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)) + # 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 = 1.0 # Studio does not convert units (see geometry_basis) + 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 + 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 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", @@ -570,7 +774,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, @@ -631,6 +837,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/snapstudio_core/preflight.py b/backend/snapstudio_core/preflight.py index bd6264da..199023ed 100644 --- a/backend/snapstudio_core/preflight.py +++ b/backend/snapstudio_core/preflight.py @@ -354,26 +354,78 @@ 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 + 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"): 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 + 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] + 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( + "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=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, + 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=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( + "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=f"project geometry vs {owner} 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=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 {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/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/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) 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..3bd450c1 --- /dev/null +++ b/backend/tests/test_part_paths_and_move_verifier.py @@ -0,0 +1,232 @@ +"""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 + +import pytest + +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) + + +@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): + 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("", "" + "") + 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 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_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..b15a9c4f 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,9 +423,81 @@ 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" 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 + + +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_repeated_instance_placement.py b/backend/tests/test_repeated_instance_placement.py new file mode 100644 index 00000000..3666bb84 --- /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 instances" 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 falls outside") + assert [row["instance_count"] for row in report["items"]] == [1] diff --git a/backend/tests/test_role_inheritance_placement.py b/backend/tests/test_role_inheritance_placement.py new file mode 100644 index 00000000..b5c1556f --- /dev/null +++ b/backend/tests/test_role_inheritance_placement.py @@ -0,0 +1,168 @@ +"""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 + + +# --- 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): + """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 + 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 + 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) +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 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..024d5f7b --- /dev/null +++ b/backend/tests/test_travel_and_move_writer.py @@ -0,0 +1,171 @@ +"""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_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 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): + 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"] + + +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 + + +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") + assert check["result"] == "attention" and check["confidence"] == "likely" + 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)) == [] + + +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 + + +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"] + + +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"]) diff --git a/desktop/src/api.ts b/desktop/src/api.ts index 914ca53e..82b0e576 100644 --- a/desktop/src/api.ts +++ b/desktop/src/api.ts @@ -1354,6 +1354,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; @@ -1371,7 +1375,9 @@ export interface PlacementCheck { items: PlacementItem[]; off_plate: PlacementItem[]; skipped_plates?: { plate: number; reason: string }[]; - unresolved_objects?: { object_id: string }[]; + unresolved_objects?: { object_id: string; item_index?: number; reason?: string }[]; + // Placed instances Studio could not judge (not found, or on no plate record). + not_judged?: number; fixable: boolean; summary?: string; } 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..62ef652f 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,42 @@ 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."); + }); + + 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 9e349dce..46938dc0 100644 --- a/desktop/src/lib/placement.ts +++ b/desktop/src/lib/placement.ts @@ -30,18 +30,37 @@ export function placementVerdict(check: PlacementCheck | null): PlacementVerdict canFix: false, }; } + 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 { 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 +68,18 @@ 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}`; +} + +/** 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) @@ -61,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 d36181e6..247eef37 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. ```