feat(slice): cross-printer re-slicing across nozzle classes + multi-plate slice-all

Re-slicing a 3MF authored for a single-nozzle printer (X1C, P1S, A1, P2S)
  onto a dual-nozzle printer (H2D / H2D Pro) — or vice versa — previously
  failed with "G-code in unprintable area of multi-extruder printers" (the
  source's bed-coordinate layout lands in the H2D's per-nozzle dead zone)
  or, on multi-color projects, a hard SIGSEGV inside the slicer's ZFiller
  polygon-clipping. Earlier shipped a fail-fast 400 guard; this drop lifts
  it and actually does the conversion by forwarding the sidecar's existing
  --arrange flag when the source and target nozzle classes differ. BS
  itself reconciles the embedded project_settings.config against the new
  printer that way, the same way the GUI's "Switch Printer" operation
  does. The guard becomes a kept-for-compat no-op.

  Slice-all-plates added to the SliceModal: a checkbox for multi-plate
  sources sends plate=0 to the backend, which forwards --slice 0 to the
  BS CLI. Same-class slice-all produces one multi-plate output 3MF in a
  single sidecar call. Cross-class slice-all loops per plate (BS's
  --arrange is project-wide and would otherwise consolidate every plate's
  objects onto one bed) and merges the per-plate outputs into one
  multi-plate 3MF locally via the new merge_plate_3mfs helper. The toast
  shows "Plate 2 of 5 — Generating G-code (47%)" through the loop.

  Three side fixes surfaced during testing:
  - substitute_unused_plate_filaments overwrites unused-slot filaments
    with the slot-1 selection before slicing so BS's loaded-filament
    temperature validator doesn't reject a PLA print whose unused slot 2
    defaulted to ABS in the dropdown
  - re-sliced archive thumbnail now prefers the source's per-plate
    render (Metadata/plate_N.png) over the project-wide MakerWorld cover
    art, because BS CLI with --arrange skips writing a fresh per-plate
    preview
  - re-sliced archive bed_type now lifts from the sliced output's
    curr_bed_type onto the PrintArchive column the card actually reads

  Schema: SliceRequest.plate range relaxed from ge=1 to ge=0 to admit
  the "all plates" sentinel; SlicerApiService.slice_with_profiles /
  slice_with_bundle take an `arrange` parameter.

  Tests: 26 in test_slicer_3mf_convert (count / merge / substitute /
  extract), 3 in test_slicer_api (arrange wire format), 9 in
  test_library_slice_api (guard no-op, bed_type lift, thumbnail
  fallback, new cross-class slice-all loop integration test), 2 in
  test_archive_service (Auxiliaries fallback), 4 in SliceModal.test
  (plate=0 toggle), 2 in SliceJobTrackerContext.test (multi-plate toast
  prefix). 659 backend + 42 frontend green; backend ruff clean,
  frontend build clean, i18n parity green at 4984 keys × 9 locales.
