mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user