diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e02dccc..9006b12f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] ### Fixed +- Preparing a multi-plate project from a printer with a different plate size now lays each plate's objects out on the U1's plate grid (translation only; rotation, scale and painting untouched), in both modes, so Snapmaker Orca no longer lists the objects on later plates as outside. It is only done when every object's position matches the plate the project records for it; otherwise positions are left as they were and the prepared copy says why. Prime towers are not moved: Studio reports a tower it can prove is outside the plate or on an object, and otherwise says it cannot tell. +- "Use Studio's U1 starter settings" keeps the support settings (`enable_support`, support type, support filament, interface filament) when the model itself marks where it needs support (painted support enforcers, support-enforcer volumes, or support switched on for an object); explicitly disabled supports stay off, and blocker-only data does not turn supports on. - Bed fit now reports each object's size and the instances' placement as separate facts; several small, separated objects are no longer summed into one "too big" object (#91). - Object placement judges each placed copy of a repeated object on its own, so a copy that hangs off the plate is no longer hidden by one that does not (#93). diff --git a/backend/snapstudio_core/convert.py b/backend/snapstudio_core/convert.py index f0545a02..83a8bd2a 100644 --- a/backend/snapstudio_core/convert.py +++ b/backend/snapstudio_core/convert.py @@ -113,6 +113,8 @@ def _action_reasons(report: dict) -> tuple[dict[str, str], set[str], set[str], d [report["preset_deviations_declared"]] if report.get("preset_deviations_declared") else []] groups.append(report.get("project_materials_changes", [])) + # the prime tower keys the layout correction moved: reported with their own reason + groups.append(report.get("placement_corrections", [])) identity = report.get("identity", {}) groups.append(identity.get("changed", []) if isinstance(identity, dict) else []) for group in groups: @@ -246,6 +248,25 @@ def _settings_summary(before: dict, after: dict, raw_config: bytes, outcome, "recommendations_available": prepare_mode == "preserve", "recommended_changes": recommended_changes if prepare_mode == "preserve" else [], } + layout = outcome.report.get("layout") + if layout: + summary["layout"] = {k: layout.get(k) for k in ( + "status", "plates", "formula_check", "tower", "tower_note", "stride_source", "stride_target", + "cols", "items_moved", "unresolved")} + summary["placement_changes"] = list(layout.get("disclosure") or []) + summary["warnings"] = summary["warnings"] + [ + line for line in layout.get("disclosure") or [] + if line.startswith("Plate positions were left") + or ("prime tower is outside" in line) or ("prime tower sits on an object" in line)] + intent = outcome.report.get("support_intent") + if intent: + from . import support_intent as _support_intent + + summary["support_intent"] = { + "decision": intent["decision"], "kept": bool(intent["explicit"]), + "evidence": intent["evidence"], "blocker_evidence": intent["blocker_evidence"], + "disable_evidence": intent["disable_evidence"], "keys": intent["keys"], + "statement": _support_intent.statement(intent)} # Preservation invariant: every changed source setting needs a specific # repair-pipeline reason, a deliberate foreign-value clear, or the # profile-owned machine/compat allowlist. The independent diff is the @@ -410,7 +431,7 @@ def convert_to_u1(path: str, out_dir: str | None = None, prepare_mode: str = "pr internal_mode = "preserve" if prepare_mode == "preserve" else "u1" outcome = do_repair(tm, mode=internal_mode, remap=None, dry_run=dry_run, opt_profile=None, confirmed_presets=confirmed_presets, filament_catalog=filament_catalog, - confirmed_colours=confirmed_colours) + confirmed_colours=confirmed_colours, source_path=str(src)) after = load_project_settings(tm.read_part(SETTINGS)) if confirmed_presets: # Judged on what this mode will actually write, not on what the source declared: @@ -428,7 +449,7 @@ def convert_to_u1(path: str, out_dir: str | None = None, prepare_mode: str = "pr # results cannot diverge from a real recommended conversion or write. do_repair(recommended_tm, mode="u1", remap=None, dry_run=True, opt_profile=None, confirmed_presets=confirmed_presets, filament_catalog=filament_catalog, - confirmed_colours=confirmed_colours) + confirmed_colours=confirmed_colours, source_path=str(src)) recommended_after = load_project_settings(recommended_tm.read_part(SETTINGS)) summary = _settings_summary(before, after, raw_config, outcome, prepare_mode, recommended_after=recommended_after) diff --git a/backend/snapstudio_core/fidelity.py b/backend/snapstudio_core/fidelity.py index 43421582..4ff5385a 100644 --- a/backend/snapstudio_core/fidelity.py +++ b/backend/snapstudio_core/fidelity.py @@ -192,7 +192,9 @@ def _model_part_row(part: str, before: bytes, after: bytes, if placement_applied: return _row(_human(part), CHANGED, part=part, detail="the same geometry, moved to different coordinates", - reason="a placement fix moved the objects onto the U1 plate") + reason=("the objects were repositioned for the U1 (a placement fix, or the plate " + "layout corrected for the U1's plate grid): translation only, the " + "geometry itself is unchanged")) if moved_after and not moved_before: return _row(_human(part), PRESERVED_SEMANTIC, part=part, detail="the same geometry, split into one mesh per part", @@ -747,12 +749,28 @@ def _placement_report(prepared: str) -> dict | None: from . import placement try: + if _plate_count(prepared) > 1: + # This report judges every object against ONE plate. A multi-plate project sits on a grid of + # plates (the second plate starts a plate-stride to the right), so it would call every object + # on plate 2 onward "outside" by hundreds of millimetres. The layout check Studio makes while + # preparing (per plate, against the U1's own cell) is the statement about those projects. + return None report = placement.assess(prepared) except Exception: # noqa: BLE001 — a report, never a crash return None return report if report.get("available") else None +def _plate_count(path: str) -> int: + """How many plates a prepared project records (0 when it cannot be read).""" + try: + from . import plate_placement + + return len(plate_placement._plates_from_model_settings(ThreeMF.open(path))) + except Exception: # noqa: BLE001 + return 0 + + def _settings_rows(a: ThreeMF, b: ThreeMF) -> list[dict]: return _settings_row(_json_or_none(a, PROJECT_SETTINGS), _json_or_none(b, PROJECT_SETTINGS)) diff --git a/backend/snapstudio_core/plate_placement.py b/backend/snapstudio_core/plate_placement.py index f036e5fc..2799be23 100644 --- a/backend/snapstudio_core/plate_placement.py +++ b/backend/snapstudio_core/plate_placement.py @@ -734,6 +734,45 @@ def fix_item(match: re.Match) -> str: return out.encode("utf-8"), moved +_COMMENT_RE = re.compile(r"", re.S) + + +def rewrite_items_by_index(raw: bytes, offsets: dict) -> tuple[bytes, int]: + """Translate build items by ordinal: ``offsets`` maps an item's index among the root model's ```` + elements to ``(dx, dy)`` in the root model's own unit. + + Like ``_rewrite_items`` it is byte-surgical (only the translation tokens of the matched tags change, every + other token is copied verbatim), but it addresses an INSTANCE, not an object id, so two uses of one object + on different plates move independently. Commented-out tags are not items and are skipped.""" + text = raw.decode("utf-8", "strict") + spans = [(m.start(), m.end()) for m in _COMMENT_RE.finditer(text)] + out, last, ordinal, moved = [], 0, 0, 0 + for match in _ITEM_TAG_RE.finditer(text): + if any(start <= match.start() < end for start, end in spans): + continue + index, ordinal = ordinal, ordinal + 1 + offset = offsets.get(index) + if offset is None: + continue + tag = match.group(0) + dx, dy = offset + attr = _TRANSFORM_ATTR_RE.search(tag) + if attr is None: + insert = f' transform="{_identity_shifted(dx, dy)}"' + new = tag[:-2] + insert + "/>" if tag.endswith("/>") else tag[:-1] + insert + ">" + else: + shifted = _shift_transform(attr.group(2), dx, dy) + if shifted is None: + continue + new = tag[:attr.start()] + attr.group(1) + shifted + attr.group(3) + tag[attr.end():] + out.append(text[last:match.start()]) + out.append(new) + last = match.end() + moved += 1 + out.append(text[last:]) + return "".join(out).encode("utf-8"), moved + + def _uniform_offset(dx: float, dy: float): return lambda _oid: (dx, dy) diff --git a/backend/snapstudio_core/regrid.py b/backend/snapstudio_core/regrid.py new file mode 100644 index 00000000..9ede3b4f --- /dev/null +++ b/backend/snapstudio_core/regrid.py @@ -0,0 +1,370 @@ +"""Prepare-time layout correction: the plate grid and the prime tower. + +A multi-plate project from another printer carries its build items on THAT printer's plate grid. Snapmaker +Orca rebuilds plate membership from geometry when it opens a file, so an item that is not inside the U1's +cell for its plate is listed under *Outside* and the plate will not slice. The same project carries a prime +(wipe) tower position written for the source bed, which can lie outside the U1 plate or on top of a model. + +Both are corrected here, in BOTH prepare modes (where things sit is not a creator's setting), by +translating build items and moving a tower. Nothing else changes: not a mesh, not a paint attribute, not a +part transform, not a rotation or scale token. + +The grid rule (Orca 2.4.0 ``PartPlate.cpp``; confirmed on 24 of 24 build items of the four multi-plate +benchmark projects, and by the creator's own Orca-made U1 file): + + stride_x = int(bed width) * 1.2 stride_y = int(bed depth) * 1.2 + cols = compute_colum_count(plates) origin(i) = ((i % cols) * stride_x, -(i // cols) * stride_y) + +An item's position is that origin plus its position in the bed's own frame. The rule is only used when it +can be cross-checked: every build item's printing footprint must sit inside the cell the project's own plate +record names for it. If any item fails that check, or its plate cannot be established, nothing is moved and +the condition is reported as unresolved. Studio never guesses a grid. + +The tower is NOT moved. Its footprint is not in the project: the depth of a prime tower is decided by +Snapmaker Orca when it plans the tool changes (purge volumes, ribs, brim, clearance). From the file Studio +knows the corner, the width and the brim, which is enough to PROVE a tower is outside the printable area (its +corner or its known width leaves it) or sits on an object (its corner is inside a printing footprint), and +never enough to say it is inside or clear. So each plate's tower is reported as ``outside_proven``, +``overlap_proven`` or ``unknown``, with the known extents and, clearly marked as an estimate, a square of the +tower's width and its clearances; ``wipe_tower_x/y`` are never written. Nothing here claims a tower is safe. +""" +from __future__ import annotations + +import copy +import math +import re + +from . import geometry, placement, plate_placement as pp + +#: Orca's ``LOGICAL_PART_PLATE_GAP``: the stride is the plate size times (1 + gap). +PLATE_GAP = 1.0 / 5.0 + +#: The tower's depth is not stored in the project. ONLY the estimate shown next to a tower uses it +#: (a square of its width); no decision is taken from it. +TOWER_DEPTH_FROM_WIDTH = 1.0 + +_TOLERANCE = 1e-6 + + +# --- Orca's plate grid --------------------------------------------------------------------------- + +def column_count(plates: int) -> int: + """``compute_colum_count`` from Orca's ``PartPlate.hpp``: the grid is as square as it can be.""" + value = math.sqrt(float(plates)) + rounded = round(value) + return int(rounded + 1) if value > rounded else int(rounded) + + +def stride_of(polygon: list[tuple] | None): + """``(stride_x, stride_y, (min_x, min_y, max_x, max_y))`` of a bed polygon, or None. + + Orca keeps the plate size as an integer (``m_plate_width``/``m_plate_depth``), so the size is + truncated before the gap is applied.""" + if not polygon or len(polygon) < 3: + return None + left, front, right, back = placement.bounds_of(polygon) + width, depth = int(right - left), int(back - front) + if width <= 0 or depth <= 0: + return None + return width * (1.0 + PLATE_GAP), depth * (1.0 + PLATE_GAP), (left, front, right, back) + + +def origin(index: int, cols: int, stride: tuple) -> tuple[float, float]: + """Where plate ``index`` (0-based) starts on the grid: columns go right, rows go to NEGATIVE Y.""" + return (index % cols) * stride[0], -(index // cols) * stride[1] + + +# --- the correction ------------------------------------------------------------------------------ + +def correct(tm, work: dict, source_cfg: dict, source_path: str) -> dict: + """Re-grid the plates and place the prime tower, on ``tm`` (build items) and ``work`` (tower keys). + + Returns the report (``status``, per-plate cells and deltas, the formula check, the tower decisions, + ``setting_changes`` for the keys it wrote and ``disclosure`` for people). Never raises: whatever it + cannot establish is reported as unresolved and left as it was. + """ + report: dict = {"status": "not_applicable", "plates": [], "formula_check": None, "tower": [], + "setting_changes": [], "disclosure": [], "unresolved": []} + try: + _correct(tm, work, source_cfg, source_path, report) + except Exception as exc: # noqa: BLE001 - a prepared copy must still be produced + report["status"] = "unresolved" + report["unresolved"].append(f"Studio could not check the plate layout ({type(exc).__name__})") + report["items_moved"] = 0 + return report + + +def _correct(tm, work, source_cfg, source_path, report) -> None: + items, geometry_unresolved = geometry.measure_items(source_path) + by_index = {item["item_index"]: item for item in items} + plates = pp._plates_from_model_settings(tm) + numbers = sorted({p["ui_number"] for p in plates if p.get("ui_number") is not None}) + multi = len(numbers) > 1 + target_polygon = placement.polygon_of(work) + target = stride_of(target_polygon) + source = stride_of(placement.polygon_of(source_cfg)) + report["items_moved"] = 0 + + # which plate (index) each item sits on, from the project's own records + plate_index: dict[int, int] = {} + cols = 1 + unresolved: list[str] = list(f"item {u['item_index']}: {u['reason']}" for u in geometry_unresolved) + if multi: + if numbers != list(range(1, len(numbers) + 1)): + unresolved.append("the plate numbers are not 1..n, so the plate grid cannot be established") + grouped, loose = pp._group_items_by_plate(items, plates) + for number, members in grouped.items(): + for member in members: + plate_index[member["item_index"]] = numbers.index(number) + if loose: + unresolved.append(f"{len(loose)} build item(s) are on no plate record, or on more than one") + cols = column_count(len(numbers)) + else: + for item in items: + plate_index[item["item_index"]] = 0 + + if source is None or target is None: + unresolved.append("the source or the U1 printable area is not a rectangle Studio can read") + + # --- cross-check: the item's footprint centre is inside the cell its own record names --------- + check = {"items": 0, "matched": 0, "mismatches": []} + cells_ok = source is not None and not unresolved + if multi and source is not None: + left, front, right, back = source[2] + for index, item in by_index.items(): + if index not in plate_index: + continue + check["items"] += 1 + ox, oy = origin(plate_index[index], cols, source) + lo, hi = item["bounds"]["min"], item["bounds"]["max"] + cx, cy = (lo[0] + hi[0]) / 2.0, (lo[1] + hi[1]) / 2.0 + if ox + left - _TOLERANCE <= cx <= ox + right + _TOLERANCE \ + and oy + front - _TOLERANCE <= cy <= oy + back + _TOLERANCE: + check["matched"] += 1 + else: + check["mismatches"].append( + {"item_index": index, "plate": plate_index[index] + 1, + "centre": [round(cx, 2), round(cy, 2)], + "cell": [round(ox + left, 2), round(oy + front, 2), round(ox + right, 2), + round(oy + back, 2)]}) + report["formula_check"] = check + if check["mismatches"]: + unresolved.append(f"{len(check['mismatches'])} of {check['items']} build item(s) are not in " + "the plate cell their record names on the source grid") + cells_ok = False + + offsets: dict[int, tuple[float, float]] = {} + plate_report: dict[int, dict] = {} + if multi: + report["status"] = "unresolved" if unresolved else "same_grid" + if not unresolved and source is not None and target is not None: + same = abs(source[0] - target[0]) < _TOLERANCE and abs(source[1] - target[1]) < _TOLERANCE + if not same: + offsets, plate_report = _plan_regrid(by_index, plate_index, cols, source, target, work) + report["status"] = "regridded" if any( + abs(dx) > _TOLERANCE or abs(dy) > _TOLERANCE for dx, dy in offsets.values()) else "same_grid" + report["stride_source"] = [round(source[0], 4), round(source[1], 4)] if source else None + report["stride_target"] = [round(target[0], 4), round(target[1], 4)] if target else None + report["cols"] = cols + else: + report["status"] = "not_multi_plate" + report["unresolved"].extend(unresolved) + + # --- apply the item translations (translation only, per build item) --------------------------- + if offsets and report["status"] == "regridded": + _apply_offsets(tm, offsets, report) + report["plates"] = [plate_report[i] for i in sorted(plate_report)] + for index in list(offsets): + item = by_index.get(index) + if item is not None: + dx, dy = offsets[index] + item["bounds"] = { + "min": (item["bounds"]["min"][0] + dx, item["bounds"]["min"][1] + dy, item["bounds"]["min"][2]), + "max": (item["bounds"]["max"][0] + dx, item["bounds"]["max"][1] + dy, item["bounds"]["max"][2])} + + # --- a project already authored on THIS U1 plate keeps the tower where its creator put it ---- + # The starter profile owns a tower position (a constant written for no particular model). A project + # whose bed IS the U1's printable area was laid out in Orca for this very plate, tower included, so its + # position is placement, not tuning, and is kept in every mode. + src_polygon, dst_polygon = placement.polygon_of(source_cfg), placement.polygon_of(work) + if src_polygon and dst_polygon and sorted(src_polygon) == sorted(dst_polygon): + for key in ("wipe_tower_x", "wipe_tower_y"): + if key in source_cfg and work.get(key) != source_cfg[key]: + work[key] = copy.deepcopy(source_cfg[key]) + report.setdefault("tower_kept_from_source", []).append(key) + + # --- the prime tower, per plate, against the footprints as they now are ---------------------- + _assess_towers(work, by_index, plate_index, multi, cols, target if target else None, report, + plates_known=cells_ok or not multi) + _disclose(report, multi) + + +def _plan_regrid(by_index, plate_index, cols, source, target, work): + """Per item: the translation that puts its plate on the U1 grid, then the plate's own minimal fit.""" + plate_items: dict[int, list[int]] = {} + for index in by_index: + if index in plate_index: + plate_items.setdefault(plate_index[index], []).append(index) + u1 = pp.u1_bed_rect() + offsets: dict[int, tuple[float, float]] = {} + plates: dict[int, dict] = {} + for idx, members in plate_items.items(): + sox, soy = origin(idx, cols, source) + tox, toy = origin(idx, cols, target) + dx, dy = tox - sox, toy - soy + # the plate's content in the TARGET cell's own frame, to see whether it needs a nudge to fit + boxes = [by_index[m]["bounds"] for m in members] + cluster = {"min_x": min(b["min"][0] for b in boxes) + dx - tox, + "min_y": min(b["min"][1] for b in boxes) + dy - toy, + "max_x": max(b["max"][0] for b in boxes) + dx - tox, + "max_y": max(b["max"][1] for b in boxes) + dy - toy} + fit = pp._centering_offset(cluster, u1) + fits = pp._fits_after(cluster, u1, fit) + for member in members: + offsets[member] = (dx + fit["x"], dy + fit["y"]) + plates[idx] = {"plate": idx + 1, "items": len(members), + "cell_source": [round(sox, 3), round(soy, 3)], + "cell_target": [round(tox, 3), round(toy, 3)], + "delta": [round(dx, 3), round(dy, 3)], + "fit_shift": [fit["x"], fit["y"]], "fits_u1_plate": bool(fits)} + return offsets, plates + + +def _apply_offsets(tm, offsets: dict, report: dict) -> None: + raw = tm.read_part(pp.ROOT_MODEL) + from . import units as _units + + scale = _units.mm_per_unit(raw) + rewritten, moved = pp.rewrite_items_by_index( + raw, {index: (dx / scale, dy / scale) for index, (dx, dy) in offsets.items()}) + before, after = geometry.build_items(raw), geometry.build_items(rewritten) + same = len(before) == len(after) and all( + {k: v for k, v in a["attributes"].items() if k != "transform"} + == {k: v for k, v in b["attributes"].items() if k != "transform"} + for a, b in zip(before, after)) + if not same or moved != len(offsets): + # the writer and the reader disagree about the build items: write nothing + report["status"] = "unresolved" + report["unresolved"].append("Studio could not rewrite the build items safely, so it left them") + offsets.clear() + return + tm.replace_part(pp.ROOT_MODEL, rewritten) + report["items_moved"] = moved + + +# --- the prime tower (assessed, never moved) ----------------------------------------------------- + +def _entry(values, plate: int, fallback: float | None = None): + """Orca's ``get_at``: the plate's own entry, else the first.""" + if isinstance(values, (str, int, float)): + values = [values] + if not isinstance(values, list) or not values: + return fallback + try: + return float(values[plate] if plate < len(values) else values[0]) + except (TypeError, ValueError): + return fallback + + +def tower_rect(x: float, y: float, width: float, brim: float) -> tuple[float, float, float, float]: + """The ESTIMATED rectangle of a tower at corner (x, y): a square of its width, brim included.""" + depth = width * TOWER_DEPTH_FROM_WIDTH + return (x - brim, y - brim, x + width + brim, y + depth + brim) + + +def _gap(rect, box) -> float: + """Edge-to-edge distance between two rectangles (0 when they overlap).""" + dx = max(box[0] - rect[2], rect[0] - box[2], 0.0) + dy = max(box[1] - rect[3], rect[1] - box[3], 0.0) + return math.hypot(dx, dy) + + +def _assess_towers(work, by_index, plate_index, multi, cols, target, report, *, plates_known: bool) -> None: + """Per plate: what the file PROVES about the prime tower, and nothing it does not. + + ``outside_proven``: the corner, or the known width plus brim, leaves the U1 printable area. + ``overlap_proven``: the corner lies inside a printing footprint of that plate. + ``unknown``: neither is proven; the full footprint depends on Orca's tool-change planning. + """ + enabled = work.get("enable_prime_tower") + if enabled is None or str(enabled).lower() in ("0", "false", "", "none"): + return + try: + width = float(work.get("prime_tower_width")) + except (TypeError, ValueError): + width = 0.0 + bed = pp.u1_bed_rect() + try: + brim = max(0.0, float(work.get("prime_tower_brim_width") or 0)) + except (TypeError, ValueError): + brim = 0.0 + n_plates = (len(set(plate_index.values())) or 1) if multi else 1 + xs, ys = work.get("wipe_tower_x"), work.get("wipe_tower_y") + footprints: dict[int, list[tuple]] = {i: [] for i in range(n_plates)} + for index, item in by_index.items(): + idx = plate_index.get(index) + if idx is None: + continue + ox, oy = (origin(idx, cols, target) if (multi and target) else (0.0, 0.0)) + lo, hi = item["bounds"]["min"], item["bounds"]["max"] + footprints.setdefault(idx, []).append((lo[0] - ox, lo[1] - oy, hi[0] - ox, hi[1] - oy)) + + for i in range(n_plates): + x, y = _entry(xs, i), _entry(ys, i) + record = {"plate": i + 1, "position": [x, y], "width": width, "brim": brim, + "status": "unknown", "reason": None, "known_x_extent": None, "known_y_min": None, + "estimate": {"rect": None, "clearance_to_edges": None, "clearance_to_objects": None, + "note": "a square of the tower's width; Orca decides the real depth"}} + report["tower"].append(record) + if multi and not plates_known: + record["reason"] = "the plate layout could not be established" + continue + if x is None or y is None or width <= 0: + record["reason"] = "the project states no usable tower position or width" + continue + record["known_x_extent"] = [round(x - brim, 3), round(x + width + brim, 3)] + record["known_y_min"] = round(y - brim, 3) + est = tower_rect(x, y, width, brim) + record["estimate"]["rect"] = [round(v, 3) for v in est] + record["estimate"]["clearance_to_edges"] = { + "left": round(est[0] - bed["min_x"], 3), "right": round(bed["max_x"] - est[2], 3), + "front": round(est[1] - bed["min_y"], 3), "back": round(bed["max_y"] - est[3], 3)} + boxes = footprints.get(i, []) + record["estimate"]["clearance_to_objects"] = ( + round(min(_gap(est, box) for box in boxes), 3) if boxes else None) + if (x - brim < bed["min_x"] - _TOLERANCE or x + width + brim > bed["max_x"] + _TOLERANCE + or y - brim < bed["min_y"] - _TOLERANCE or y > bed["max_y"] + _TOLERANCE): + record.update(status="outside_proven", + reason="its position and width already leave the U1 printable area") + elif any(b[0] <= x <= b[2] and b[1] <= y <= b[3] for b in boxes): + record.update(status="overlap_proven", reason="its corner is inside an object on that plate") + else: + record["reason"] = ("the tower's depth is decided when Snapmaker Orca plans the tool changes, " + "so whether it is inside the plate and clear of the objects is not known") + report["tower_note"] = ("Studio did not move any prime tower: its full footprint (depth, ribs, brim, " + "clearance) is only fixed when Snapmaker Orca plans the tool changes.") + + +def _disclose(report: dict, multi: bool) -> None: + lines = report["disclosure"] + if report.get("status") == "regridded": + moved = [p for p in report["plates"] if abs(p["delta"][0]) > _TOLERANCE or abs(p["delta"][1]) > _TOLERANCE + or abs(p["fit_shift"][0]) > _TOLERANCE or abs(p["fit_shift"][1]) > _TOLERANCE] + for p in moved: + lines.append(f"Repositioned plate {p['plate']} objects to the U1 plate layout " + f"(X {p['delta'][0] + p['fit_shift'][0]:+g} mm, Y {p['delta'][1] + p['fit_shift'][1]:+g} mm; " + "positions inside the plate, rotation and scale are unchanged).") + for p in report["plates"]: + if not p["fits_u1_plate"]: + lines.append(f"Plate {p['plate']}'s objects are larger than the U1 plate, so they still " + "will not fit it; Snapmaker Orca will show them outside.") + for t in report["tower"]: + if t["status"] == "outside_proven": + lines.append(f"Plate {t['plate']}'s prime tower is outside the U1 printable area ({t['reason']}); " + "Studio left it where it is because it cannot establish the tower's full footprint. " + "Move it in Snapmaker Orca's prime tower settings.") + elif t["status"] == "overlap_proven": + lines.append(f"Plate {t['plate']}'s prime tower sits on an object ({t['reason']}); Studio left it " + "where it is because it cannot establish the tower's full footprint.") + for reason in report["unresolved"]: + lines.append(f"Plate positions were left as the source had them: {reason}.") diff --git a/backend/snapstudio_core/repair.py b/backend/snapstudio_core/repair.py index bd117ce9..6148e301 100644 --- a/backend/snapstudio_core/repair.py +++ b/backend/snapstudio_core/repair.py @@ -22,7 +22,8 @@ def repair(tm: ThreeMF, mode: str = "u1", remap: dict | None = None, dry_run: bool = False, profile_name: str = "snapmaker_u1", opt_profile: str | None = None, confirmed_presets: dict | None = None, - filament_catalog=None, confirmed_colours: dict | None = None) -> RepairOutcome: + filament_catalog=None, confirmed_colours: dict | None = None, + source_path: str | None = None) -> RepairOutcome: assert mode in ("safe", "preserve", "u1", "optimize") remap = remap or {} source = detect_source(tm) @@ -123,6 +124,26 @@ def repair(tm: ThreeMF, mode: str = "u1", remap: dict | None = None, report["orca_compatibility"] = orca_import.apply_compatibility( work, filament_count(work)) + # Where things sit is not a creator's setting, so the plate grid and the prime tower are corrected in + # both modes (see ``regrid``): translation of build items and one tower position per plate, nothing else. + # It reads the original file for geometry, so it runs only when the caller says where that is. + layout = None + if mode in ("preserve", "u1", "optimize") and source_path: + from . import regrid + layout = regrid.correct(tm, work, cfg, source_path) + report["layout"] = layout + report["placement_corrections"] = layout["setting_changes"] + + # A model's own statement of where it needs support survives the starter settings (Recommended only; + # Preserve keeps the creator's declarations as they are). See ``support_intent``. + support_keys: set[str] = set() + if mode in ("u1", "optimize"): + from . import support_intent + intent = support_intent.detect(tm, cfg) + report["support_intent"] = intent + if intent["explicit"]: + support_keys = set(intent["keys"]) + # Everything above states values. This says which of them Snapmaker Orca is # not to take from the preset the project names. # @@ -147,7 +168,8 @@ def repair(tm: ThreeMF, mode: str = "u1", remap: dict | None = None, report.get("preserved_value_changes"), report.get("optimizations"), report.get("orca_compatibility"), - ), + report.get("placement_corrections"), + ) | support_keys, filaments=filament_count(work), ) if declared: diff --git a/backend/snapstudio_core/support_intent.py b/backend/snapstudio_core/support_intent.py new file mode 100644 index 00000000..88dfc399 --- /dev/null +++ b/backend/snapstudio_core/support_intent.py @@ -0,0 +1,135 @@ +"""Whether the source expresses explicit support intent, which Recommended must not silently discard. + +Recommended is the "U1 starter settings" mode: speed, temperature, retraction, supports and cooling are the +starter's, and the support policy of a creator's print profile is rightly reset. The one exception is a +model's own, positive statement of where it needs support. The rule, in order of precedence (the first that +applies decides): + +1. **Explicit disable wins.** ``enable_support`` = 0 in the project's settings, or ``enable_support`` = 0 on any + object, means supports are off and stay off. The model's painting or enforcers are then disclosed as + unused; nothing is declared. +2. **Positive intent is kept.** Painted support *enforcers* (``paint_supports`` facets whose paint has the + enforcer state), ``support_enforcer`` volumes, or ``enable_support`` = 1 on an object. The creator's + ``enable_support`` / ``support_type`` / ``support_filament`` / ``support_interface_filament`` are declared + as the project's own and kept. +3. **Otherwise the starter reset stands** (this is what the mode says it does): no support data at all, or + blocker-only data. Blockers (``support_blocker`` volumes, blocker paint) say where NOT to support, which + is not a request for supports, so they never keep anything. +4. Whatever is not decided above takes the creator's value (Preserve mode never reaches this module). + +Paint states are the codec's: leaf state 1 is "enforcer", 2 is "blocker" (Orca's ``EnforcerBlockerType``). +""" +from __future__ import annotations + +import re + +from .container import ThreeMF + +SUPPORT_KEYS = ("enable_support", "support_type", "support_filament", "support_interface_filament") + +ENFORCER_STATE = 1 +BLOCKER_STATE = 2 + +_PAINT = re.compile(rb'paint_supports="([^"]+)"') +_OBJECT_ENABLE = re.compile(r' bool: + return str(value).strip().lower() in ("0", "false", "off", "no") + + +def _on(value) -> bool: + return str(value).strip().lower() in ("1", "true", "on", "yes") + + +def _paint_states(tm: ThreeMF) -> set[int]: + from . import paint_codec + + states: set[int] = set() + seen: set[bytes] = set() + for part in tm.list_parts(): + if not part.endswith(".model"): + continue + for match in _PAINT.finditer(tm.read_part(part)): + attribute = match.group(1) + if attribute in seen or len(seen) >= _MAX_DISTINCT_PAINT: + continue + seen.add(attribute) + try: + states |= set(paint_codec.states(attribute.decode("ascii", "ignore"))) + except Exception: # noqa: BLE001 - paint Studio cannot decode is not evidence of intent + continue + if ENFORCER_STATE in states and BLOCKER_STATE in states: + return states + return states + + +def detect(tm: ThreeMF, source_cfg: dict) -> dict: + """The decision (``kept`` / ``disabled`` / ``starter_reset``) and the evidence for it.""" + from . import roles + from .assignments import SUPPORT_BLOCKER, SUPPORT_ENFORCER, role_of + + positive: list[str] = [] + blockers: list[str] = [] + disabled: list[str] = [] + + try: + states = _paint_states(tm) + if ENFORCER_STATE in states: + positive.append("painted support enforcers") + if BLOCKER_STATE in states: + blockers.append("painted support blockers") + except Exception: # noqa: BLE001 + pass + try: + if tm.has_part(MODEL_SETTINGS): + text = tm.read_part(MODEL_SETTINGS).decode("utf-8", "ignore") + subtypes = [role_of(sub) for subs in roles.read_records(text).part_subtypes.values() + for sub in subs.values()] + if SUPPORT_ENFORCER in subtypes: + positive.append("support-enforcer volumes") + if SUPPORT_BLOCKER in subtypes: + blockers.append("support-blocker volumes") + values = _OBJECT_ENABLE.findall(text) + if any(_on(v) for v in values): + positive.append("support switched on for an individual object") + if any(_off(v) for v in values): + disabled.append("support switched off for an individual object (enable_support = 0)") + except Exception: # noqa: BLE001 + pass + if "enable_support" in (source_cfg or {}) and _off(source_cfg["enable_support"]): + disabled.append("support switched off in the project's settings (enable_support = 0)") + + keys = [k for k in SUPPORT_KEYS if k in (source_cfg or {})] + if disabled: + decision = DISABLED + elif positive and keys: + decision = KEPT + else: + decision = STARTER_RESET + return {"decision": decision, "explicit": decision == KEPT, "evidence": positive, + "blocker_evidence": blockers, "disable_evidence": disabled, "keys": keys} + + +def statement(intent: dict) -> str: + """One plain sentence for the summary.""" + decision = intent["decision"] + if decision == KEPT: + return ("This model marks where it needs support (" + ", ".join(intent["evidence"]) + "), so its " + "support settings (" + ", ".join(intent["keys"]) + ") were kept as the creator set them " + "rather than reset to the U1 starter's.") + if decision == DISABLED: + extra = (f" The model also has {', '.join(intent['evidence'])}, which are not used while supports " + "are off." if intent["evidence"] else "") + return ("Supports stay off: " + "; ".join(intent["disable_evidence"]) + "." + extra) + if intent["blocker_evidence"]: + return ("Supports use the U1 starter settings. The model's " + ", ".join(intent["blocker_evidence"]) + + " only say where not to support, so they do not turn supports on.") + return "Supports use the U1 starter settings, as this mode states." diff --git a/backend/tests/regrid_fixtures.py b/backend/tests/regrid_fixtures.py new file mode 100644 index 00000000..0174afce --- /dev/null +++ b/backend/tests/regrid_fixtures.py @@ -0,0 +1,138 @@ +"""Multi-plate Bambu/Orca projects built at test time from the committed Orca-authored base. + +The base is ``fixtures/painted/orcaslicer-2.4.2-painted-cube.3mf``: one 20 mm painted cube (centred on its own +origin) on a single U1 plate. Everything else (more plates, a larger source bed, tower positions, support +intent) is written into a copy of it, so no third-party model is in the repository. +""" +from __future__ import annotations + +import json +import re +import zipfile +from pathlib import Path + +from snapstudio_core import regrid + +BASE = Path(__file__).parent / "fixtures" / "painted" / "orcaslicer-2.4.2-painted-cube.3mf" +ROOT = "3D/3dmodel.model" +SETTINGS = "Metadata/project_settings.config" +MODEL_SETTINGS = "Metadata/model_settings.config" +OBJECT_PART = "3D/Objects/sample_cube_1.model" + +IDENT = "1 0 0 0 1 0 0 0 1" + + +def bed_polygon(width: float, depth: float, min_x: float = 0.0, min_y: float = 0.0) -> list[str]: + return [f"{min_x:g}x{min_y:g}", f"{min_x + width:g}x{min_y:g}", + f"{min_x + width:g}x{min_y + depth:g}", f"{min_x:g}x{min_y + depth:g}"] + + +def source_origin(index: int, plates: int, bed: tuple[float, float]) -> tuple[float, float]: + """Where plate ``index`` starts on the SOURCE grid, by Orca's rule (the fixture's own copy of it).""" + stride = (int(bed[0]) * 1.2, int(bed[1]) * 1.2) + cols = regrid.column_count(plates) + return (index % cols) * stride[0], -(index // cols) * stride[1] + + +def read(path) -> dict: + with zipfile.ZipFile(path) as z: + return {n: z.read(n) for n in z.namelist()} + + +def write(path, entries: dict) -> str: + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z: + for name, data in entries.items(): + z.writestr(name, data) + return str(path) + + +def items_of(path) -> list[dict]: + """(transform text, other attributes) of every build item of a project, in order.""" + root = read(path)[ROOT].decode("utf-8") + return [{"transform": (re.search(r'transform="([^"]*)"', t) or [None, None])[1], "tag": t} + for t in re.findall(r"]*/>", root)] + + +def settings_of(path) -> dict: + return json.loads(read(path)[SETTINGS]) + + +def project(tmp_path, name="multi.3mf", *, plates=2, bed=(330.0, 320.0), local=(165.0, 160.0), + transforms=None, cfg=None, supports=None, instances=None): + """A ``plates``-plate project on a source bed of ``bed`` mm. + + ``local`` is each item's position in its plate's own frame (so it lands in the cell the formula predicts); + ``transforms`` overrides the full 12-token transform of item ``i``; ``cfg`` updates project settings; + ``supports`` is a token or a set of them ("paint" = enforcer paint, "paint_blocker", "enforcer" and + "blocker" volumes, "object" = enable_support 1 on the object, "object_off" = 0) written into the copy. + """ + entries = read(BASE) + root = entries[ROOT].decode("utf-8") + items = [] + for i in range(plates): + ox, oy = source_origin(i, plates, bed) + text = (transforms or {}).get(i) or f"{IDENT} {ox + local[0]:g} {oy + local[1]:g} 10" + items.append(f' ') + root = re.sub(r"]*)>.*?", + lambda m: f"\n" + "\n".join(items) + "\n ", root, flags=re.S) + entries[ROOT] = root.encode("utf-8") + + ms = entries[MODEL_SETTINGS].decode("utf-8") + blocks = [] + for i in range(plates): + blocks.append(f""" + + + + + + + + + + + + +""") + ms = re.sub(r" .*\n", "".join(blocks), ms, flags=re.S) + wanted = set([supports] if isinstance(supports, str) else (supports or ())) + helper_volumes = [t for t in ("enforcer", "blocker") if t in wanted] + for n, kind in enumerate(helper_volumes): + # a small extra mesh in the object file, referenced by the root object as component 3 / 4 + number = 3 + n + part = entries[OBJECT_PART].decode("utf-8") + block = re.search(r'', part, re.S).group(0) + clean = re.sub(r' paint_[a-z]+="[^"]*"', "", block).replace('id="1"', f'id="{number}"', 1) + clean = re.sub(r'p:UUID="[^"]*"', f'p:UUID="0003000{number}-81cb-4c03-9d28-80fed5dfa1dc"', clean, count=1) + clean = clean.replace('type="model"', 'type="other"', 1) # a helper volume is typed "other" + entries[OBJECT_PART] = part.replace("", clean + "\n ", 1).encode("utf-8") + root = entries[ROOT].decode("utf-8") + root = root.replace("", f'', 1) + entries[ROOT] = root.encode("utf-8") + ms = ms.replace(" ", f' \n' + f' \n \n ', 1) + for flag, value in (("object", "1"), ("object_off", "0")): + if flag in wanted: + ms = ms.replace('', + f'\n ', 1) + entries[MODEL_SETTINGS] = ms.encode("utf-8") + + for flag, state in (("paint", "4"), ("paint_blocker", "8")): + if flag in wanted: + part = entries[OBJECT_PART].decode("utf-8") + part = re.sub(r'(]*?)(/>)', rf' paint_supports="{state}"', part, count=1) + assert "paint_supports" in part + entries[OBJECT_PART] = part.encode("utf-8") + + settings = json.loads(entries[SETTINGS]) + settings["printable_area"] = bed_polygon(*bed) + settings.update({"enable_support": "1", "support_type": "tree(manual)", "support_filament": "2", + "support_interface_filament": "2"}) + settings["different_settings_to_system"] = [ + "enable_support;support_filament;support_interface_filament;support_type", "", "", "", "", ""] + settings.update(cfg or {}) + entries[SETTINGS] = json.dumps(settings, indent=4).encode("utf-8") + return write(Path(tmp_path) / name, entries) diff --git a/backend/tests/test_regrid.py b/backend/tests/test_regrid.py new file mode 100644 index 00000000..032a9e6a --- /dev/null +++ b/backend/tests/test_regrid.py @@ -0,0 +1,311 @@ +"""Per-plate re-grid, prime-tower placement and support intent, through the real producer +(``convert_to_u1``) on projects built at test time from the committed Orca-authored base.""" +from __future__ import annotations + +import pytest + +from snapstudio_core import regrid +from snapstudio_core.convert import convert_to_u1 +from tests import regrid_fixtures as rf + +MODES = ["preserve", "recommended"] + + +def run(tmp_path, src, mode): + return convert_to_u1(src, out_dir=str(tmp_path / f"out_{mode}"), prepare_mode=mode) + + +def tokens(path): + return [i["transform"].split() for i in rf.items_of(path)] + + +# --- Orca's grid rule ---------------------------------------------------------------------------- + +def test_column_count_matches_orcas_rule(): + assert [regrid.column_count(n) for n in (1, 2, 3, 4, 5, 6, 7, 9, 10)] == [1, 2, 2, 2, 3, 3, 3, 3, 4] + + +def test_origin_goes_right_then_down_with_negative_y(): + stride = (324.0, 324.0) + assert regrid.origin(0, 3, stride) == (0, 0) + assert regrid.origin(2, 3, stride) == (648.0, 0) + assert regrid.origin(3, 3, stride) == (0, -324.0) + + +# --- the plate grid ------------------------------------------------------------------------------ + +@pytest.mark.parametrize("mode", MODES) +def test_a_larger_bed_stride_is_regridded_per_plate_in_both_modes(tmp_path, mode): + src = rf.project(tmp_path, plates=2, bed=(330.0, 320.0)) + before = tokens(src) + result = run(tmp_path, src, mode) + after = tokens(result.output_path) + # plate 1 is where it was; plate 2 moved from 396 + 165 to 324 + 165 (the U1 stride) + assert after[0] == before[0] and after[1][9] == "489" and after[1][10] == "160" + layout = result.settings_summary["layout"] + assert layout["status"] == "regridded" and layout["formula_check"] == {"items": 2, "matched": 2, "mismatches": []} + assert layout["stride_source"] == [396.0, 384.0] and layout["stride_target"] == [324.0, 324.0] + assert any("Repositioned plate 2 objects to the U1 plate layout" in line + for line in result.settings_summary["placement_changes"]) + assert result.validated_ok + + +@pytest.mark.parametrize("mode", MODES) +def test_rotation_and_scale_tokens_survive_verbatim(tmp_path, mode): + rot = "0.7071067812 0.7071067812 0 -0.7071067812 0.7071067812 0 0 0 1.5" + ox, oy = rf.source_origin(1, 2, (330.0, 320.0)) + src = rf.project(tmp_path, plates=2, transforms={1: f"{rot} {ox + 165:g} {oy + 160:g} 10"}) + result = run(tmp_path, src, mode) + after = tokens(result.output_path)[1] + assert " ".join(after[:9]) == rot and after[11] == "10" # basis and Z, byte for byte + assert after[9] == "489" and after[10] == "160" + + +@pytest.mark.parametrize("mode", MODES) +def test_five_plates_use_negative_y_rows(tmp_path, mode): + src = rf.project(tmp_path, plates=5, bed=(330.0, 320.0)) + result = run(tmp_path, src, mode) + after = tokens(result.output_path) + layout = result.settings_summary["layout"] + assert layout["cols"] == 3 and layout["status"] == "regridded" + # plate 4 (index 3): row 1, col 0 -> y = -324 + 160 on the U1 grid (it was -384 + 160) + assert (after[3][9], after[3][10]) == ("165", "-164") + # plate 3 (index 2): col 2 -> x = 648 + 165 + assert (after[2][9], after[2][10]) == ("813", "160") + + +@pytest.mark.parametrize("mode", MODES) +def test_an_item_outside_its_recorded_cell_is_never_guessed(tmp_path, mode): + # item 2 is recorded on plate 2 but sits where plate 1's cell is: the formula does not hold + src = rf.project(tmp_path, plates=2, transforms={1: f"{rf.IDENT} 165 160 10"}) + before = tokens(src) + result = run(tmp_path, src, mode) + assert tokens(result.output_path) == before # nothing moved + layout = result.settings_summary["layout"] + assert layout["status"] == "unresolved" and layout["formula_check"]["mismatches"] + assert any("Plate positions were left" in w for w in result.settings_summary["warnings"]) + + +@pytest.mark.parametrize("mode", MODES) +def test_the_same_grid_is_left_alone(tmp_path, mode): + src = rf.project(tmp_path, plates=2, bed=(270.0, 270.0), local=(135.5, 136.0)) + before = tokens(src) + result = run(tmp_path, src, mode) + assert tokens(result.output_path) == before + assert result.settings_summary["layout"]["status"] == "same_grid" + + +@pytest.mark.parametrize("mode", MODES) +def test_a_plate_that_needs_a_nudge_gets_the_minimal_fit(tmp_path, mode): + # local y 290 on a 320 bed is outside the U1's 271 limit: plate 2 is nudged, plate 1 is not + ox, oy = rf.source_origin(1, 2, (330.0, 320.0)) + src = rf.project(tmp_path, plates=2, transforms={1: f"{rf.IDENT} {ox + 165:g} {oy + 290:g} 10"}) + result = run(tmp_path, src, mode) + after = tokens(result.output_path) + plate = result.settings_summary["layout"]["plates"][1] + assert plate["fits_u1_plate"] is True and plate["fit_shift"][1] < 0 + assert float(after[1][10]) <= 271 - 10 - 0.5 + 1e-6 and after[1][9] == "489" + + +@pytest.mark.parametrize("mode", MODES) +def test_the_original_is_never_modified(tmp_path, mode): + import hashlib + src = rf.project(tmp_path, plates=2) + before = hashlib.sha256(open(src, "rb").read()).hexdigest() + run(tmp_path, src, mode) + assert hashlib.sha256(open(src, "rb").read()).hexdigest() == before + + +# --- the prime tower: assessed, never moved, never called safe -------------------------------------- + +def tower_values(path): + cfg = rf.settings_of(path) + return cfg["wipe_tower_x"], cfg["wipe_tower_y"] + + +def tower_records(result): + return result.settings_summary["layout"]["tower"] + + +@pytest.mark.parametrize("mode", MODES) +def test_an_outside_tower_is_proven_and_reported_but_never_moved(tmp_path, mode): + cfg = {"wipe_tower_x": ["236.895", "165"], "wipe_tower_y": ["233.729", "250"], "prime_tower_width": "60"} + src = rf.project(tmp_path, plates=2, cfg=cfg) + result = run(tmp_path, src, mode) + records = tower_records(result) + if mode == "preserve": + # the creator's own values come through byte for byte: Studio did not move them + assert tower_values(result.output_path) == (["236.895", "165"], ["233.729", "250"]) + # plate 1: x 236.9 + 60 + brim leaves the 270.5 edge, which the file proves; plate 2's depth is unknown + assert [r["status"] for r in records] == ["outside_proven", "unknown"] + assert all(r["status"] != "inside" and r["status"] != "safe" for r in records) + assert any("prime tower is outside" in w for w in result.settings_summary["warnings"]) == (mode == "preserve") + + +@pytest.mark.parametrize("mode", MODES) +def test_a_tower_that_looks_fine_is_unknown_not_safe(tmp_path, mode): + src = rf.project(tmp_path, plates=2, bed=(270.0, 270.0), local=(135.5, 136.0), + cfg={"wipe_tower_x": ["39.25", "39.25"], "wipe_tower_y": ["212.1", "212.1"], + "prime_tower_width": "30"}) + result = run(tmp_path, src, mode) + records = tower_records(result) + assert [r["status"] for r in records] == ["unknown", "unknown"] + assert records[0]["estimate"]["rect"] is not None and "depth" in records[0]["estimate"]["note"] + assert "tower_note" in result.settings_summary["layout"] + if mode == "preserve": + assert tower_values(result.output_path) == (["39.25", "39.25"], ["212.1", "212.1"]) # byte-identical + + +def test_a_tower_whose_corner_is_inside_an_object_is_an_overlap_proven(tmp_path): + src = rf.project(tmp_path, plates=1, bed=(270.0, 270.0), local=(165.0, 160.0), + cfg={"wipe_tower_x": ["165"], "wipe_tower_y": ["160"], "prime_tower_width": "30"}) + result = run(tmp_path, src, "preserve") + assert tower_records(result)[0]["status"] == "overlap_proven" + assert tower_values(result.output_path) == (["165"], ["160"]) + + +def test_every_tower_record_carries_the_known_extent_and_the_estimate_with_clearances(tmp_path): + src = rf.project(tmp_path, plates=1, bed=(270.0, 270.0), local=(135.5, 136.0), + cfg={"wipe_tower_x": ["39.25"], "wipe_tower_y": ["212.1"], "prime_tower_width": "30"}) + record = tower_records(run(tmp_path, src, "preserve"))[0] + assert record["known_x_extent"] == [34.25, 74.25] and record["known_y_min"] == 207.1 + est = record["estimate"] + assert est["rect"] == [34.25, 207.1, 74.25, 247.1] + assert est["clearance_to_edges"] == {"left": 33.75, "right": 196.25, "front": 206.1, "back": 23.9} + assert est["clearance_to_objects"] is not None and est["clearance_to_objects"] > 0 + + +@pytest.mark.parametrize("mode", MODES) +def test_towers_are_never_written(tmp_path, mode): + src = rf.project(tmp_path, plates=2, cfg={"wipe_tower_x": ["236.895", "165"], + "wipe_tower_y": ["233.729", "250"]}) + before = rf.settings_of(src) + after = rf.settings_of(run(tmp_path, src, mode).output_path) + if mode == "preserve": + assert (after["wipe_tower_x"], after["wipe_tower_y"]) == (before["wipe_tower_x"], before["wipe_tower_y"]) + + +def test_a_disabled_tower_is_not_assessed(tmp_path): + src = rf.project(tmp_path, plates=1, bed=(270.0, 270.0), local=(135.5, 136.0), + cfg={"enable_prime_tower": "0", "wipe_tower_x": ["900"], "wipe_tower_y": ["900"]}) + result = run(tmp_path, src, "preserve") + assert tower_records(result) == [] + assert tower_values(result.output_path) == (["900"], ["900"]) + + +# --- support intent: explicit disable > positive intent > starter reset > creator values ------------- + +SUPPORT = ("enable_support", "support_type", "support_filament", "support_interface_filament") + + +def declared_first(path): + return set(filter(None, rf.settings_of(path)["different_settings_to_system"][0].split(";"))) + + +def support_case(tmp_path, supports, cfg=None, mode="recommended"): + src = rf.project(tmp_path, plates=1, bed=(270.0, 270.0), local=(135.5, 136.0), supports=supports, cfg=cfg) + result = run(tmp_path, src, mode) + return result, result.settings_summary.get("support_intent"), declared_first(result.output_path) + + +@pytest.mark.parametrize("supports", ["paint", "enforcer", "object"]) +def test_positive_intent_survives_the_starter_settings(tmp_path, supports): + result, intent, declared = support_case(tmp_path, supports) + cfg = rf.settings_of(result.output_path) + assert intent["decision"] == "kept" and set(SUPPORT) <= declared + assert (cfg["enable_support"], cfg["support_type"], cfg["support_filament"]) == ("1", "tree(manual)", "2") + assert "kept as the creator set them" in intent["statement"] + + +@pytest.mark.parametrize("supports", ["blocker", "paint_blocker", {"blocker", "paint_blocker"}]) +def test_blocker_only_data_is_not_a_request_for_supports(tmp_path, supports): + result, intent, declared = support_case(tmp_path, supports) + assert intent["decision"] == "starter_reset" and not (set(SUPPORT) & declared) + assert intent["blocker_evidence"] and "do not turn supports on" in intent["statement"] + + +def test_enforcers_with_an_explicit_object_disable_stay_disabled_and_say_so(tmp_path): + result, intent, declared = support_case(tmp_path, {"enforcer", "object_off"}) + assert intent["decision"] == "disabled" and not (set(SUPPORT) & declared) + assert "Supports stay off" in intent["statement"] and "support-enforcer volumes" in intent["statement"] + + +@pytest.mark.parametrize("enable, decision", [("0", "disabled"), ("1", "kept")]) +def test_painted_supports_with_global_enable_support_zero_or_one(tmp_path, enable, decision): + result, intent, declared = support_case(tmp_path, "paint", cfg={"enable_support": enable}) + assert intent["decision"] == decision + assert bool(set(SUPPORT) & declared) == (decision == "kept") + assert rf.settings_of(result.output_path)["enable_support"] == enable # the value is the creator's + + +def test_with_no_support_data_the_starter_reset_stands(tmp_path): + result, intent, declared = support_case(tmp_path, None) + assert intent["decision"] == "starter_reset" and not (set(SUPPORT) & declared) + assert "starter settings" in intent["statement"] + + +@pytest.mark.parametrize("supports", ["paint", "enforcer", {"enforcer", "object_off"}]) +def test_preserve_is_unchanged_whatever_the_intent(tmp_path, supports): + src = rf.project(tmp_path, plates=1, bed=(270.0, 270.0), local=(135.5, 136.0), supports=supports) + result = run(tmp_path, src, "preserve") + # the creator's own declaration comes through as it was; Studio adds none and removes none of the four + assert set(SUPPORT) <= declared_first(result.output_path) + assert result.settings_summary.get("support_intent") is None + + +# --- the audit accounts for it ------------------------------------------------------------------- + +@pytest.mark.parametrize("mode", MODES) +def test_the_fidelity_audit_does_not_call_a_regrid_a_bug(tmp_path, mode): + from snapstudio_core import fidelity + src = rf.project(tmp_path, plates=2) + result = run(tmp_path, src, mode) + report = fidelity.audit(src, result.output_path) + text = str(report) + assert "report this as a bug" not in text + + +def test_the_audit_makes_no_one_plate_outside_claim_about_a_multi_plate_copy(tmp_path): + from snapstudio_core import fidelity + src = rf.project(tmp_path, plates=2) + result = run(tmp_path, src, "preserve") + report = fidelity.audit(src, result.output_path) + assert report["placement"] is None # not "plate 2 is 228 mm outside" + row = next(r for r in report["rows"] if r["element"] == "Model geometry and object placement") + assert row["status"] == "changed" and "translation only" in row["reason"] + assert report["claims"]["geometry_unchanged"] is True + + +# --- already-U1 projects and preservation ---------------------------------------------------------- + +U1_POLYGON = ["0.5x1", "270.5x1", "270.5x271", "0.5x271"] + + +@pytest.mark.parametrize("mode", MODES) +def test_an_already_u1_multi_plate_project_comes_out_placement_identical(tmp_path, mode): + # the creator's own Orca-made U1 project: plates on the U1 stride (324), tower where Orca put it + src = rf.project(tmp_path, plates=2, bed=(270.0, 270.0), local=(135.5, 136.0), cfg={ + "printable_area": U1_POLYGON, "wipe_tower_x": ["39.25", "37.6"], "wipe_tower_y": ["212.1", "207.2"], + "prime_tower_width": "30"}) + result = run(tmp_path, src, mode) + assert [i["tag"] for i in rf.items_of(result.output_path)] == [i["tag"] for i in rf.items_of(src)] + cfg = rf.settings_of(result.output_path) + assert (cfg["wipe_tower_x"], cfg["wipe_tower_y"]) == (["39.25", "37.6"], ["212.1", "207.2"]) + assert result.settings_summary["layout"]["status"] == "same_grid" + assert not result.settings_summary["placement_changes"] + + +@pytest.mark.parametrize("mode", MODES) +def test_painting_helper_relationships_and_object_files_are_untouched(tmp_path, mode): + src = rf.project(tmp_path, plates=2, supports={"paint", "enforcer"}) + result = run(tmp_path, src, mode) + a, b = rf.read(src), rf.read(result.output_path) + for name in (n for n in a if n.startswith("3D/Objects/")): + assert a[name] == b[name] # meshes, paint_color, paint_supports + for token in (b"paint_color", b"paint_supports"): + assert a[name].count(token) == b[name].count(token) > 0 or a[name].count(token) == 0 + import re as _re + parts = lambda blob: _re.findall(rb'
CustomReview settings before preparing. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index de0dff02..850cc2b3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -60,6 +60,20 @@ processes** on graceful close, crash, or force-kill. `_SnapmakerU1.3mf` beside the source; **never overwrites the original**. - **`repair`** — clamp rules -> profile swap -> conform filament arrays -> **`u1_identity`** normalize -> scrub foreign tokens -> blank slice_info version. +- **`regrid`** — prepare-time layout correction, both modes: a multi-plate project's + build items are translated onto the U1 plate grid (Orca's rule: stride = + `int(bed) * 1.2`, `cols = compute_colum_count`, origin `(col*sx, -row*sy)`) only + when every item's footprint lies in the cell its own plate record names; + otherwise nothing moves and it says why. Translation tokens only. Prime towers + are assessed (proven outside / on an object / unknown) and never moved, because + their depth is decided by Orca at slice time. +- **`support_intent`** — the one exception to Recommended's starter-settings reset. + Precedence: explicit disable (`enable_support` 0 in the project or on an object) + > positive intent (painted support *enforcers*, `support_enforcer` volumes, + `enable_support` 1 on an object) > starter reset (no data, or blocker-only data) + > the creator's values. Positive intent keeps and declares `enable_support`, + `support_type`, `support_filament`, `support_interface_filament`; Preserve never + reaches it. - **`u1_identity`** — the authoritative U1 identity layer: preset ids, version, filament identity (from the known-good reference), foreign Bambu/BBL/H2D scrub, newer-schema value normalization, and `is_u1_clean()` (the validation gate).