Explorar o código

Slicer: sliced 3MFs mark Bambuddy's process changes (#3497)

- the slicer CLI leaves different_settings_to_system empty, so the
  process settings Bambuddy put on a system preset (slice dialog
  overrides, ticked designer settings, carried supports) were in the
  3MF unmarked and Bambu Studio reopened it with the preset's values
- the slice now lists the keys it changed on the process preset in
  the process entry of that list, keeping the keys already there
- only for a standard process preset; cloud and local presets and
  the embedded-settings path are left as they were
maziggy hai 1 día
pai
achega
c147ec6e9a

+ 19 - 0
backend/app/api/routes/library.py

@@ -85,8 +85,10 @@ from backend.app.services.archive import ThreeMFParser, estimate_filament_cost
 from backend.app.services.design_settings import (
     DesignOverride,
     apply_design_overrides,
+    changed_process_keys,
     extract_design_process_overrides,
     overrides_from_config,
+    record_process_changes,
 )
 from backend.app.services.filament_requirements import annotate_rack_groups
 from backend.app.services.library_folder_access import (
@@ -4461,6 +4463,9 @@ async def _run_slicer_with_fallback(
     # the embedded plate / model definitions remain intact.
     is_3mf = model_filename.lower().endswith(".3mf")
     primary_bytes = model_bytes
+    # The process JSON before Bambuddy layers its own settings on the picked
+    # preset, to tell which keys the output 3MF has to list as changed (#3497).
+    process_as_picked = presets["process"]
     if is_3mf:
         # Strip inherit/unset sentinels from Metadata/project_settings.config
         # so the CLI's StaticPrintConfig range validator accepts the file
@@ -4514,6 +4519,17 @@ async def _run_slicer_with_fallback(
     if request.process_overrides:
         presets["process"] = apply_process_overrides(presets["process"], request.process_overrides)
 
+    # #3497: the slicer CLI leaves different_settings_to_system empty, so the
+    # keys set above are not marked as changed in the output 3MF and Bambu
+    # Studio reopens it with the stock preset's values. Only for a bundled
+    # (standard) process preset: the system preset is then the base those keys
+    # differ from. A cloud or local preset already is the user's own preset.
+    process_changes = (
+        changed_process_keys(process_as_picked, presets["process"])
+        if request.process_preset is not None and request.process_preset.source == "standard"
+        else []
+    )
+
     used_embedded_settings = False
     # "Slice as designed" (#2611): honour the file's embedded
     # project_settings.config instead of the picked profile triplet. Only
@@ -4867,6 +4883,9 @@ async def _run_slicer_with_fallback(
     # meant to slice with a profile their sidecar image predates. Skipped on
     # the embedded-settings path, which sends no filament profiles for the
     # bundle to resolve in the first place.
+    if process_changes and request.export_3mf and not used_embedded_settings:
+        result = result._replace(content=record_process_changes(result.content, process_changes))
+
     if not used_embedded_settings:
         unresolved = unresolved_filament_slots(result.content, export_3mf=bool(request.export_3mf))
         if unresolved:

+ 69 - 0
backend/app/services/design_settings.py

@@ -225,3 +225,72 @@ def apply_design_overrides(process_json: str, overrides: list[DesignOverride], s
     process_cfg.update(by_key)
     logger.info("Carrying %d design setting(s) onto the picked process preset: %s", len(by_key), sorted(by_key))
     return json.dumps(process_cfg)
+
+
+def changed_process_keys(before_json: str, after_json: str) -> list[str]:
+    """The keys Bambuddy set on a process JSON between two points (#3497).
+
+    Added keys and keys whose value changed. Empty when either side does not
+    parse as a JSON object, so a caller then records nothing rather than a
+    guess.
+    """
+    try:
+        before = json.loads(before_json)
+        after = json.loads(after_json)
+    except json.JSONDecodeError:
+        return []
+    if not isinstance(before, dict) or not isinstance(after, dict):
+        return []
+    return sorted(key for key, value in after.items() if key not in before or before[key] != value)
+
+
+def record_process_changes(zip_bytes: bytes, keys: list[str]) -> bytes:
+    """List *keys* as changed from the system process preset in a sliced 3MF (#3497).
+
+    The slicer CLI writes ``different_settings_to_system`` but never fills it,
+    so the settings Bambuddy put on the picked preset are in the file without
+    being marked. Bambu Studio then opens the project with the stock preset's
+    values for them, and a re-slice there drops them.
+
+    Merges into the process entry (index 0) only: keys already listed stay,
+    the filament and printer entries are left alone. Returns the bytes
+    unchanged when there is nothing to add or the file is not one whose layout
+    we understand, the same checks :func:`overrides_from_config` reads it with.
+    """
+    if not keys:
+        return zip_bytes
+    try:
+        with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zin:
+            if _PROJECT_SETTINGS not in zin.namelist():
+                return zip_bytes
+            config = json.loads(zin.read(_PROJECT_SETTINGS).decode("utf-8"))
+            if not isinstance(config, dict):
+                return zip_bytes
+            changed = config.get("different_settings_to_system")
+            filaments = config.get("filament_settings_id")
+            if (
+                not isinstance(changed, list)
+                or not isinstance(filaments, list)
+                or len(changed) != len(filaments) + 2
+                or not isinstance(changed[0], str)
+            ):
+                logger.info("Sliced 3MF has no usable different_settings_to_system; process changes not recorded")
+                return zip_bytes
+            listed = _split_changed_keys(changed[0])
+            merged = sorted(set(listed) | set(keys))
+            if merged == sorted(listed):
+                return zip_bytes
+            changed[0] = ";".join(merged)
+            patched = json.dumps(config, indent=4)
+
+            dst = BytesIO()
+            with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
+                for item in zin.infolist():
+                    if item.filename == _PROJECT_SETTINGS:
+                        zout.writestr(item, patched)
+                    else:
+                        zout.writestr(item, zin.read(item.filename))
+    except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError):
+        return zip_bytes
+    logger.info("Recorded %d process setting(s) as changed from the system preset", len(keys))
+    return dst.getvalue()

+ 236 - 0
backend/tests/unit/test_process_changes_recorded_3497.py

@@ -0,0 +1,236 @@
+"""Settings Bambuddy puts on a process preset are marked in the sliced 3MF (#3497).
+
+The slicer CLI writes ``different_settings_to_system`` but leaves it empty, so
+a slice-modal override (40% gyroid) was in ``project_settings.config`` without
+being listed as changed from the system preset. Bambu Studio opened the
+project with the stock preset's values instead, and a re-slice there dropped
+the override.
+"""
+
+import io
+import json
+import zipfile
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.api.routes.library import _run_slicer_with_fallback
+from backend.app.schemas.slicer import PresetRef, SliceRequest
+from backend.app.services.design_settings import changed_process_keys, record_process_changes
+
+pytestmark = pytest.mark.unit
+
+_SETTINGS = "Metadata/project_settings.config"
+
+
+def _sliced_3mf(changed: list[str], filaments: int = 1, **extra) -> bytes:
+    buffer = io.BytesIO()
+    with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
+        archive.writestr("3D/3dmodel.model", "<model/>")
+        archive.writestr("Metadata/plate_1.gcode", "M1002 gcode_claim_action : 1\n")
+        archive.writestr(
+            _SETTINGS,
+            json.dumps(
+                {
+                    "machine_start_gcode": "M1002 gcode_claim_action : 1\nM620 M\n",
+                    "print_settings_id": "0.20mm Standard @BBL A1M",
+                    "filament_settings_id": ["Bambu PLA Basic @BBL A1M"] * filaments,
+                    "different_settings_to_system": changed,
+                    **extra,
+                }
+            ),
+        )
+    return buffer.getvalue()
+
+
+def _config(zip_bytes: bytes) -> dict:
+    with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive:
+        return json.loads(archive.read(_SETTINGS))
+
+
+class TestRecord:
+    def test_the_keys_land_in_the_process_entry(self):
+        out = record_process_changes(_sliced_3mf(["", "", ""]), ["sparse_infill_density", "sparse_infill_pattern"])
+
+        assert _config(out)["different_settings_to_system"] == ["sparse_infill_density;sparse_infill_pattern", "", ""]
+
+    def test_keys_already_listed_stay(self):
+        out = record_process_changes(
+            _sliced_3mf(["wall_loops;enable_support", "filament_flow_ratio", "machine_start_gcode"]),
+            ["sparse_infill_density", "wall_loops"],
+        )
+
+        assert _config(out)["different_settings_to_system"] == [
+            "enable_support;sparse_infill_density;wall_loops",
+            "filament_flow_ratio",
+            "machine_start_gcode",
+        ]
+
+    @pytest.mark.parametrize("filaments", [2, 3, 4])
+    def test_only_the_process_entry_changes_with_several_filaments(self, filaments):
+        changed = [""] * (filaments + 2)
+
+        out = record_process_changes(_sliced_3mf(changed, filaments), ["sparse_infill_density"])
+
+        assert _config(out)["different_settings_to_system"] == ["sparse_infill_density"] + [""] * (filaments + 1)
+
+    def test_nothing_to_record_leaves_the_file_alone(self):
+        source = _sliced_3mf(["", "", ""])
+
+        assert record_process_changes(source, []) is source
+
+    def test_a_list_that_does_not_match_the_filaments_is_left_alone(self):
+        source = _sliced_3mf(["", ""])
+
+        assert record_process_changes(source, ["sparse_infill_density"]) is source
+
+    def test_a_file_without_the_list_is_left_alone(self):
+        buffer = io.BytesIO()
+        with zipfile.ZipFile(buffer, "w") as archive:
+            archive.writestr(_SETTINGS, json.dumps({"filament_settings_id": ["x"]}))
+        source = buffer.getvalue()
+
+        assert record_process_changes(source, ["sparse_infill_density"]) is source
+
+    def test_not_a_zip_is_left_alone(self):
+        assert record_process_changes(b"; plain gcode\n", ["sparse_infill_density"]) == b"; plain gcode\n"
+
+    def test_every_other_member_is_kept(self):
+        source = _sliced_3mf(["", "", ""], layer_height="0.2")
+
+        out = record_process_changes(source, ["sparse_infill_density"])
+
+        with zipfile.ZipFile(io.BytesIO(source)) as before, zipfile.ZipFile(io.BytesIO(out)) as after:
+            assert before.namelist() == after.namelist()
+            assert before.read("Metadata/plate_1.gcode") == after.read("Metadata/plate_1.gcode")
+        assert _config(out)["layer_height"] == "0.2"
+
+
+class TestChangedKeys:
+    def test_added_and_changed_keys(self):
+        before = json.dumps({"name": "x", "inherits": "x", "wall_loops": "2"})
+        after = json.dumps({"name": "x", "inherits": "x", "wall_loops": "3", "sparse_infill_density": "40%"})
+
+        assert changed_process_keys(before, after) == ["sparse_infill_density", "wall_loops"]
+
+    def test_unparseable_json_records_nothing(self):
+        assert changed_process_keys("{", json.dumps({"a": "1"})) == []
+
+
+def _request(process_source: str = "standard", **overrides) -> SliceRequest:
+    return SliceRequest(
+        printer_preset=PresetRef(source="standard", id="Bambu Lab A1 mini 0.4 nozzle"),
+        process_preset=PresetRef(source=process_source, id="0.20mm Standard @BBL A1M"),
+        filament_presets=[PresetRef(source="standard", id="Bambu PLA Basic @BBL A1M")],
+        export_3mf=True,
+        **overrides,
+    )
+
+
+def _source_3mf() -> bytes:
+    buffer = io.BytesIO()
+    with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
+        archive.writestr("3D/3dmodel.model", "<model/>")
+        archive.writestr(
+            _SETTINGS,
+            json.dumps(
+                {
+                    "enable_support": "1",
+                    "support_type": "tree(auto)",
+                    "filament_settings_id": ["Bambu PLA Basic @BBL A1M"],
+                    "different_settings_to_system": ["enable_support;support_type", "", ""],
+                }
+            ),
+        )
+        archive.writestr("Metadata/model_settings.config", "<config><object id='1'/></config>")
+        archive.writestr(
+            "Metadata/slice_info.config",
+            "<config><plate><metadata key='index' value='1'/></plate></config>",
+        )
+    return buffer.getvalue()
+
+
+async def _slice(request: SliceRequest, *, model: str = "stl", embedded_fallback: bool = False):
+    from backend.app.services import preset_resolver, slicer_api as slicer_api_module
+
+    result = slicer_api_module.SliceResult(
+        content=_sliced_3mf(["", "", ""]),
+        print_time_seconds=600,
+        filament_used_g=12.0,
+        filament_used_mm=4000.0,
+    )
+    service = MagicMock()
+    service.close = AsyncMock()
+    if embedded_fallback:
+        service.slice_with_profiles = AsyncMock(side_effect=slicer_api_module.SlicerApiServerError("boom"))
+        service.slice_without_profiles = AsyncMock(return_value=result)
+    else:
+        service.slice_with_profiles = AsyncMock(return_value=result)
+        service.slice_without_profiles = AsyncMock()
+
+    async def _setting(_db, key):
+        return {"preferred_slicer": "bambu_studio", "bambu_studio_api_url": "http://sidecar:3000"}.get(key)
+
+    async def _resolve(_db, _user, ref, slot):
+        if ref.source == "standard":
+            return preset_resolver._resolve_standard(ref, slot)
+        # A cloud or local preset arrives flattened.
+        return json.dumps({"name": ref.id, "from": "system", "type": "process", "wall_loops": "3"})
+
+    with (
+        patch("backend.app.api.routes.settings.get_setting", new=AsyncMock(side_effect=_setting)),
+        patch("backend.app.services.preset_resolver.resolve_preset_ref", new=AsyncMock(side_effect=_resolve)),
+        patch.object(slicer_api_module, "SlicerApiService", return_value=service),
+        patch.object(slicer_api_module, "get_stall_timeout_seconds", new=AsyncMock(return_value=60.0)),
+    ):
+        result, _ = await _run_slicer_with_fallback(
+            SimpleNamespace(get=AsyncMock(return_value=None)),
+            model_bytes=_source_3mf() if model == "3mf" else b"solid cube\nendsolid cube\n",
+            model_filename=f"cube.{model}",
+            request=request,
+            current_user_id=None,
+        )
+    return _config(result.content)["different_settings_to_system"]
+
+
+@pytest.mark.asyncio
+class TestTheSlice:
+    async def test_the_reporters_overrides_are_listed(self):
+        listed = await _slice(
+            _request(process_overrides={"sparse_infill_density": "40%", "sparse_infill_pattern": "gyroid"})
+        )
+
+        assert listed == ["sparse_infill_density;sparse_infill_pattern", "", ""]
+
+    async def test_the_build_plate_pick_is_not_listed(self):
+        listed = await _slice(_request(bed_type="Textured PEI Plate", process_overrides={"wall_loops": "3"}))
+
+        assert listed == ["wall_loops", "", ""]
+
+    async def test_the_designer_settings_picked_are_listed(self):
+        # support_type was offered and left unticked (#2942): not carried, not listed.
+        listed = await _slice(_request(design_overrides=["enable_support"]), model="3mf")
+
+        assert listed == ["enable_support", "", ""]
+
+    async def test_the_carried_support_settings_are_listed(self):
+        # A client without the per-key choice gets the #1881 support carry.
+        listed = await _slice(_request(), model="3mf")
+
+        assert listed == ["enable_support;support_type", "", ""]
+
+    async def test_no_overrides_lists_nothing(self):
+        assert await _slice(_request()) == ["", "", ""]
+
+    async def test_a_cloud_or_local_process_preset_lists_nothing(self):
+        listed = await _slice(_request(process_source="local", process_overrides={"sparse_infill_density": "40%"}))
+
+        assert listed == ["", "", ""]
+
+    async def test_the_embedded_settings_fallback_lists_nothing(self):
+        listed = await _slice(
+            _request(process_overrides={"sparse_infill_density": "40%"}), model="3mf", embedded_fallback=True
+        )
+
+        assert listed == ["", "", ""]