Browse Source

Slicer: stock Bambu Cloud presets mark process changes too (#3497)

- signed in to Bambu Cloud, the stock process presets are picked from
  the cloud and the bundled twin is hidden (#3386), so the recording
  added for standard presets never ran for those users
- a cloud preset from the listing's "public" list now counts as the
  system preset too; the listing comes from the same cache as the
  preset listing, and one that can't be fetched counts as not stock
- private Bambu Cloud, Orca Cloud and local presets stay unmarked, and
  slices with them don't ask Bambu Cloud
maziggy 5 hours ago
parent
commit
291bc1c24b

+ 1 - 1
CHANGELOG.md

@@ -28,7 +28,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **A backup from Windows restored on Linux or macOS finds its files (#3501, reported by @Tech0verlord)** — A Windows install stores the location of each archive, library file and print log entry with backslashes, which Linux and macOS read as part of the file name. After restoring a Windows backup on Docker or another Linux install, every archive showed no thumbnail and refused its 3D preview and reprint with "Archive data unavailable". The restore now turns those backslashes into slashes. It runs only during a restore, does nothing on Windows, and leaves a path alone when a file with that exact name exists. An install already restored from a Windows backup is fixed by restoring the backup again after updating.
 - **Tags written by SpoolBuddy can be read by phones and other readers (#3500, reported by @pleasantone; likely #3076)** — SpoolBuddy's **Write Tag** wrote a copy of the tag's Capability Container in front of the OpenTag3D data. On an NTAG tag that copy landed in the data area, where a phone, NFC Tools or another OpenTag3D reader took it for unknown data, skipped over the spool record and reported an empty tag, although the write had succeeded. Bambuddy itself was unaffected, since it links spools by the tag's ID. The data now starts with the spool record, and the tag's own Capability Container is left as it is. Tags written before need writing again for other readers to see them.
 - **An A2L with an AMS and an AMS Lite shows both (#3499, reported by @auberonkai)** — With an AMS 2 Pro and an AMS Lite attached to an A2L at the same time, the printer card showed only the external spool, until one of them was unplugged and the printer reconnected. Bambuddy failed to sort the two units, because the printer names a regular AMS with text and the AMS Lite with a number, and from then on it could not read the AMS data of any later update. Both units now show, in order, and an AMS update that can't be read no longer blocks the ones after it. The connection diagnostic also no longer fails an A2L on "Store sent files on external storage": neither the printer nor the slicers offer that option for it, so the check is skipped, as on the P1 series, and the explanation no longer names the P1 series only.
-- **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.
+- **Sliced projects keep their changed settings in Bambu Studio (#3497, reported by @mdbook)** — A 3MF sliced in Bambuddy with a system process preset (a bundled one, or Bambu's stock copy on Bambu Cloud, which is what is picked when signed in) 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 the user's own presets (Bambu Cloud, Orca Cloud or local), 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. When a power cut also restarts Bambuddy, it comes back while the printer is still paused in power-loss recovery, and the resume was taken for a new print: a second archive of the same print, left "printing". A print found paused when Bambuddy starts now carries on as the same print once it resumes, as one found printing already did; this also covers a print paused by hand before a restart.
 - **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.

+ 10 - 4
backend/app/api/routes/library.py

@@ -4534,12 +4534,18 @@ async def _run_slicer_with_fallback(
 
     # #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.
+    # Studio reopens it with the stock preset's values. Only for a system
+    # process preset, bundled (standard) or Bambu's stock copy on Bambu Cloud,
+    # which signed-in users pick instead (#3386): the system preset is then
+    # the base those keys differ from. A private cloud, Orca Cloud or local
+    # preset already is the user's own preset.
+    from backend.app.api.routes.slicer_presets import is_stock_cloud_preset
+
+    process_ref = request.process_preset
     process_changes = (
         changed_process_keys(process_as_picked, presets["process"])
-        if request.process_preset is not None and request.process_preset.source == "standard"
+        if process_ref is not None
+        and (process_ref.source == "standard" or await is_stock_cloud_preset(db, user, process_ref, "process"))
         else []
     )
 

+ 12 - 0
backend/app/api/routes/slicer_presets.py

@@ -817,6 +817,18 @@ async def load_stock_aliases(db: AsyncSession, user: User | None) -> dict[str, P
     return _stock_aliases(cloud, standard)
 
 
+async def is_stock_cloud_preset(db: AsyncSession, user: User | None, ref: PresetRef, slot: str) -> bool:
+    """Whether ``ref`` is one of Bambu's stock presets on Bambu Cloud (#3497).
+
+    The cloud listing's "public" list, from the same cache as the listing; a
+    listing that can't be fetched counts as not stock.
+    """
+    if ref.source != "cloud":
+        return False
+    cloud, _status = await _fetch_cloud_presets(db, user)
+    return any(p.stock and p.id == ref.id for p in cloud.get(slot, []))
+
+
 def listed_ref(aliases: dict[str, PresetRef], ref: PresetRef) -> PresetRef:
     """The ref the listing shows for ``ref``: its cloud twin if hidden, else itself."""
     return aliases.get(f"{ref.source}:{ref.id}", ref)

+ 57 - 5
backend/tests/unit/test_process_changes_recorded_3497.py

@@ -17,6 +17,7 @@ import pytest
 
 from backend.app.api.routes.library import _run_slicer_with_fallback
 from backend.app.schemas.slicer import PresetRef, SliceRequest
+from backend.app.schemas.slicer_presets import UnifiedPreset
 from backend.app.services.design_settings import changed_process_keys, record_process_changes
 
 pytestmark = pytest.mark.unit
@@ -118,10 +119,12 @@ class TestChangedKeys:
         assert changed_process_keys("{", json.dumps({"a": "1"})) == []
 
 
-def _request(process_source: str = "standard", **overrides) -> SliceRequest:
+def _request(
+    process_source: str = "standard", process_id: str = "0.20mm Standard @BBL A1M", **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"),
+        process_preset=PresetRef(source=process_source, id=process_id),
         filament_presets=[PresetRef(source="standard", id="Bambu PLA Basic @BBL A1M")],
         export_3mf=True,
         **overrides,
@@ -151,9 +154,17 @@ def _source_3mf() -> bytes:
     return buffer.getvalue()
 
 
-async def _slice(request: SliceRequest, *, model: str = "stl", embedded_fallback: bool = False):
+def _cloud_listing(*presets: UnifiedPreset, status: str = "ok"):
+    slots = {"printer": [], "process": list(presets), "filament": []}
+    return AsyncMock(return_value=(slots, status))
+
+
+async def _slice(request: SliceRequest, *, model: str = "stl", embedded_fallback: bool = False, cloud_listing=None):
     from backend.app.services import preset_resolver, slicer_api as slicer_api_module
 
+    if cloud_listing is None:
+        cloud_listing = _cloud_listing(status="not_authenticated")
+
     result = slicer_api_module.SliceResult(
         content=_sliced_3mf(["", "", ""]),
         print_time_seconds=600,
@@ -183,6 +194,7 @@ async def _slice(request: SliceRequest, *, model: str = "stl", embedded_fallback
         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)),
+        patch("backend.app.api.routes.slicer_presets._fetch_cloud_presets", new=cloud_listing),
     ):
         result, _ = await _run_slicer_with_fallback(
             SimpleNamespace(get=AsyncMock(return_value=None)),
@@ -223,8 +235,48 @@ class TestTheSlice:
     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%"}))
+    async def test_a_local_process_preset_lists_nothing(self):
+        cloud_listing = _cloud_listing(status="not_authenticated")
+        listed = await _slice(
+            _request(process_source="local", process_overrides={"sparse_infill_density": "40%"}),
+            cloud_listing=cloud_listing,
+        )
+
+        assert listed == ["", "", ""]
+        # Nor does it ask Bambu Cloud.
+        cloud_listing.assert_not_awaited()
+
+    async def test_bambus_stock_process_preset_on_bambu_cloud_lists_them(self):
+        # Signed in, the stock presets are picked from Bambu Cloud (#3386).
+        listed = await _slice(
+            _request(
+                process_source="cloud",
+                process_id="GP000",
+                process_overrides={"sparse_infill_density": "40%", "sparse_infill_pattern": "gyroid"},
+            ),
+            cloud_listing=_cloud_listing(
+                UnifiedPreset(id="GP000", name="0.20mm Standard @BBL A1M", source="cloud", stock=True)
+            ),
+        )
+
+        assert listed == ["sparse_infill_density;sparse_infill_pattern", "", ""]
+
+    async def test_a_private_bambu_cloud_process_preset_lists_nothing(self):
+        listed = await _slice(
+            _request(process_source="cloud", process_id="PPUS1", process_overrides={"sparse_infill_density": "40%"}),
+            cloud_listing=_cloud_listing(
+                UnifiedPreset(id="PPUS1", name="My 0.20mm", source="cloud", stock=False),
+                UnifiedPreset(id="GP000", name="0.20mm Standard @BBL A1M", source="cloud", stock=True),
+            ),
+        )
+
+        assert listed == ["", "", ""]
+
+    async def test_a_cloud_preset_without_the_listing_lists_nothing(self):
+        listed = await _slice(
+            _request(process_source="cloud", process_id="GP000", process_overrides={"sparse_infill_density": "40%"}),
+            cloud_listing=_cloud_listing(status="unreachable"),
+        )
 
         assert listed == ["", "", ""]