This commit is contained in:
maziggy
2026-05-23 12:16:34 +02:00
parent ebba1385d3
commit 4686d108ef
27 changed files with 2024 additions and 307 deletions
+245 -39
View File
@@ -364,6 +364,23 @@ def _clean_3mf_metadata(obj):
return obj
def _read_3mf_entry(zip_path: Path, entry: str) -> bytes | None:
"""Return the raw bytes of an entry inside a 3MF (ZIP), or ``None`` when
the file isn't a parseable zip / doesn't contain that entry / any IO
error. Used to lift the source archive's per-plate render onto a
re-sliced archive (#1493 follow-up) — the slicer CLI often doesn't
emit a fresh ``Metadata/plate_N.png`` and the project-wide cover-art
fallback in :class:`ThreeMFParser` looks unrelated to the actual slice.
"""
try:
with zipfile.ZipFile(zip_path, "r") as zf:
if entry not in zf.namelist():
return None
return zf.read(entry)
except (zipfile.BadZipFile, OSError, KeyError):
return None
def _without_print_name(metadata: dict | None) -> dict | None:
"""Drop the embedded 3MF Title (``print_name``) from library-file metadata.
@@ -3050,6 +3067,39 @@ async def _run_slicer_with_fallback(
used_embedded_settings = False
service = SlicerApiService(api_url)
# #1493: cross-nozzle-class re-slice (single <-> dual). Without
# intervention the slicer rejects with either "G-code in unprintable
# area of multi-extruder printers" (the source's X1C-coordinate layout
# lands in the H2D's per-nozzle dead zone) or — worse — segfaults
# inside ZFiller's polygon clipping when the geometry pipeline trips
# on the cross-class transition. Forwarding the sidecar's --arrange
# flag for these cases lets BambuStudio reposition objects for the
# target bed and reconcile the embedded project_settings.config
# against the new printer, the same way the GUI's "Switch Printer"
# operation does. --arrange WILL reposition objects, so we only
# enable it on a true class crossing — same-printer slices keep the
# user's deliberate layout. The bed-type and arrange flags are
# orthogonal so this decision doesn't interact with the #1337 build-
# plate override.
cross_class_arrange = False
if is_3mf:
from backend.app.services.slicer_3mf_convert import (
extract_source_printer_model,
)
from backend.app.utils.printer_models import is_dual_nozzle_model
source_model = extract_source_printer_model(primary_bytes)
target_model = await _resolve_target_printer_model(db, user, request)
if source_model and target_model and is_dual_nozzle_model(source_model) != is_dual_nozzle_model(target_model):
logger.info(
"Cross-nozzle-class re-slice (%s -> %s, %s): enabling --arrange so BS reconciles "
"the embedded project layout against the target printer",
source_model,
target_model,
"bundle" if use_bundle else "presets",
)
cross_class_arrange = True
# When this slice is dispatcher-tracked, generate a request_id so
# the sidecar publishes progress under it, and wire a callback that
# forwards each frame onto SliceDispatchService.set_progress for the
@@ -3067,9 +3117,142 @@ async def _run_slicer_with_fallback(
_dispatch.set_progress(job_id, snapshot)
progress_callback = _on_progress
# SliceModal lets the user pick a filament profile per slot, but each
# plate uses only a subset of the slots. The unused-slot dropdowns get
# whatever default the modal serves up — and a heterogeneous default
# (e.g. ABS in slot 2 next to a PLA in the used slot 1) makes
# BambuStudio reject the slice with "the temperature difference of
# the filaments used is too large" (exit 194) even though the G-code
# never touches the unused slot. Replace unused-slot entries with the
# slot-1 selection before the real slice so the loaded-filament set
# is materially homogeneous.
bundle_filament_names: list[str] | None = None
if is_3mf and request.plate is not None:
from backend.app.services.slicer_3mf_convert import substitute_unused_plate_filaments
if use_bundle:
assert request.bundle is not None
bundle_filament_names = substitute_unused_plate_filaments(
primary_bytes, request.plate, list(request.bundle.filament_names)
)
else:
filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate, filament_jsons)
# Cross-class slice-all loop (#1493): when the user asks for
# ``plate=0`` (all plates) AND the source's nozzle class differs from
# the target's, ``--slice 0 --arrange 1`` consolidates every plate's
# objects onto a single target bed (BS's ``--arrange`` is project-
# wide) — either packing them all together or rejecting with "Some
# objects are located over the boundary of the heated bed" when
# nothing fits. Slice each plate independently with ``--arrange 1``
# and merge the per-plate outputs into one multi-plate 3MF instead.
# Same-class slice-all goes through the regular path below — the
# sidecar's native ``--slice 0`` produces the right shape directly.
use_cross_class_slice_all = cross_class_arrange and request.plate == 0 and request.export_3mf
try:
try:
if use_bundle:
if use_cross_class_slice_all:
from backend.app.services.slicer_3mf_convert import (
count_plates_in_3mf,
merge_plate_3mfs,
)
plate_count = count_plates_in_3mf(primary_bytes)
if plate_count == 0:
raise HTTPException(
status_code=400,
detail=(
"Couldn't read plate count from the source 3MF for cross-class "
"slice-all. The source may be malformed or missing "
"Metadata/model_settings.config."
),
)
logger.info(
"Cross-class slice-all: looping over %d plates with --arrange per plate, then merging",
plate_count,
)
from backend.app.services.slicer_api import SliceResult
per_plate_results: list[tuple[int, SliceResult]] = []
# Forward the same progress request_id + callback to each
# per-plate sub-call so the toast keeps showing the
# sidecar's stage messages ("Generating G-code 45%…").
# The sub-calls run sequentially, so the poller for plate
# N is cancelled before plate N+1's poller starts — no
# cross-talk between plate streams. Wrap the callback to
# surface "(plate N/M)" alongside the slicer's stage
# message so the user sees progress through the whole
# multi-plate loop, not just one plate at a time.
def _wrap_progress_for_plate(plate_num: int, total: int):
if progress_callback is None:
return None
def _cb(snapshot: dict) -> None:
snapshot = dict(snapshot)
snapshot["multi_plate_index"] = plate_num
snapshot["multi_plate_count"] = total
progress_callback(snapshot)
return _cb
for plate_num in range(1, plate_count + 1):
plate_cb = _wrap_progress_for_plate(plate_num, plate_count)
if use_bundle:
assert request.bundle is not None
per_plate = await service.slice_with_bundle(
model_bytes=primary_bytes,
model_filename=model_filename,
bundle_id=request.bundle.bundle_id,
printer_name=request.bundle.printer_name,
process_name=request.bundle.process_name,
filament_names=(
bundle_filament_names
if bundle_filament_names is not None
else request.bundle.filament_names
),
plate=plate_num,
export_3mf=True,
arrange=True,
bed_type=request.bed_type,
request_id=progress_request_id,
on_progress=plate_cb,
)
else:
per_plate = await service.slice_with_profiles(
model_bytes=primary_bytes,
model_filename=model_filename,
printer_profile_json=presets["printer"],
process_profile_json=presets["process"],
filament_profile_jsons=filament_jsons,
plate=plate_num,
export_3mf=True,
arrange=True,
request_id=progress_request_id,
on_progress=plate_cb,
)
per_plate_results.append((plate_num, per_plate))
# Merge the N single-plate 3MFs into one multi-plate 3MF.
# ``primary_bytes`` is the source 3MF: it carries the
# original per-plate previews the slicer's --arrange
# pass doesn't regenerate, so the merger can fall back
# to those for each plate's cover image.
merged_bytes = merge_plate_3mfs(
[(n, r.content) for n, r in per_plate_results],
source_3mf_bytes=primary_bytes,
)
# Synthetic SliceResult: totals are the sum of each
# plate's so the archive card shows the project's print
# time and filament use, not just plate 1's.
result = SliceResult(
content=merged_bytes,
print_time_seconds=sum(r.print_time_seconds for _, r in per_plate_results),
filament_used_g=sum(r.filament_used_g for _, r in per_plate_results),
filament_used_mm=sum(r.filament_used_mm for _, r in per_plate_results),
)
elif use_bundle:
# Bundle dispatch: sidecar materialises the JSON triplet
# from the stored .bbscfg by name. ``request.bundle`` is
# guaranteed non-None here by the use_bundle branch above.
@@ -3080,9 +3263,12 @@ async def _run_slicer_with_fallback(
bundle_id=request.bundle.bundle_id,
printer_name=request.bundle.printer_name,
process_name=request.bundle.process_name,
filament_names=request.bundle.filament_names,
filament_names=bundle_filament_names
if bundle_filament_names is not None
else request.bundle.filament_names,
plate=request.plate,
export_3mf=request.export_3mf,
arrange=cross_class_arrange,
bed_type=request.bed_type,
request_id=progress_request_id,
on_progress=progress_callback,
@@ -3096,6 +3282,7 @@ async def _run_slicer_with_fallback(
filament_profile_jsons=filament_jsons,
plate=request.plate,
export_3mf=request.export_3mf,
arrange=cross_class_arrange,
request_id=progress_request_id,
on_progress=progress_callback,
)
@@ -3194,35 +3381,25 @@ async def _resolve_target_printer_model(db: AsyncSession, user: User | None, req
async def guard_nozzle_class_reslice(
db: AsyncSession, user: User | None, request: SliceRequest, source_model: str | None
) -> None:
"""Block a re-slice that crosses the single-nozzle <-> dual-nozzle boundary.
"""No-op guard, retained for call-site compatibility.
Re-slicing a model laid out for a single-nozzle printer onto a dual-nozzle
printer (H2D / H2D Pro), or vice versa, produces a 3MF whose embedded
single-nozzle filament/extruder layout BambuStudio's multi-extruder
validator rejects — and a crude automatic conversion segfaults the CLI.
Fail fast with a clear message instead of a cryptic slicer error.
Cross-nozzle-class re-slicing is handled by ``_run_slicer_with_fallback``'s
two-pass conversion (#1493): a 1mm cube is sliced with the target triplet
(via either ``slice_with_profiles`` or ``slice_with_bundle``, whichever
dispatch mode the caller is using) to produce a fresh target-shaped
``Metadata/project_settings.config``, which is then spliced into the
source 3MF before the real slice. So this guard never needs to block
anymore — both preset and bundle paths are covered.
No-op when the source isn't a sliced file, the target can't be resolved,
or both printers are the same nozzle class.
The function and its call sites in ``archives.py`` / the library re-slice
route are kept so external pinned-version forks and downstream patches
don't break, but it does nothing on a successful slice path. If the
two-pass conversion fails inside the slicer, the existing
``SlicerApiServerError`` / ``_slicer_rejection_message`` plumbing
surfaces the CLI's actual error to the user — which is more informative
than the old "isn't supported yet" 400 the guard used to raise.
"""
from backend.app.utils.printer_models import is_dual_nozzle_model
if not source_model:
return
target_model = await _resolve_target_printer_model(db, user, request)
if not target_model:
return
if is_dual_nozzle_model(source_model) == is_dual_nozzle_model(target_model):
return
raise HTTPException(
status_code=400,
detail=(
f"Can't re-slice this file for {target_model}: it was sliced for {source_model}, "
f"and re-slicing between a single-nozzle and a dual-nozzle printer (H2D / H2D Pro) "
f"isn't supported yet — the embedded filament layout can't be converted "
f"automatically. Slice the original model for {target_model} instead."
),
)
return None
async def slice_and_persist(
@@ -3381,21 +3558,42 @@ async def slice_and_persist_as_archive(
out_path = archive_dir / out_filename
out_path.write_bytes(result.content)
# Extract a thumbnail from the produced 3MF so the new archive card has
# a preview. The 3MF parser pulls Metadata/plate_*.png; failures here
# shouldn't fail the whole slice — the archive row is still useful
# without a thumbnail.
# Extract a thumbnail for the new archive card. Priority order:
# 1. Source archive's ``Metadata/plate_{N}.png`` — the GUI-rendered
# preview of the same plate the user is re-slicing. Closer to
# "what's actually printing" than any other available image
# (with --arrange the layout may differ slightly, but objects
# and colours match).
# 2. ``ThreeMFParser`` fallback chain on the sliced output: the
# slicer's own per-plate render if it wrote one, then the
# project-wide thumbnail under ``Auxiliaries/.thumbnails/``.
# BambuStudio CLI frequently doesn't emit a fresh per-plate render
# (slice writes the new gcode but leaves the preview slot empty),
# so without (1) the card falls all the way through to the
# MakerWorld-style cover art — visually unrelated to what the user
# picked, see #1493 follow-up. Failures don't fail the slice — the
# archive row is still useful without a thumbnail.
plate_num = request.plate or 1
thumbnail_path: str | None = None
parsed_metadata: dict = {}
src_3mf_path = app_settings.base_dir / source_archive.file_path
source_plate_bytes = _read_3mf_entry(src_3mf_path, f"Metadata/plate_{plate_num}.png")
if source_plate_bytes:
thumb_dest = archive_dir / "thumbnail.png"
thumb_dest.write_bytes(source_plate_bytes)
thumbnail_path = str(thumb_dest.relative_to(app_settings.base_dir))
try:
parser = ThreeMFParser(str(out_path))
parser = ThreeMFParser(str(out_path), plate_number=plate_num)
parsed = parser.parse()
thumb_data = parsed.get("_thumbnail_data")
thumb_ext = parsed.get("_thumbnail_ext", ".png")
if thumb_data:
thumb_dest = archive_dir / f"thumbnail{thumb_ext}"
thumb_dest.write_bytes(thumb_data)
thumbnail_path = str(thumb_dest.relative_to(app_settings.base_dir))
if thumbnail_path is None:
thumb_data = parsed.get("_thumbnail_data")
thumb_ext = parsed.get("_thumbnail_ext", ".png")
if thumb_data:
thumb_dest = archive_dir / f"thumbnail{thumb_ext}"
thumb_dest.write_bytes(thumb_data)
thumbnail_path = str(thumb_dest.relative_to(app_settings.base_dir))
parsed_metadata = {k: v for k, v in parsed.items() if not k.startswith("_")}
except Exception as exc:
logger.warning("Failed to parse sliced 3MF metadata for %s: %s", out_filename, exc)
@@ -3449,6 +3647,14 @@ async def slice_and_persist_as_archive(
# (Copying source_archive.sliced_for_model kept a cross-printer
# re-slice, e.g. X1C→H2D, showing the old "X1C sliced" model.)
sliced_for_model=parsed_metadata.get("sliced_for_model") or source_archive.sliced_for_model,
# Build plate type that the sliced output was produced for (#1493
# follow-up): the frontend's ArchiveCard reads ``archive.bed_type``
# off the top-level column, not extra_data, so without this lift the
# re-sliced card had no plate badge. ThreeMFParser pulls it from the
# sliced 3MF's ``slice_info.config`` ``curr_bed_type``; if that's
# absent (older sidecar / older slice profile) the source archive's
# bed_type is the right default.
bed_type=parsed_metadata.get("bed_type") or source_archive.bed_type,
makerworld_url=source_archive.makerworld_url,
designer=source_archive.designer,
# Sliced-but-not-printed: keep status default ("completed") so it