From bdac27ebee28d4038800521de760e3a55b3c8f54 Mon Sep 17 00:00:00 2001 From: maziggy Date: Thu, 2 Jul 2026 11:32:22 +0200 Subject: [PATCH] fix(slicer): preserve PVA-for-support intent across re-slice of source 3MF (#1881) Three bugs on the same PLA-model + PVA-support flow, discovered in sequence: (A) substitute_unused_plate_filaments inspected only object geometry (per-object extruder metadata + paint_color triangles) so a support- only slot was silently treated as "unused" and the user's PVA profile got overwritten with slot 1's PLA. (B) _extract_filament_info stripped filament_is_support==1 entries, hiding PVA from unsliced source archive cards even when the project explicitly configured it. (C) --load-settings is authoritative over the source's project_settings. config, and Bambu's shipped process presets ship enable_support=0 (supports are a per-print decision, not per-quality). So even with (A) fixed, the sliced output had supports disabled and the PVA slot loaded but never consumed. Inverts BambuStudio GUI's semantics where the project overrides the preset. Fixes: - New extract_support_filament_slots_from_3mf reads enable_support + support_filament + support_interface_filament from project_settings. config; substitute_unused_plate_filaments unions it into the geometry- derived set. - _extract_filament_info returns all configured filament types + colours. - New _patch_process_support_settings overlays four fields (enable_ support, support_filament, support_interface_filament, support_type) from the source 3MF onto the picked process preset JSON before --load-settings sees it. Deliberately targeted to what fixes #1881 without widening to a full project-over-preset merge. --- CHANGELOG.md | 2 + backend/app/api/routes/library.py | 60 +++++++ backend/app/services/archive.py | 51 +++--- backend/app/services/slicer_3mf_convert.py | 10 +- backend/app/utils/threemf_tools.py | 47 ++++++ .../unit/services/test_archive_service.py | 73 +++++++++ .../unit/services/test_slicer_3mf_convert.py | 45 ++++++ .../unit/test_slice_process_support_patch.py | 153 ++++++++++++++++++ backend/tests/unit/test_threemf_tools.py | 93 +++++++++++ 9 files changed, 506 insertions(+), 28 deletions(-) create mode 100644 backend/tests/unit/test_slice_process_support_patch.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a57b8852..88d046caf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b2] - Unreleased ### Fixed +- **Server-side slice of a PLA-model / PVA-support 3MF silently loses the PVA — supports print in PLA, archive card hides the PVA tag (#1881, reporter @JonasFovea)** — Reporter uploaded a multi-material H2D Pro 3MF configured with PLA in slot 1 and PVA as support material in slot 2, hit Slice, picked a profile for both slots in the SliceModal. Result: the resulting `.gcode.3mf` carried a single filament (PLA) with no visible supports in the 3D preview, and the archive card never displayed the PVA badge for the source 3MF either. Local BambuStudio slice of the identical file with default settings worked. **Three distinct bugs on the same happy path**, discovered in sequence as each fix uncovered the next. **Bug A — `substitute_unused_plate_filaments` overwrites support-material slots.** The frontend does receive both filaments (log line `get_filament_info called with 4 IDs: ['GFA01', 'GFA00', 'GFG02', 'GFU00']`; `GFU` is Bambu's PVA prefix), the SliceModal renders two dropdowns via `extract_project_filaments_from_3mf`, the user picks a PVA profile for slot 2 and the payload arrives at `_run_slicer_with_fallback` intact. Before the sidecar call, `_run_slicer_with_fallback` (`backend/app/api/routes/library.py:3521`) invokes `substitute_unused_plate_filaments` — the helper that replaces "not used by this plate" slot entries with slot 1's profile so BambuStudio's loaded-filament temperature-spread validator doesn't reject the job (`temperature difference of the filaments used is too large`, exit 194). That helper delegates to `extract_plate_extruder_set_from_3mf` (`backend/app/utils/threemf_tools.py:878`) to enumerate which slots the plate actually references. The extractor walks three sources — `` top-level `extruder` metadata, per-`` overrides, and `paint_color` triangle quadtree leaves — all **object-geometry-derived**. **Support material is a process setting**, not object geometry: BambuStudio writes `support_filament` / `support_interface_filament` / `enable_support` into `Metadata/project_settings.config` and the slicer's process pass generates the support paths at slice time. The three geometry sources return `{1}`, `substitute_unused_plate_filaments` sees slot 2 as "unused", overwrites the user's PVA profile with slot 1's PLA profile, and the sidecar gets `[PLA_json, PLA_json]`. Silent, no error, no warning, no log line. **Bug B — `_extract_filament_info` strips support filaments from the tag list.** Independent from A: `backend/app/services/archive.py:377` was reading `filament_is_support` and excluding any slot where the flag was `"1"` from `filament_type` / `filament_color`. That's what drives the ArchiveCard's material badges. Result: even a correctly-sliced PLA+PVA `.gcode.3mf` would show only "PLA Basic" on the card. **Bug C — process preset overrides source's `enable_support`, `support_filament`, `support_interface_filament`, `support_type`.** Uncovered on the first test slice after A + B shipped: the sliced archive still had only PLA. Comparing `project_settings.config` in the source vs the sliced output: source has `enable_support: '1'` + `support_interface_filament: '2'`, sliced output has `enable_support: 0` + `support_interface_filament: 0`. Bambuddy passes the picked process preset via BambuStudio CLI's `--load-settings`, which is authoritative — every field in the loaded JSON overrides the source 3MF's embedded `project_settings.config`. Bambu's shipped process presets ("0.20mm Standard @BBL H2D" etc.) ship `enable_support: 0` because supports are a per-print decision, not a per-quality one. So even with the substitute-fix (A) sending both filaments to the CLI, `slice_info.config` in the output shows `support_used="false"` and only one `` entry consumed — PLA. The PVA slot loads but never gets referenced. **This inverts BambuStudio GUI's semantics** where the loaded project's settings are authoritative and the process preset is the inheritance backbone — but Bambuddy's `--load-settings` flow has preset winning over project, so any per-project setting the user configured before exporting the 3MF (supports on, PVA-for-interface, etc.) gets discarded on re-slice. **Fix A.** New helper `extract_support_filament_slots_from_3mf(zf)` in `threemf_tools.py` reads `enable_support` / `support_filament` / `support_interface_filament` from `project_settings.config` and returns the set of slots that must stay "used" for a plate print. Gated on `enable_support` (accepts BambuStudio's stringly-typed `"1"`/`"0"`, real booleans, and empty falsy variants); slot value `0` means "same as model" and is ignored; slot value `> 0` is added to the set. `substitute_unused_plate_filaments` unions this into the geometry-derived set — so a support-only slot is now correctly seen as "used" and the user's PVA profile survives to the slicer. When supports are DISABLED (or `support_filament == 0`), the substitution still runs and homogenises the loaded-filament array, preserving the pre-existing #1493 fix for the temperature-spread validator. **Fix B.** Removed the `filament_is_support` filter from `_extract_filament_info`. All configured filament types (PLA, PVA, etc.) now land on `filament_type` / `filament_color` in slot order with dedup on repeats. Sliced `.gcode.3mf` files are unaffected because they promote `_slice_filament_type` (parsed from `slice_info.config` which lists what the print actually consumed) over the project-settings fallback at `archive.py:150-155`; the filter change only affects unsliced source 3MFs that a user uploaded to the Archives page. **Fix C.** New helper `_patch_process_support_settings(process_json, source_3mf_bytes)` reads the source's `project_settings.config` and overlays four support-related fields onto the picked process preset JSON before `--load-settings` sees it: `enable_support`, `support_filament`, `support_interface_filament`, `support_type`. Deliberately targeted — the scope is what fixes #1881 without opening the semantic can of "should every project setting override every preset field" (which would need to reconcile #1201's sentinel handling, all the bed-type / prime-tower / brim / raft edge cases, and users who deliberately pick a preset to escape a broken 3MF's settings). Widening to more fields as follow-up if more "preserve X" reports come in. Silently no-ops on STL / STEP (no `project_settings.config`), on malformed sources, and on malformed presets — the slice then runs with the preset's own defaults, matching pre-fix behaviour on those paths. **Verification.** Ran the patch against the reporter's actual 3MF from Bambuddy's live DB (archive #262): source has `enable_support: '1'` + `support_interface_filament: '2'` + `support_type: 'normal(manual)'`, patched preset gets those exact values while its `layer_height: '0.20'` stays untouched. **Tests.** Nine new cases in `test_threemf_tools.py::TestExtractSupportFilamentSlotsFrom3mf` pin the helper (headline PLA+PVA scenario, distinct body/interface slots, `enable_support` off, slot-0 == same-as-model, JSON bool `enable_support`, integer vs string slot values, missing project settings, malformed JSON, non-numeric slot). Two new cases in `TestSubstituteUnusedPlateFilaments` end-to-end the substitution — `test_support_material_slot_preserved` reproduces the reporter's exact model_settings+project_settings shape and asserts slot 2's `pva_support.json` is NOT overwritten; `test_support_disabled_still_substitutes_unused` is the regression guard that turning supports off preserves the pre-existing homogenisation behaviour. Three new cases in `test_archive_service.py::TestThreeMFParserSupportMaterial` cover Bug B — reporter's PLA+PVA source ships both types + both colours to `filament_type`/`filament_color`, single-support-only degenerate case, duplicate types deduped while duplicate colours kept for the multi-colour path. New `test_slice_process_support_patch.py::TestPatchProcessSupportSettings` (9 cases) pins Fix C: reporter's exact scenario (source wins, layer_height preserved), source-off beats preset-on (symmetric direction), partial-source only patches keys it defines, no-project-settings passthrough, malformed source passthrough, malformed project-JSON passthrough, non-dict project settings passthrough, malformed preset passthrough, non-dict preset passthrough. 337/337 across all affected test files green, ruff clean. **Scope.** Backend-only. One new helper in `threemf_tools.py` (~40 LOC), one 3-line union in `slicer_3mf_convert.py`, one function body simplification in `archive.py`, one new helper in `library.py` (~35 LOC), one call-site in `_run_slicer_with_fallback`. No DB migration, no new permission, no i18n key, no frontend change. Users on 0.2.5b1 and earlier who tried server-side slicing a multi-material PVA-support 3MF: the slice will now actually include PVA supports on the next attempt after upgrade. Users who uploaded source 3MFs with PVA-for-support: the archive card will show both materials after a Bambuddy restart (badges are derived at parse time, so re-uploading refreshes the display; existing rows keep whatever they parsed with). + - **Bambu Studio on macOS won't reconnect to the VP after machine sleep — zombie writer pinned in `_clients` for hours (#1872, reporter @avvidme)** — Reporter on H2C + macOS 26.5.1 + BS 2.8.0.50: after every sleep/wake cycle Bambu Studio couldn't see the VP or connect to it. Only workaround was quitting BS and rebooting Bambuddy. The physical printer's own cloud / LAN link recovered in ~5 s from the same sleep, so the delta is in the VP's session-handling. **Log evidence (`bug-report-assets/logs/ddf1ede75df045cd94ad223d0f08f88a.log`).** 14:04:06 shows a healthy `1Hz status push: 60 pushes/min to [IP]:54698` — full-rate 1 Hz for the last minute pre-sleep. Then 5 min of SSDP output only — no push summary for :54698, no OSError, no disconnect line. At 14:09:16 a brand-new TCP source port :54861 connects, authenticates, subscribes — so the MQTT server is not rejecting reconnects. At 14:10:17, the first DEBUG line after the reporter enabled debug logging is `MQTT drain timeout for device/…/report — client may be busy` — smoking gun. **Root cause.** `_publish_to_report` at `mqtt_server.py:1149-1152` caught `asyncio.wait_for(writer.drain(), timeout=5)` `TimeoutError` at DEBUG and returned silently. Timeouts are not `OSError`, so the push loop's `except OSError` at `:441` never saw them — the client sat in `self._clients` until the OS's default TCP keepalive detected the dead peer, which on Linux is `tcp_keepalive_time=7200 s` (2 h) + 9 probes × 75 s = ~2 h 11 min. That's exactly the 5 min silence in the log; drain likely stayed under the 5 s ceiling because the kernel TX buffer had room, so the DEBUG line didn't even fire until much later. Meanwhile the loop was iterating with a stalled client sitting in the dict every tick. **Fix — two hunks.** (1) On drain `TimeoutError`, close the writer (best-effort, catch `Exception` so an already-broken writer doesn't mask the raise) and raise `BrokenPipeError`. The eviction path is indirect but reliable: `BrokenPipeError` is an `OSError` subclass, so it's caught by every `_send_*` wrapper's outer `except OSError` at `:1036` / `:1118` / `:1236` and logged at ERROR — push_counts increments on this tick, no direct eviction. BUT `writer.close()` was called inside `_publish_to_report`, so on the next push-loop tick, `writer.is_closing()` at `:431` returns True → the client is appended to `disconnected` → popped from `_clients`, `_client_serials`, `push_counts` on the same tick. Real eviction latency: ~1 s (one 1 Hz tick), down from ~2 h. Same mechanism as the pre-existing hard-disconnect path (RST → OSError swallowed in `_send_*` → transport marks closing → next-tick eviction), just extended to also cover the "silent stall" case that has no OS-level RST. (2) Tighten the Linux TCP-keepalive schedule right after `SO_KEEPALIVE=1` at `:600`: `TCP_KEEPIDLE=60`, `TCP_KEEPINTVL=15`, `TCP_KEEPCNT=4` — dead-peer detection in ~2 min instead of ~2 h. `getattr(socket, ...)` guards keep the code cross-platform: macOS has `TCP_KEEPINTVL` but not `TCP_KEEPIDLE` (it exposes `TCP_KEEPALIVE` under a different constant), other platforms silently skip whichever knobs their kernel doesn't expose. **What I initially got wrong.** First-pass hypothesis was "no MQTT session takeover on same `client_id`". Wrong. `_handle_connect` at `:762` parses the protocol client_id but discards it (assignment commented out), and `self._clients` is keyed on socket peer `f"{addr[0]}:{addr[1]}"`, so each reconnect gets a distinct key — no takeover race actually exists. The log fixed this: the "not seen" symptom was BS-side (macOS UDP receive socket recovering slowly from sleep, plus BS's `client_id` still holding the old socket state) but the *server-side amplifier* was the zombie writer keeping push loop attention. **Tests.** 3 new cases in `test_vp_mqtt_server.py`. `TestSendPublishDrainTimeoutEviction::test_drain_timeout_raises_broken_pipe_and_closes_writer` patches `asyncio.wait_for` to raise `TimeoutError` immediately and asserts `BrokenPipeError` propagates AND `writer.close()` was called — pins both halves of the contract. `..._still_closes_writer_when_close_fails` covers the best-effort `close()`: even if the writer is already broken and `.close()` raises, `_publish_to_report` must still raise `BrokenPipeError` — silent swallowing here would put us right back to the pre-fix zombie state. `TestHandleClientTCPKeepaliveTuning::test_handle_client_source_names_the_tuning_constants` uses `inspect.getsource` to pin that `_handle_client` references `TCP_KEEPIDLE`, `TCP_KEEPINTVL`, `TCP_KEEPCNT` — a socket-module regression or a stripped-down platform can then be diagnosed from a support bundle. Full VP MQTT + VP manager suites 179/179 green, ruff clean. **Scope.** Backend-only. No new i18n key, no new permission, no DB migration, no frontend change. Users on 0.2.5b1 or earlier with any macOS slicer client: fix takes effect on next Bambuddy restart, no reconfiguration needed. On non-Linux hosts (e.g. Bambuddy running on macOS or FreeBSD for development), the keepalive-schedule tightening is a no-op — the drain-timeout eviction still applies. - **Non-proxy VP camera passthrough is dead for A1 / P1 targets — OrcaSlicer Liveview fails with `[2:-10061]` (#1868, reporter @tom4711-2)** — Symptom on a P1S target in server/non-proxy VP mode: the VP starts 3000, 3002, 8883, 990 and 322, but nothing on 6000, so OrcaSlicer's Liveview button fails. Reporter confirmed a raw `socat` forwarder `:6000 → :6000` immediately restores the stream — the target camera works, the VP just isn't publishing it. **Root cause.** `backend/app/services/virtual_printer/manager.py:1098-1118` hardcoded the camera-passthrough `TCPProxy` to `listen_port=322 / target_port=322` regardless of the target printer's model. That port is correct for RTSPS models (X1/X2/H2/P2S), but A1 / A1 Mini / P1P / P1S use Bambu's proprietary chamber-image protocol on port 6000 — the 322 listener the manager opens for those targets has no upstream, and the slicer's connection to `:322` yields "connection refused". Proxy mode is unaffected because `SlicerProxyManager` (`tcp_proxy.py:1596`) already opens 6000 for file-transfer, and Bambu reuses the same port for chamber-image, so the passthrough coincidentally works there. **Fix.** Read the target printer's model from `printer_manager.get_client(target_id).model` at the same point the target IP is read, then call `get_camera_port(target_model)` — the same source of truth used by `routes/camera.py` and covered by `test_printer_models.py::TestSupportsRtsp` / `TestGetCameraPort` — to pick 322 or 6000. TCPProxy listen_port and target_port both follow the same value. Rename the log tag from `"RTSP"` to `f"Camera-{camera_port}"` so support-bundle grep tells you at a glance which protocol the VP is fronting for. The internal `_rtsp_proxy` attribute name is unchanged to keep the diff tight; the comment above the block spells out that it doubles as chamber-image passthrough on A1/P1. Model comes from the target printer's client instance (`target_client.model`), NOT `self.model` — the VP's spoofed identity has no bearing on how the physical printer serves its camera. **Tests.** New `TestVirtualPrinterCameraPassthrough` in `test_virtual_printer.py` — 4 cases pin the branch: `test_rtsp_model_p2s_opens_port_322` and `test_rtsp_model_x1c_opens_port_322` guard the RTSP path stays on 322 (regression against the fix accidentally routing everything to 6000); `test_chamber_image_model_p1s_opens_port_6000` and `test_chamber_image_model_a1_opens_port_6000` are the direct #1868 guards — P1S / A1 targets must expose 6000. The P1S case additionally asserts NO 322 listener was opened, since a stale 322 on an A1/P1 install would confuse anyone port-scanning the VP. Test harness monkeypatches `TCPProxy` and every peer service (`VirtualPrinterFTPServer`, `SimpleMQTTServer`, `MQTTBridge`, `BindServer`, `VirtualPrinterSSDPServer`, `SSDPProxy`) to lightweight MagicMocks with an already-set `asyncio.Event` on `.ready` — `start_server()` awaits `.ready.wait()` on those four barriers before returning, so an unset event would deadlock the test. 143/143 in the file green (was 139 + 4 new). **Scope.** Backend-only, one-hunk change in `manager.py` plus 4 tests. No i18n key, no permission change, no DB migration, no frontend change. Users on 0.2.5b1 or earlier with A1 / A1 Mini / P1P / P1S targets in non-proxy VP mode: the fix takes effect on the next Bambuddy restart, no reconfiguration needed. The workaround `socat` forwarder can be removed after upgrade. diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 069b9751d..b4c4f6ac5 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -3285,6 +3285,57 @@ def _patch_process_bed_type(process_json: str, bed_type: str) -> str: return json.dumps(profile) +# Support-related keys we lift from the source 3MF's project_settings.config +# into the picked process preset before `--load-settings` sees it (#1881). +# BambuStudio's shipped process presets ("0.20mm Standard @BBL H2D" etc.) +# define `enable_support: 0` as their default — supports are a per-print +# decision, not a per-quality one. `--load-settings` is authoritative, so +# without preserving these fields the source's per-project support intent +# (supports on, PVA in the interface slot, tree vs normal) gets discarded +# and the slicer produces a single-material output with no supports at all. +_SOURCE_PROCESS_SUPPORT_KEYS_TO_PRESERVE = ( + "enable_support", + "support_filament", + "support_interface_filament", + "support_type", +) + + +def _patch_process_support_settings(process_json: str, source_3mf_bytes: bytes) -> str: + """Overlay the source 3MF's support configuration onto the process JSON. + + Only fires on 3MF sources — STL / STEP don't carry `project_settings. + config`. Silently no-ops when the source doesn't have the config, has + a malformed one, or when the process JSON isn't parseable — the slice + then runs with the process preset's own defaults, which is the safe + fall-back for both this bug and the pre-fix behaviour. + """ + from io import BytesIO + + try: + with zipfile.ZipFile(BytesIO(source_3mf_bytes), "r") as zf: + if "Metadata/project_settings.config" not in zf.namelist(): + return process_json + src_cfg = json.loads(zf.read("Metadata/project_settings.config").decode("utf-8")) + except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError, KeyError): + return process_json + if not isinstance(src_cfg, dict): + return process_json + + try: + process_cfg = json.loads(process_json) + except json.JSONDecodeError: + return process_json + if not isinstance(process_cfg, dict): + return process_json + + for key in _SOURCE_PROCESS_SUPPORT_KEYS_TO_PRESERVE: + if key in src_cfg: + process_cfg[key] = src_cfg[key] + + return json.dumps(process_cfg) + + # The sidecar prefixes the slicer CLI's own error_string with this when the # slicer ran and rejected the job (model off the bed, incompatible filament # temps, range validation) — as opposed to the CLI crashing before it could @@ -3458,6 +3509,15 @@ async def _run_slicer_with_fallback( # didn't touch) still drive the slice. primary_bytes = _sanitize_project_settings_sentinels(primary_bytes) + # #1881: preserve the source 3MF's support configuration on top of + # the picked process preset. Bambu's shipped process presets set + # `enable_support: 0` by default (supports are a per-print, not + # per-quality, decision); `--load-settings` is authoritative so + # without patching, the source's `enable_support: 1` + support-slot + # assignments get discarded and the slice comes out single-material + # with a PVA slot loaded but never used. + presets["process"] = _patch_process_support_settings(presets["process"], primary_bytes) + used_embedded_settings = False service = SlicerApiService(api_url) diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index 2702bc84a..816480ead 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -375,42 +375,39 @@ class ThreeMFParser: pass # G-code header parsing is best-effort; metadata may come from other sources def _extract_filament_info(self, data: dict): - """Extract filament info, preferring non-support filaments.""" + """Extract filament info from project settings — includes support + materials so a PLA-model / PVA-support project shows both on the + archive card badge (#1881). + + Earlier code filtered by ``filament_is_support``; that hid PVA + (and any other soluble/breakaway support material) from the card + even when the user had explicitly configured it, and made source + 3MFs look single-material until the print completed. slice_info + (parsed separately) is still preferred when present — it lists + only filaments the print actually consumes, this fallback only + runs on unsliced source 3MFs. + """ try: filament_types = data.get("filament_type", []) filament_colors = data.get("filament_colour", []) - filament_is_support = data.get("filament_is_support", []) if not filament_types: return - # Collect all non-support filaments - non_support_types = [] - non_support_colors = [] + unique_types: list[str] = [] + for ftype in filament_types: + if ftype and ftype not in unique_types: + unique_types.append(ftype) - for i, ftype in enumerate(filament_types): - is_support = filament_is_support[i] if i < len(filament_is_support) else "0" - if is_support == "0": - if ftype and ftype not in non_support_types: - non_support_types.append(ftype) - if i < len(filament_colors) and filament_colors[i]: - color = filament_colors[i] - if color not in non_support_colors: - non_support_colors.append(color) + unique_colors: list[str] = [] + for color in filament_colors: + if color and color not in unique_colors: + unique_colors.append(color) - # Fallback to first filament if all are support - if not non_support_types and filament_types: - non_support_types = [filament_types[0]] - if not non_support_colors and filament_colors: - non_support_colors = [filament_colors[0]] - - # Store filament type(s) - if non_support_types: - self.metadata["filament_type"] = ", ".join(non_support_types) - - # Store all colors as comma-separated (for multi-color display) - if non_support_colors: - self.metadata["filament_color"] = ",".join(non_support_colors) + if unique_types: + self.metadata["filament_type"] = ", ".join(unique_types) + if unique_colors: + self.metadata["filament_color"] = ",".join(unique_colors) except Exception: pass # Filament info is optional; fall back to slice_info values diff --git a/backend/app/services/slicer_3mf_convert.py b/backend/app/services/slicer_3mf_convert.py index 8933c9385..3bcb3a6fe 100644 --- a/backend/app/services/slicer_3mf_convert.py +++ b/backend/app/services/slicer_3mf_convert.py @@ -264,11 +264,19 @@ def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | N return items # Local import keeps the bytes->ZipFile boundary in this module and # avoids dragging zipfile into every caller. - from backend.app.utils.threemf_tools import extract_plate_extruder_set_from_3mf + from backend.app.utils.threemf_tools import ( + extract_plate_extruder_set_from_3mf, + extract_support_filament_slots_from_3mf, + ) try: with zipfile.ZipFile(BytesIO(source_3mf_bytes), "r") as zf: + # Geometry-derived slots (per-object metadata + paint_color) + # plus process-derived support-filament slots. Supports aren't + # attached to object geometry so the geometry pass alone + # misses PVA-in-support-slot setups (#1881). used = extract_plate_extruder_set_from_3mf(zf, plate_id) + used |= extract_support_filament_slots_from_3mf(zf) except (zipfile.BadZipFile, OSError) as exc: logger.warning("Plate-filament parse failed (%s); leaving filament list unchanged", exc) return items diff --git a/backend/app/utils/threemf_tools.py b/backend/app/utils/threemf_tools.py index dace2cb74..4482ea11d 100644 --- a/backend/app/utils/threemf_tools.py +++ b/backend/app/utils/threemf_tools.py @@ -859,6 +859,53 @@ def extract_project_filaments_from_3mf(zf: zipfile.ZipFile) -> list[dict]: return out +def extract_support_filament_slots_from_3mf(zf: zipfile.ZipFile) -> set[int]: + """Slots referenced by the process settings for support material. + + Supports aren't attached to object geometry — they're generated by + the slicer's process pass — so :func:`extract_plate_extruder_set_from_3mf`, + which walks per-object extruder metadata + paint_color triangles, + doesn't see them. Callers that need the complete set of slots a + plate print will exercise (e.g. the SliceModal's filament- + substitution logic) must union this in — otherwise a support-only + slot (typical PLA-model + PVA-support setup) looks "unused" and its + user-picked profile gets silently overwritten with slot 1's, + producing a single-material print (#1881). + + Returns the empty set when supports are disabled, ``support_filament`` + / ``support_interface_filament`` are 0 (== "same as model"), the + project has no embedded settings, or the file isn't a valid 3MF. + """ + if "Metadata/project_settings.config" not in zf.namelist(): + return set() + try: + cfg = json.loads(zf.read("Metadata/project_settings.config").decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError, OSError): + return set() + if not isinstance(cfg, dict): + return set() + # BambuStudio serialises bool config options as string "1"/"0" in + # project_settings.config, but forks / older versions occasionally + # write real booleans or ints — accept anything that isn't + # unambiguously falsy. + enable = cfg.get("enable_support") + if enable in (False, 0, "0", "false", "False", "", None): + return set() + out: set[int] = set() + for key in ("support_filament", "support_interface_filament"): + raw = cfg.get(key) + if raw is None: + continue + try: + slot = int(raw) + except (ValueError, TypeError): + continue + # Slot 0 means "same as model" — no dedicated slot to preserve. + if slot > 0: + out.add(slot) + return out + + _PAINT_COLOR_ATTR_RE = re.compile(rb'paint_color="([0-9A-Fa-f]+)"') # Painted-face quadtree leaves include both real filament assignments and diff --git a/backend/tests/unit/services/test_archive_service.py b/backend/tests/unit/services/test_archive_service.py index 324c54259..fb550a627 100644 --- a/backend/tests/unit/services/test_archive_service.py +++ b/backend/tests/unit/services/test_archive_service.py @@ -938,3 +938,76 @@ class TestMultiPlateSliceInfoSum: assert meta["print_time_seconds"] == 300 # Only the second plate's weight contributed. assert meta["filament_used_grams"] == 5.0 + + +class TestThreeMFParserSupportMaterial: + """#1881: `_extract_filament_info` used to filter out support materials + (any slot where `filament_is_support == "1"`). That hid PVA / BVOH from + the archive card of unsliced source 3MFs — a PLA-model + PVA-support + project looked single-material until the print completed. This class + covers the follow-up: support materials must be included in + `filament_type` / `filament_color`. + """ + + @staticmethod + def _make_3mf_with_project_settings(project_settings: dict) -> str: + import json + import os + import tempfile + import zipfile + + fd, path = tempfile.mkstemp(suffix=".3mf") + os.close(fd) + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("3D/3dmodel.model", "") + zf.writestr("Metadata/project_settings.config", json.dumps(project_settings)) + return path + + def test_support_filament_included_on_source_3mf(self): + # Reporter's exact config: PLA model + PVA support. Both must show + # on the archive card badge, in slot order. + from backend.app.services.archive import ThreeMFParser + + path = self._make_3mf_with_project_settings( + { + "filament_type": ["PLA", "PVA"], + "filament_colour": ["#FFFFFF", "#00AA00"], + "filament_is_support": ["0", "1"], + } + ) + meta = ThreeMFParser(path).parse() + assert meta["filament_type"] == "PLA, PVA" + assert meta["filament_color"] == "#FFFFFF,#00AA00" + + def test_single_support_material_still_populated(self): + # Degenerate case: only material configured happens to be marked + # support. Old fallback picked the first entry; new logic keeps + # the same shape. + from backend.app.services.archive import ThreeMFParser + + path = self._make_3mf_with_project_settings( + { + "filament_type": ["PVA"], + "filament_colour": ["#FFFFFF"], + "filament_is_support": ["1"], + } + ) + meta = ThreeMFParser(path).parse() + assert meta["filament_type"] == "PVA" + assert meta["filament_color"] == "#FFFFFF" + + def test_duplicate_material_types_deduped(self): + # Two AMS slots both PLA of different colours: type list dedupes + # but colour list keeps both (multi-colour print). + from backend.app.services.archive import ThreeMFParser + + path = self._make_3mf_with_project_settings( + { + "filament_type": ["PLA", "PLA"], + "filament_colour": ["#FFFFFF", "#000000"], + "filament_is_support": ["0", "0"], + } + ) + meta = ThreeMFParser(path).parse() + assert meta["filament_type"] == "PLA" + assert meta["filament_color"] == "#FFFFFF,#000000" diff --git a/backend/tests/unit/services/test_slicer_3mf_convert.py b/backend/tests/unit/services/test_slicer_3mf_convert.py index 570f700f7..4bfda15e3 100644 --- a/backend/tests/unit/services/test_slicer_3mf_convert.py +++ b/backend/tests/unit/services/test_slicer_3mf_convert.py @@ -326,3 +326,48 @@ class TestSubstituteUnusedPlateFilaments: items = ["a.json", "b.json", "c.json"] result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items) assert result == items + + def test_support_material_slot_preserved(self): + # #1881 regression: object geometry references only slot 1 (PLA), + # but slot 2 (PVA) is configured as the support material in + # project_settings.config. Without the support-slot union, slot 2's + # user-picked PVA profile would be overwritten with slot 1's PLA + # and the print would come out single-material with PLA supports. + model_settings = self._model_settings_xml([(1, [1])]) + project_settings = json.dumps( + { + "enable_support": "1", + "support_filament": "2", + "support_interface_filament": "2", + "filament_type": ["PLA", "PVA"], + } + ).encode() + zip_bytes = _make_3mf( + { + "Metadata/model_settings.config": model_settings, + "Metadata/project_settings.config": project_settings, + } + ) + items = ["pla.json", "pva_support.json"] + result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items) + assert result == ["pla.json", "pva_support.json"] + + def test_support_disabled_still_substitutes_unused(self): + # When supports are off, slot 2 is genuinely unused — the temp-spread + # validator still needs the substitution to succeed. + model_settings = self._model_settings_xml([(1, [1])]) + project_settings = json.dumps( + { + "enable_support": "0", + "support_filament": "2", + } + ).encode() + zip_bytes = _make_3mf( + { + "Metadata/model_settings.config": model_settings, + "Metadata/project_settings.config": project_settings, + } + ) + items = ["pla.json", "abs_never_used.json"] + result = substitute_unused_plate_filaments(zip_bytes, plate_id=1, items=items) + assert result == ["pla.json", "pla.json"] diff --git a/backend/tests/unit/test_slice_process_support_patch.py b/backend/tests/unit/test_slice_process_support_patch.py new file mode 100644 index 000000000..2b3f5fe6c --- /dev/null +++ b/backend/tests/unit/test_slice_process_support_patch.py @@ -0,0 +1,153 @@ +"""Regression tests for the #1881 support-settings patch on slice requests. + +BambuStudio's shipped process presets ("0.20mm Standard @BBL H2D" etc.) +define `enable_support: 0` because supports are a per-print decision, not +a per-quality one. Bambuddy passes the picked process preset via +`--load-settings`, which is authoritative — every field in the loaded +JSON overrides the source 3MF's embedded `project_settings.config`. So +without patching, a user who exported a source 3MF with supports +configured (PLA in slot 1 + PVA in slot 2 for support_interface, +enable_support on) got a single-material output with the PVA slot loaded +but never used. + +The patch reads support-related fields from the source's +project_settings.config and overlays them onto the process preset JSON, +so the source's per-project support intent survives `--load-settings`. +""" + +import io +import json +import zipfile + +from backend.app.api.routes.library import _patch_process_support_settings + + +def _make_3mf(project_settings: dict | None) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("3D/3dmodel.model", "") + if project_settings is not None: + zf.writestr("Metadata/project_settings.config", json.dumps(project_settings)) + return buf.getvalue() + + +class TestPatchProcessSupportSettings: + def test_preserves_source_enable_support_and_interface_slot(self): + # Reporter's exact #1881 config: source has supports on with PVA + # in slot 2 for the interface. Shipped process preset has all four + # fields off. Post-patch, the source wins for the support keys and + # the process preset's own layer_height stays untouched. + source = _make_3mf( + { + "enable_support": "1", + "support_filament": "0", + "support_interface_filament": "2", + "support_type": "normal(manual)", + "filament_type": ["PLA", "PVA"], + } + ) + preset = json.dumps( + { + "name": "0.20mm Standard @BBL H2D", + "enable_support": "0", + "support_filament": "0", + "support_interface_filament": "0", + "support_type": "default", + "layer_height": "0.20", + } + ) + result = json.loads(_patch_process_support_settings(preset, source)) + assert result["enable_support"] == "1" + assert result["support_filament"] == "0" + assert result["support_interface_filament"] == "2" + assert result["support_type"] == "normal(manual)" + # Non-support fields survive. + assert result["layer_height"] == "0.20" + assert result["name"] == "0.20mm Standard @BBL H2D" + + def test_source_supports_off_beats_preset_supports_on(self): + # Symmetric: a source with supports explicitly disabled must win + # over a process preset that happens to have supports on. Rare in + # practice (Bambu's presets ship off) but the semantic is "source + # wins" regardless of direction — a user who exported without + # supports doesn't want a preset accidentally re-enabling them. + source = _make_3mf( + { + "enable_support": "0", + "support_filament": "0", + "support_interface_filament": "0", + } + ) + preset = json.dumps({"enable_support": "1", "support_filament": "2", "support_interface_filament": "2"}) + result = json.loads(_patch_process_support_settings(preset, source)) + assert result["enable_support"] == "0" + assert result["support_filament"] == "0" + assert result["support_interface_filament"] == "0" + + def test_only_patches_keys_present_in_source(self): + # Source with a partial support config (e.g. legacy 3MFs from an + # older BambuStudio) only overrides the keys it defines. Preset's + # values for the other support keys survive. + source = _make_3mf({"enable_support": "1"}) + preset = json.dumps( + { + "enable_support": "0", + "support_filament": "2", + "support_interface_filament": "3", + "support_type": "tree(auto)", + } + ) + result = json.loads(_patch_process_support_settings(preset, source)) + assert result["enable_support"] == "1" + # Preset's values kept for keys the source didn't define. + assert result["support_filament"] == "2" + assert result["support_interface_filament"] == "3" + assert result["support_type"] == "tree(auto)" + + def test_no_project_settings_in_source_returns_preset_unchanged(self): + # STL / STEP / a stripped-down 3MF has no project_settings.config; + # nothing to overlay, preset must pass through untouched. + source = _make_3mf(None) + preset = json.dumps({"enable_support": "0", "layer_height": "0.20"}) + result = _patch_process_support_settings(preset, source) + # Same JSON round-trips. + assert json.loads(result) == {"enable_support": "0", "layer_height": "0.20"} + + def test_malformed_source_returns_preset_unchanged(self): + # A malformed source 3MF (or a random blob) can't yield support + # info; the slice then runs with the preset's own defaults, which + # is the safe fall-back matching pre-fix behaviour. + preset = json.dumps({"enable_support": "0"}) + assert json.loads(_patch_process_support_settings(preset, b"not a zip")) == {"enable_support": "0"} + + def test_malformed_project_settings_json_returns_preset_unchanged(self): + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("Metadata/project_settings.config", "{not json") + source = buf.getvalue() + preset = json.dumps({"enable_support": "0"}) + assert json.loads(_patch_process_support_settings(preset, source)) == {"enable_support": "0"} + + def test_source_project_settings_not_dict_returns_preset_unchanged(self): + # Defensive: spec says it's a dict, but a source that ships a + # top-level list (or anything non-dict) shouldn't crash the slice. + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("Metadata/project_settings.config", json.dumps([])) + source = buf.getvalue() + preset = json.dumps({"enable_support": "0"}) + assert json.loads(_patch_process_support_settings(preset, source)) == {"enable_support": "0"} + + def test_malformed_preset_json_returns_input_unchanged(self): + # Symmetric to test_returns_input_unchanged_when_json_is_invalid + # in the bed-type patch's test suite. The slicer would error on + # the preset anyway; the patch is a straight passthrough so + # failure attributes to the original input. + source = _make_3mf({"enable_support": "1"}) + bogus = "not a json document" + assert _patch_process_support_settings(bogus, source) is bogus + + def test_preset_json_not_a_dict_returns_input_unchanged(self): + source = _make_3mf({"enable_support": "1"}) + not_a_dict = json.dumps(["this", "is", "an", "array"]) + assert _patch_process_support_settings(not_a_dict, source) is not_a_dict diff --git a/backend/tests/unit/test_threemf_tools.py b/backend/tests/unit/test_threemf_tools.py index 43cc210d6..5b1f58673 100644 --- a/backend/tests/unit/test_threemf_tools.py +++ b/backend/tests/unit/test_threemf_tools.py @@ -16,6 +16,7 @@ from backend.app.utils.threemf_tools import ( extract_plate_extruder_set_from_3mf, extract_print_time_from_3mf, extract_project_filaments_from_3mf, + extract_support_filament_slots_from_3mf, get_cumulative_usage_at_layer, mm_to_grams, parse_gcode_layer_filament_usage, @@ -941,3 +942,95 @@ class TestExtractPrintTimeFrom3mf: assert extract_print_time_from_3mf(file_path) is None assert extract_print_time_from_3mf(file_path, plate_id=2) is None + + +# --------------------------------------------------------------------------- +# Tests for extract_support_filament_slots_from_3mf — #1881: a plate that uses +# PVA (or any material) exclusively for supports doesn't reference the support +# slot from object geometry, so without this helper substitute_unused_plate_ +# filaments overwrites the user's support-material profile with slot 1's. +# --------------------------------------------------------------------------- + + +class TestExtractSupportFilamentSlotsFrom3mf: + def test_pla_object_plus_pva_support_returns_support_slot(self): + # The reporter's exact scenario (#1881): slot 1 = PLA (model), + # slot 2 = PVA (support). enable_support on. Without this the + # substitute logic replaces slot 2's PVA profile with PLA and + # the printed supports come out in PLA. + cfg = json.dumps( + { + "enable_support": "1", + "support_filament": "2", + "support_interface_filament": "2", + "filament_type": ["PLA", "PVA"], + } + ) + with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf: + assert extract_support_filament_slots_from_3mf(zf) == {2} + + def test_distinct_support_body_and_interface_slots(self): + cfg = json.dumps( + { + "enable_support": "1", + "support_filament": "2", + "support_interface_filament": "3", + } + ) + with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf: + assert extract_support_filament_slots_from_3mf(zf) == {2, 3} + + def test_supports_disabled_returns_empty(self): + # enable_support off — supports won't be printed even if a slot is + # configured. Don't force it into the "used" set; substitution + # should still homogenise the loaded-filament array. + cfg = json.dumps( + { + "enable_support": "0", + "support_filament": "2", + "support_interface_filament": "2", + } + ) + with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf: + assert extract_support_filament_slots_from_3mf(zf) == set() + + def test_slot_zero_treated_as_same_as_model(self): + # BambuStudio's `0` for support_filament means "same as model" — + # no dedicated slot to preserve. + cfg = json.dumps( + { + "enable_support": "1", + "support_filament": "0", + "support_interface_filament": "0", + } + ) + with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf: + assert extract_support_filament_slots_from_3mf(zf) == set() + + def test_boolean_enable_support_accepted(self): + # Some forks / older versions write a real JSON bool instead of "1"/"0". + cfg = json.dumps({"enable_support": True, "support_filament": "2"}) + with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf: + assert extract_support_filament_slots_from_3mf(zf) == {2} + + def test_integer_slot_value_accepted(self): + cfg = json.dumps({"enable_support": "1", "support_filament": 3}) + with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf: + assert extract_support_filament_slots_from_3mf(zf) == {3} + + def test_missing_project_settings_returns_empty(self): + with _make_3mf_with({"placeholder.txt": "hi"}) as zf: + assert extract_support_filament_slots_from_3mf(zf) == set() + + def test_malformed_json_returns_empty(self): + with _make_3mf_with({"Metadata/project_settings.config": b"{not json"}) as zf: + assert extract_support_filament_slots_from_3mf(zf) == set() + + def test_root_is_list_returns_empty(self): + with _make_3mf_with({"Metadata/project_settings.config": json.dumps([])}) as zf: + assert extract_support_filament_slots_from_3mf(zf) == set() + + def test_non_numeric_slot_value_skipped(self): + cfg = json.dumps({"enable_support": "1", "support_filament": "not-a-number"}) + with _make_3mf_with({"Metadata/project_settings.config": cfg}) as zf: + assert extract_support_filament_slots_from_3mf(zf) == set()