mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 21:51:23 +02:00
fix(slicer): stop a 3MF from switching off supports its process preset turned on (#2820)
--load-settings is authoritative, so since #1881 four support fields travel the other way -- enable_support, the two filament slots, and support_type -- lifted out of the source 3MF and written over the picked process preset. Bambu's shipped presets all set enable_support: 0 because supports are a per-print decision, and without the carry a project exported with PVA in the interface slot sliced single-material. But the carry ran in both directions, and the off direction is the one nobody asked for. Nearly every published model ships with supports off, so slicing one against a custom preset that deliberately enabled them stripped them back out. The reporter's preset sets enable_support 1, support_type normal(auto), support_style snug; the slice came back disabled and tree(auto). Only the style survived -- it is not one of the four carried, and they had re-entered it in the slice dialog. The source can now switch supports on, never off. Nothing is lost: every shipped preset has them off, so a preset that has them on is a deliberate choice by whoever wrote it, and a file that wants supports still gets them with its slot assignments. A file that never declares enable_support is treated as off -- no intent to act on. The truthiness rule ("1", true, 1, and the forks that write neither) now lives in one place as supports_enabled_in_config(), shared with extract_support_filament_slots_from_3mf, which had it inline. Also log the carry with the fields it took. The slice dialog shows the picked preset's values, so a carried field silently disagrees with what was on screen and this step logged nothing at all -- the report chased an unrelated sanitiser line about the source file's own settings, which was the only thing in the log that mentioned any of these keys.
This commit is contained in:
@@ -28,6 +28,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests.
|
||||
|
||||
### Fixed
|
||||
- **A process preset that turns supports on had them switched back off by the file being sliced (#2820, reported by @zevulos)** — The server slicer is handed the picked process preset as the authoritative settings for the slice, so anything the preset does not name comes from the file's own embedded settings. Since #1881 four support fields travel the other way as well — supports on/off, the support and interface filament slots, and tree versus normal — because Bambu's shipped process presets all set supports off (supports are a decision per print, not per quality level), and without carrying them a project exported with supports configured came out of the slicer as a single-material print with a PVA slot loaded and never used. That carry ran in both directions, which is wrong in the direction nobody asked for: a file that ships with supports off, which is very nearly every published model, stripped supports back out of a preset that deliberately turned them on. The reporter's own preset turns supports on with normal(auto) and snug, and the slice came back with supports disabled and set to tree(auto) — the only setting of the three that survived was the style, and only because it is not one of the four fields carried and they had re-entered it in the slice dialog. The file can now switch supports on but never off. Nothing is lost by that: since every shipped preset has supports off, a preset that has them on is a deliberate choice by whoever wrote it, and a file that wants supports still gets them along with its slot assignments. A file that never states whether it wants supports at all is treated the same as one that says no. The carry is also written to the log now, naming the fields it took, because the slice dialog shows the picked preset's values and a carried field quietly disagrees with what was on screen — with nothing in the log to say so, this bug reads as the preset being ignored, and the log line the report understandably keyed on was an unrelated one about the source file's own settings.
|
||||
- **Printing several copies of a file uploaded straight to a printer left every copy after the first unable to run (#2819, reported by @ooniiik)** — Dropping a file onto a printer card uploads it to the library, prints it, and deletes it again: the file is a courier, not something you asked to keep. Ask for more than one copy, though, and each copy is its own queue entry pointing at that same temporary file — so the first one to dispatch deleted the file the others were still waiting for. What happened next depended on which database Bambuddy is running on, and neither answer was right. On SQLite the remaining copies were left pointing at a file that no longer existed: starting one failed with "Library file not found", and a copy set to start manually simply sat in the queue forever, giving no reason. On PostgreSQL, which enforces the same rule the schema has always described, the remaining copies were deleted outright — the jobs vanished from the queue with no error and no history, which is harder to notice and harder still to explain. On a farm running batches across twenty printers, both leave work that looks queued and is not. The copy that dispatches first now hands the rest the archive it just created, before the file is deleted: they print the same bytes from Print History, and the flag that consumes a library file is cleared on them so they cannot delete anything themselves. A copy already printing from its own archive keeps it, and a finished one keeps its outcome — but both stop referring to the deleted file, because on PostgreSQL that reference is the only thing tying them to the deletion, and the finished rows are what a batch order counts its progress from. Cross-model jobs (#671) that had this file as one of several alternatives keep their other alternatives and simply lose this one; one left with no alternative at all is handed the archive like any other copy. The same fix covers batch orders, which inherit the file from the item they were cloned from, and copies held back by a printer's previous-print gate, which come back the moment that gate is cleared. Reprints from Print History and any file kept in the library are untouched — nothing is deleted there in the first place. Covered by backend tests, and verified row for row against PostgreSQL as well as SQLite.
|
||||
- **Deleting a file from the File Manager broke, or silently removed, the jobs queued against it (#2819, reported by @ooniiik)** — The same fault as above by a different route, and the one that accounted for most of the reporter's "Library file not found" history. Nothing connected a library file to the queue: deleting a file left its queued jobs pointing at something that was no longer there, and they failed at the printer days later with a message that named neither the file nor the deletion. On PostgreSQL it was worse and quieter — the jobs were removed along with the file, including finished ones, which is what a batch order counts its progress from, and including a job that was printing at that moment. Deleting a whole folder did the same to every file underneath it, and did not even clean up the cross-model alternatives that pointed at them. Now, whenever a file is removed for good — a file outside Bambuddy's own storage, a folder deleted with its contents, emptying the trash, or the retention sweeper finally clearing it — the jobs waiting on it are cancelled at that moment and say which file went, and every other row that referred to it is detached rather than destroyed, so print history and batch counts survive. A job that is already printing is left running: what is being deleted is the copy in the library, not the copy the printer is working from. Moving a file to the trash still changes nothing about the queue, because the trash is reversible and restoring a file should not require re-queueing everything that was waiting on it — but a job that does run while its file is in the trash now says so, and says that restoring the file and queueing again will work, instead of "Library file not found".
|
||||
- **The Spool Inventory page scrolled sideways on a phone, taking the whole page with it (#2813, reported by @chitrangdesign)** — The header of **Filament → Spool Inventory** put its title and its five buttons — Import CSV, Export CSV, Locations, Print labels and Add Spool — in a single row that could neither wrap nor shrink. Side by side those buttons come to roughly 600 pixels, so on a 390-pixel screen the header ran past the edge of the viewport, and because the whole page scrolls as one region everything below it went with the header: the statistics bar, the filters and every spool card sat offset, and vertical scrolling fought a horizontal pan the entire time. Longer languages made it worse, since German renders the same two buttons as "CSV importieren" and "Etiketten drucken…". The header now stacks on narrow screens and the buttons wrap onto as many lines as they need, which is the same arrangement the Statistics, Settings and Archives headers have always used, and the one the filter bar further down this very page already used — this header simply never got it. At 640 pixels and wider nothing changes at all. The System Information header had the same construction with a single Refresh button, where it only just overflowed, and is fixed in the same way.
|
||||
|
||||
@@ -81,6 +81,7 @@ from backend.app.utils.threemf_tools import (
|
||||
extract_nozzle_mapping_from_3mf,
|
||||
extract_project_filaments_from_3mf,
|
||||
select_plate_gcode_name,
|
||||
supports_enabled_in_config,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -3622,6 +3623,16 @@ _SOURCE_PROCESS_SUPPORT_KEYS_TO_PRESERVE = (
|
||||
def _patch_process_support_settings(process_json: str, source_3mf_bytes: bytes) -> str:
|
||||
"""Overlay the source 3MF's support configuration onto the process JSON.
|
||||
|
||||
The carry is deliberately one-way: a source can switch supports *on*,
|
||||
never off (#2820). The original #1881 rule was "source wins in both
|
||||
directions", which quietly stripped supports from every custom process
|
||||
preset that enabled them — a MakerWorld download nearly always ships
|
||||
`enable_support: 0`, so the reporter's own preset (supports on, normal
|
||||
(auto)) came back out of the slicer disabled and set to tree(auto).
|
||||
Nothing is lost by not carrying the off direction: a process preset
|
||||
with supports *on* is by definition a deliberate user preset, since
|
||||
Bambu's shipped ones all ship them off.
|
||||
|
||||
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
|
||||
@@ -3639,6 +3650,8 @@ def _patch_process_support_settings(process_json: str, source_3mf_bytes: bytes)
|
||||
return process_json
|
||||
if not isinstance(src_cfg, dict):
|
||||
return process_json
|
||||
if not supports_enabled_in_config(src_cfg):
|
||||
return process_json
|
||||
|
||||
try:
|
||||
process_cfg = json.loads(process_json)
|
||||
@@ -3647,9 +3660,15 @@ def _patch_process_support_settings(process_json: str, source_3mf_bytes: bytes)
|
||||
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]
|
||||
carried = {key: src_cfg[key] for key in _SOURCE_PROCESS_SUPPORT_KEYS_TO_PRESERVE if key in src_cfg}
|
||||
process_cfg.update(carried)
|
||||
# Logged because this is the one layer of the process JSON the user
|
||||
# can't see coming: the slice modal shows the picked preset's values,
|
||||
# so a carried key silently disagrees with what was on screen.
|
||||
logger.info(
|
||||
"Carried support settings from the source 3MF onto the process preset: %s",
|
||||
dict(sorted(carried.items())),
|
||||
)
|
||||
|
||||
return json.dumps(process_cfg)
|
||||
|
||||
|
||||
@@ -1099,6 +1099,24 @@ def expand_to_project_slots(zf: zipfile.ZipFile, used: list[dict]) -> list[dict]
|
||||
return out
|
||||
|
||||
|
||||
# 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. A missing
|
||||
# key counts as off: a 3MF that never declares `enable_support` gives us no
|
||||
# support intent to act on.
|
||||
_SUPPORTS_DISABLED_VALUES = (False, 0, "0", "false", "False", "", None)
|
||||
|
||||
|
||||
def supports_enabled_in_config(cfg: dict[str, object]) -> bool:
|
||||
"""Whether a 3MF's ``project_settings.config`` has supports switched on.
|
||||
|
||||
Shared by the callers that read support intent out of a source file so
|
||||
they agree on what "on" means: the slot extractor below and the slice
|
||||
route's process-preset support carry-over (#1881 / #2820).
|
||||
"""
|
||||
return cfg.get("enable_support") not in _SUPPORTS_DISABLED_VALUES
|
||||
|
||||
|
||||
def extract_support_filament_slots_from_3mf(zf: zipfile.ZipFile) -> set[int]:
|
||||
"""Slots referenced by the process settings for support material.
|
||||
|
||||
@@ -1124,12 +1142,7 @@ def extract_support_filament_slots_from_3mf(zf: zipfile.ZipFile) -> set[int]:
|
||||
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):
|
||||
if not supports_enabled_in_config(cfg):
|
||||
return set()
|
||||
out: set[int] = set()
|
||||
for key in ("support_filament", "support_interface_filament"):
|
||||
|
||||
@@ -13,10 +13,16 @@ 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`.
|
||||
|
||||
The carry is one-way (#2820): a source can switch supports on, never off.
|
||||
The original rule was symmetric, which meant any 3MF that shipped with
|
||||
supports disabled -- i.e. nearly every MakerWorld download -- stripped
|
||||
them back out of a custom process preset that deliberately enabled them.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import zipfile
|
||||
|
||||
from backend.app.api.routes.library import _patch_process_support_settings
|
||||
@@ -65,24 +71,76 @@ class TestPatchProcessSupportSettings:
|
||||
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.
|
||||
def test_preset_supports_on_survives_a_source_with_supports_off(self):
|
||||
# #2820: the reporter's own process preset turns supports on with
|
||||
# normal(auto); the MakerWorld source they sliced ships them off
|
||||
# with tree(auto), like nearly every published 3MF. Carrying the
|
||||
# off direction handed them a supportless tree(auto) slice, so the
|
||||
# source is now only allowed to switch supports *on*.
|
||||
source = _make_3mf(
|
||||
{
|
||||
"enable_support": "0",
|
||||
"support_filament": "0",
|
||||
"support_interface_filament": "0",
|
||||
"support_type": "tree(auto)",
|
||||
}
|
||||
)
|
||||
preset = json.dumps(
|
||||
{
|
||||
"name": "Pokeball Fast - Buddy",
|
||||
"enable_support": "1",
|
||||
"support_filament": "2",
|
||||
"support_interface_filament": "2",
|
||||
"support_type": "normal(auto)",
|
||||
"support_style": "snug",
|
||||
}
|
||||
)
|
||||
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"
|
||||
assert result["enable_support"] == "1"
|
||||
assert result["support_filament"] == "2"
|
||||
assert result["support_interface_filament"] == "2"
|
||||
assert result["support_type"] == "normal(auto)"
|
||||
assert result["support_style"] == "snug"
|
||||
|
||||
def test_source_without_enable_support_carries_nothing(self):
|
||||
# A source that never declares enable_support gives us no support
|
||||
# intent to act on, so its slot assignments stay out of the preset
|
||||
# — same "supports off" branch, reached via the missing key.
|
||||
source = _make_3mf({"support_filament": "3", "support_interface_filament": "3"})
|
||||
preset = json.dumps({"support_filament": "0", "support_interface_filament": "0"})
|
||||
result = json.loads(_patch_process_support_settings(preset, source))
|
||||
assert result == {"support_filament": "0", "support_interface_filament": "0"}
|
||||
|
||||
def test_non_string_enable_support_still_counts_as_on(self):
|
||||
# Forks and older BambuStudio builds write real booleans / ints
|
||||
# instead of "1" — those must still carry (shared truthiness rule
|
||||
# with extract_support_filament_slots_from_3mf).
|
||||
for enabled in (True, 1, "1", "true"):
|
||||
source = _make_3mf({"enable_support": enabled, "support_interface_filament": "2"})
|
||||
preset = json.dumps({"enable_support": "0", "support_interface_filament": "0"})
|
||||
result = json.loads(_patch_process_support_settings(preset, source))
|
||||
assert result["enable_support"] == enabled, f"failed for {enabled!r}"
|
||||
assert result["support_interface_filament"] == "2"
|
||||
|
||||
def test_carry_is_logged_with_the_keys_it_took(self, caplog):
|
||||
# The slice modal shows the picked preset's values, so a carried
|
||||
# key silently disagrees with what the user saw. #2820's reporter
|
||||
# spent the bug report chasing an unrelated sanitiser line because
|
||||
# this step logged nothing at all.
|
||||
source = _make_3mf({"enable_support": "1", "support_interface_filament": "2"})
|
||||
preset = json.dumps({"enable_support": "0", "support_interface_filament": "0"})
|
||||
with caplog.at_level(logging.INFO, logger="backend.app.api.routes.library"):
|
||||
_patch_process_support_settings(preset, source)
|
||||
assert "Carried support settings" in caplog.text
|
||||
assert "enable_support" in caplog.text
|
||||
assert "support_interface_filament" in caplog.text
|
||||
|
||||
def test_no_log_when_the_source_has_supports_off(self, caplog):
|
||||
source = _make_3mf({"enable_support": "0", "support_type": "tree(auto)"})
|
||||
preset = json.dumps({"enable_support": "1"})
|
||||
with caplog.at_level(logging.INFO, logger="backend.app.api.routes.library"):
|
||||
_patch_process_support_settings(preset, source)
|
||||
assert "Carried support settings" not in caplog.text
|
||||
|
||||
def test_only_patches_keys_present_in_source(self):
|
||||
# Source with a partial support config (e.g. legacy 3MFs from an
|
||||
|
||||
Reference in New Issue
Block a user