mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
The print uploaded, the printer took the command, and stopped at once with HMS 0500-4047 -- "the available hotend quantity or model does not match the sliced file". nozzle_mapping told the printer one of the plate's filaments went to no hotend while ams_mapping named the tray it comes from, and the firmware will not start a job on that contradiction. Each filament in a 3MF names the group it belongs to, and on every other dual-nozzle Bambu the group number is also the extruder index, so it was read as one. On a rack machine it is not: the rack carriage holds six hotends to the fixed carriage's one, so the slicer writes a group per nozzle rather than per carriage. The failing plate carried groups 0, 1 and 2 against a two-entry physical_extruder_map, and the filament in group 2 was dropped -- indistinguishable downstream from a slot the plate does not print, which is what reached the wire as -1. extract_nozzle_mapping_from_3mf now resolves the group through the table the file states for itself, the <nozzle id extruder_id> elements in slice_info.config. Files carrying no such table keep the direct index, so H2D slices are unaffected. A filament that still cannot be placed drops the whole mapping with a logged reason instead of half an answer: the firmware then picks its own nozzle, which is the pre-existing behaviour and far better than an answer that contradicts itself. Two related faults fixed in the same pass. The mapping was read across every plate in the file, so on a multi-plate project a slot took its extruder from whichever plate came last; it is now scoped to the plate being dispatched, in extract_filament_requirements as well. And the array is now one entry per filament slot, matching BambuStudio's own dispatch of [1, 16, 16] for a three-filament plate, rather than padded to a fixed 32. Verified against the file that failed: slot extruders [-1, 1, 0] became [0, 1, 0], and the wire [-1, 16, 1, -1 x29] became [1, 16, 1].
187 lines
7.7 KiB
Python
187 lines
7.7 KiB
Python
"""Parse per-slot filament requirements out of a 3MF file.
|
|
|
|
The scheduler used to own this logic (`PrintScheduler._get_filament_requirements`)
|
|
because it ran during dispatch decisions. Extracted here so the VP queue-mode
|
|
write path can use the same parser to populate `filament_overrides` /
|
|
`required_filament_types` at upload time (#1188 — Bambuddy was creating queue
|
|
items with no filament fields, which made the scheduler fall through to
|
|
model-only matching and dispatch onto whatever printer happened to be free
|
|
regardless of loaded colour).
|
|
|
|
The shape returned here matches the `filament_overrides` JSON shape the
|
|
scheduler validates against, minus the `force_color_match` flag — callers
|
|
add that themselves based on their own setting.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import xml.etree.ElementTree as ET
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def extract_filament_requirements(file_path: Path, plate_id: int | None = None) -> list[dict]:
|
|
"""Parse `[{slot_id, type, color, tray_info_idx, used_grams, nozzle_id?}]` from a 3MF.
|
|
|
|
Args:
|
|
file_path: Path to the 3MF.
|
|
plate_id: When set, only return filaments used on that plate. When
|
|
None, return every filament with `used_g > 0` across the file.
|
|
|
|
Returns:
|
|
Sorted list (by `slot_id`) of filament dicts. Empty list when the
|
|
3MF is unreadable, missing `Metadata/slice_info.config`, or has no
|
|
filaments matching the plate filter — callers treat that as "no
|
|
requirements" rather than an error so a malformed 3MF doesn't break
|
|
the upload path.
|
|
"""
|
|
if not file_path.exists():
|
|
return []
|
|
|
|
filaments: list[dict] = []
|
|
try:
|
|
with zipfile.ZipFile(file_path, "r") as zf:
|
|
if "Metadata/slice_info.config" not in zf.namelist():
|
|
return []
|
|
|
|
content = zf.read("Metadata/slice_info.config").decode()
|
|
root = ET.fromstring(content) # noqa: S314 # nosec B314
|
|
|
|
if plate_id is not None:
|
|
for plate_elem in root.findall("./plate"):
|
|
plate_index = None
|
|
for meta in plate_elem.findall("metadata"):
|
|
if meta.get("key") == "index":
|
|
try:
|
|
plate_index = int(meta.get("value", "0"))
|
|
except ValueError:
|
|
pass
|
|
break
|
|
if plate_index == plate_id:
|
|
_collect_filaments(plate_elem, filaments)
|
|
break
|
|
else:
|
|
# Modern BambuStudio format wraps filaments inside <plate> elements.
|
|
# When no plate filter is requested, collect from every plate and
|
|
# deduplicate by slot_id (first occurrence wins after sort).
|
|
plate_elems = root.findall("./plate")
|
|
if plate_elems:
|
|
for plate_elem in plate_elems:
|
|
_collect_filaments(plate_elem, filaments)
|
|
# Deduplicate: same slot_id can appear on multiple plates.
|
|
# Keep the entry with the highest used_grams; ties go to the
|
|
# first plate (stable after sort + dict insertion order).
|
|
seen: dict[int, dict] = {}
|
|
for f in filaments:
|
|
sid = f["slot_id"]
|
|
if sid not in seen or f["used_grams"] > seen[sid]["used_grams"]:
|
|
seen[sid] = f
|
|
filaments = list(seen.values())
|
|
else:
|
|
# Older / non-plate-wrapped format: filaments are direct children of root.
|
|
_collect_filaments(root, filaments)
|
|
|
|
filaments.sort(key=lambda x: x["slot_id"])
|
|
|
|
# Dual-nozzle printers (H2D / X2D) — annotate which extruder each
|
|
# slot is fed into. Empty mapping for single-nozzle printers, in
|
|
# which case we just don't add the key.
|
|
# Same plate the filaments above were collected from: a multi-plate
|
|
# file can assign one slot to different extruders per plate, and
|
|
# annotating slot 2 with plate 3's nozzle is worse than not
|
|
# annotating it.
|
|
nozzle_mapping = extract_nozzle_mapping_from_3mf(zf, plate_id=plate_id)
|
|
if nozzle_mapping:
|
|
for filament in filaments:
|
|
filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
|
|
except Exception as e:
|
|
logger.warning("Failed to parse filament requirements from %s: %s", file_path, e)
|
|
return []
|
|
|
|
return filaments
|
|
|
|
|
|
def overrides_for_plate(
|
|
overrides: list[dict],
|
|
file_path: Path | None,
|
|
plate_id: int | None,
|
|
) -> list[dict]:
|
|
"""Drop the filament overrides whose slots this plate never prints.
|
|
|
|
Queueing several plates of one 3MF builds a single override list out of every
|
|
selected plate's filaments and hands that same list to each plate's item. A
|
|
``force_color_match`` entry blocks dispatch until the printer has that exact
|
|
colour loaded, so a single-colour plate ended up waiting on every colour in
|
|
the batch (#2551). Each item may only demand what its own plate consumes.
|
|
|
|
Overrides are kept as-is when the plate's slots cannot be established (whole
|
|
file selected, source gone, unreadable 3MF, malformed entry): an item that
|
|
waits on a colour it does not need is visible and fixable, whereas one that
|
|
silently loses a forced colour can dispatch the print in the wrong filament.
|
|
"""
|
|
if not overrides or plate_id is None or file_path is None or not file_path.exists():
|
|
return overrides
|
|
|
|
plate_slots = {f["slot_id"] for f in extract_filament_requirements(file_path, plate_id)}
|
|
if not plate_slots:
|
|
logger.warning(
|
|
"Cannot read the filaments of plate %s in %s; keeping all %d filament override(s)",
|
|
plate_id,
|
|
file_path.name,
|
|
len(overrides),
|
|
)
|
|
return overrides
|
|
|
|
narrowed = []
|
|
for override in overrides:
|
|
try:
|
|
slot_id = int(override["slot_id"])
|
|
except (KeyError, TypeError, ValueError):
|
|
narrowed.append(override)
|
|
continue
|
|
if slot_id in plate_slots:
|
|
narrowed.append(override)
|
|
|
|
if len(narrowed) != len(overrides):
|
|
logger.info(
|
|
"Plate %s: kept %d of %d filament override(s) — the rest belong to other plates",
|
|
plate_id,
|
|
len(narrowed),
|
|
len(overrides),
|
|
)
|
|
return narrowed
|
|
|
|
|
|
def _collect_filaments(parent: ET.Element, into: list[dict]) -> None:
|
|
"""Walk every `./filament` child under `parent` and append normalised
|
|
entries to `into`. Skips filaments with `used_g <= 0` (slot present in
|
|
the slicer config but not consumed by this plate)."""
|
|
for filament_elem in parent.findall("./filament"):
|
|
filament_id = filament_elem.get("id")
|
|
if not filament_id:
|
|
continue
|
|
try:
|
|
used_grams = float(filament_elem.get("used_g", "0"))
|
|
except (ValueError, TypeError):
|
|
continue
|
|
if used_grams <= 0:
|
|
continue
|
|
try:
|
|
slot_id = int(filament_id)
|
|
except (ValueError, TypeError):
|
|
continue
|
|
into.append(
|
|
{
|
|
"slot_id": slot_id,
|
|
"type": filament_elem.get("type", ""),
|
|
"color": filament_elem.get("color", ""),
|
|
"tray_info_idx": filament_elem.get("tray_info_idx", ""),
|
|
"used_grams": round(used_grams, 1),
|
|
}
|
|
)
|