Browse Source

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 1 day ago
parent
commit
8c0398c70e

+ 1 - 0
CHANGELOG.md

@@ -19,6 +19,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **"Any model" jobs go to the printer with the fewest print hours (#3480, sponsor vote Q4 2026)** — When several printers of a model can take a job, the queue used to pick the first one in its list, so on a farm the same printer got most of the work. It now picks the one with the fewest print hours (the Maintenance counter, including baseline hours entered there), so jobs and wear spread across the farm. Preferred colours still come first, and the queue powers on the least used printer too when it has to switch one on with an Auto Power On plug.
 
 ### Fixed
+- **Sliced projects keep their changed settings in Bambu Studio (#3497, reported by @mdbook)** — A 3MF sliced in Bambuddy with a system process preset carries the settings changed on top of it: what was set in the slice dialog's process settings, the designer's settings that were kept ticked, and the source file's supports. The slicer wrote their values into the project but did not mark them as changed from the preset, so Bambu Studio opened the project with the preset's own values and a re-slice there lost them. They are now marked as changed. Slices with a cloud or local preset, and those that use the file's own settings, are unchanged.
 - **A restart no longer sends printer errors again, and their titles name the right part (#3486, reported by @SaadShaya7)** — Printers keep some faults in their error list long after they happened, without showing them on the screen, for example "There was an unfinished print job when the printer lost power" after a power cut. Bambuddy remembered which faults it had already notified only until it restarted, so every restart sent each of those faults again. The notified faults are now stored, so a restart only notifies faults that are new. The title of a printer error notification also named the wrong part for several kinds of fault: USB, network and file errors came as "Nozzle/Extruder Error", camera faults as "Motion Controller Error", and an A1 mini's AMS Lite runouts as "Chamber Error". They now read "System Error", "Camera Error" and "AMS Lite Error"; AMS-HT, AMS 3H, toolhead, tool changer and air filter faults are named too, and a fault of any other kind is titled "Printer Error".
 - **A queue job resumed by the printer finishes in the queue (#3485, reported by @SaadShaya7)** — Bambuddy checks that a print's completion is about the queue job it closes, by comparing the name the printer reports with the name the job was sent under. A printer that picks a print back up, such as an A1 mini resuming after a power cut or a print Bambuddy reconnects to after a restart, reports the file name with ".3mf" on the end instead, so the names no longer matched: the completion was ignored and the job stayed "printing", blocking the printer's queue until it was stopped by hand. The name is now also compared without its extension. The same comparison decides whether a print counts as started outside Bambuddy, so with **Also ask for prints started outside Bambuddy** on, such a resumed queue job is no longer asked about as an external print.
 - **A virtual H2C reports the right model to the slicer** — The H2C comes as two models: older units (O1C, serials starting 094, like the H2D) and those from 2026 (O1C2, serials starting 31B8B). Bambu Studio only syncs printer information with a virtual printer that reports the printer's own one, and says "Printer not connected" otherwise, but the virtual printer list showed both as "H2C". The list now names each H2C entry with its serial family, and says to pick the one whose serial starts like the printer's. A proxy virtual printer takes the right one from its target printer's serial, and a virtual O1C2 now has a serial starting 31B8B like a real one, instead of the H2D's 094, so the slicer also shows it the H2C's HMS texts. An existing virtual O1C2 gets a new serial with this: add it in the slicer again once.

+ 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 (
@@ -4474,6 +4476,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
@@ -4527,6 +4532,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
@@ -4880,6 +4896,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 == ["", "", ""]