mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
Opening the slice dialog on an unsliced project runs a preview slice purely
to ask the slicer which AMS slots the chosen plate consumes. Bambu Studio 2.8
writes {if timelapse_inline_photo} into the machine's time_lapse_gcode but
exports no definition for that variable, so the template is unresolvable the
moment it leaves Studio: an older sidecar stops with a placeholder parse error
before producing any slice_info. The preview returned nothing and the caller
fell back to guessing from painted faces, silently. On the H2D project this
was found with, the guess dropped the support material -- a whole slot off a
four-filament plate.
Retry the preview once with just the named template emptied, still on the
file's own settings. Keeping the embedded settings is what keeps the answer
honest: overriding the process preset instead discards the project's support
configuration, which loses that slot and moves used_g by up to 2x. Measured
against the same file: retry reproduces all four slots gram for gram, a
printer+process override returns three.
Only templates that cannot extrude are eligible -- a start or filament-change
template lays a prime line or purges, so emptying one would move the very
grams the preview reports, and returning nothing beats a confident wrong
number. Verified on a working H2D slice that emptying time_lapse_gcode leaves
every used_g/used_m in slice_info identical.
Match on a normalised option name: the slicer reports timelapse_gcode while
the 3MF stores time_lapse_gcode, so a literal comparison finds nothing.
Decide whether a retry applies before logging, so a slice that recovers does
not announce itself at WARNING twenty seconds before it succeeds.
374 lines
16 KiB
Python
374 lines
16 KiB
Python
"""Preview-slice cache for the SliceModal.
|
|
|
|
The slice modal needs the per-plate filament list before the user picks
|
|
profiles. For sliced files this lives in ``Metadata/slice_info.config`` and
|
|
the ``/filament-requirements`` endpoint can read it directly. For unsliced
|
|
project files it doesn't exist yet — only the slicer can produce it, since
|
|
Bambu Studio applies its own pruning to painted-face data at slice time.
|
|
|
|
This module wraps the sidecar's slice call so the endpoint can run a preview
|
|
slice, parse the result's slice_info, and return the actual filament list.
|
|
The preview always uses the file's embedded settings (``slice_without_profiles``):
|
|
the slot-mapping is a model property, independent of process settings, so
|
|
we don't need to thread the user's profile triplet through here. That choice
|
|
also protects the numbers — overriding the process preset drops the project's
|
|
own support configuration, which loses whole slots from the answer.
|
|
|
|
The one thing that can defeat those embedded settings is a custom G-code
|
|
template written by a Studio newer than the sidecar, which fails to parse
|
|
before any slice_info exists. That case gets one retry with the offending
|
|
template blanked; see ``_blank_custom_gcode``.
|
|
|
|
Results are cached by ``(kind, source_id, plate_id, content_hash)`` so
|
|
repeat opens on the same plate are instant. LRU eviction keeps the cache
|
|
bounded. Hash invalidation handles in-place file replacement; no TTL is
|
|
used because preview-slice output is deterministic for a given input.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import re
|
|
import zipfile
|
|
from collections import OrderedDict
|
|
from io import BytesIO
|
|
|
|
import defusedxml.ElementTree as ET
|
|
|
|
from backend.app.services.slicer_api import (
|
|
SlicerApiError,
|
|
SlicerApiService,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
|
|
|
|
# The slicer names the offending G-code field in its stderr, e.g.
|
|
# timelapse_gcode Parsing error at line 13: Not a variable name
|
|
# {if timelapse_inline_photo}
|
|
_GCODE_PARSE_ERROR_RE = re.compile(
|
|
r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s+Parsing error at line \d+:",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
# Custom G-code fields we are willing to blank to get a preview through.
|
|
#
|
|
# Deliberately narrow, and the narrowness is the whole point: blanking a
|
|
# field that *extrudes* would change the very numbers the preview exists to
|
|
# report. `machine_start_gcode` lays a prime line, `change_filament_gcode`
|
|
# purges — silence either and the returned grams are quietly wrong, which is
|
|
# worse than returning nothing. Everything below only moves the toolhead or
|
|
# emits markers, so removing it cannot alter filament accounting. Verified
|
|
# against a real H2D slice: blanking `time_lapse_gcode` left every
|
|
# used_g/used_m in slice_info byte-identical.
|
|
#
|
|
# Keys are normalised (see `_normalise_option`) because the slicer reports
|
|
# `timelapse_gcode` while the 3MF stores `time_lapse_gcode`.
|
|
_BLANKABLE_GCODE_FIELDS = frozenset(
|
|
{
|
|
"timelapsegcode",
|
|
"layerchangegcode",
|
|
"beforelayerchangegcode",
|
|
"machinepausegcode",
|
|
"templatecustomgcode",
|
|
"printingbyobjectgcode",
|
|
}
|
|
)
|
|
|
|
|
|
def _normalise_option(name: str) -> str:
|
|
"""Fold a config-option name to a comparable form.
|
|
|
|
Bambu Studio's error text and its 3MF config disagree on word breaks for
|
|
the same option (`timelapse_gcode` vs `time_lapse_gcode`), so matching on
|
|
the literal string silently fails to find the field it just named.
|
|
"""
|
|
return re.sub(r"[^a-z0-9]", "", name.lower())
|
|
|
|
|
|
_PREVIEW_CACHE_MAX = 256
|
|
_PreviewCacheKey = tuple[str, int, int, str]
|
|
# Cache values: list[dict] on success, [] on parsed-but-empty (slicer
|
|
# returned a 3MF without filament data for this plate — caching the negative
|
|
# avoids burning 30s+ per modal open on a known-bad input).
|
|
_preview_cache: OrderedDict[_PreviewCacheKey, list[dict]] = OrderedDict()
|
|
# Per-key locks prevent N concurrent modal opens on the same (file, plate)
|
|
# from launching N redundant preview slices — only the first one runs, the
|
|
# rest wait and read from the cache. Locks are evicted alongside cache
|
|
# entries to keep the dict bounded; we do NOT cache transient sidecar
|
|
# failures (network errors etc.) so those retry naturally on next request.
|
|
_preview_locks: dict[_PreviewCacheKey, asyncio.Lock] = {}
|
|
|
|
|
|
def _content_hash(file_bytes: bytes) -> str:
|
|
return hashlib.sha256(file_bytes).hexdigest()[:16]
|
|
|
|
|
|
def _unparsable_gcode_option(error_text: str) -> str | None:
|
|
"""The normalised name of the custom-G-code field the slicer choked on.
|
|
|
|
Returns ``None`` when the failure was something else entirely, or when the
|
|
named field is one whose removal could change filament accounting — see
|
|
``_BLANKABLE_GCODE_FIELDS``. Callers treat ``None`` as "don't retry".
|
|
"""
|
|
match = _GCODE_PARSE_ERROR_RE.search(error_text)
|
|
if match is None:
|
|
return None
|
|
option = _normalise_option(match.group(1))
|
|
return option if option in _BLANKABLE_GCODE_FIELDS else None
|
|
|
|
|
|
def _blank_custom_gcode(file_bytes: bytes, option: str) -> bytes | None:
|
|
"""Return a copy of the 3MF with ``option``'s G-code template emptied.
|
|
|
|
A 3MF saved by a newer Bambu Studio can carry a machine G-code template
|
|
that references a config variable an older sidecar doesn't define — e.g.
|
|
Studio 2.8 writes ``{if timelapse_inline_photo}`` into ``time_lapse_gcode``
|
|
without exporting a definition for it, so the template is unresolvable the
|
|
moment it leaves Studio. Slicing then dies with a placeholder parse error
|
|
before producing any slice_info, and the preview has nothing to read.
|
|
|
|
Emptying just the one named template lets the slice complete on the file's
|
|
own settings, which is what keeps the answer trustworthy: process settings,
|
|
support configuration and per-slot filament assignments are all preserved,
|
|
so the filament list matches what the file would really produce.
|
|
|
|
Returns ``None`` when there is nothing to do — not a 3MF, no embedded
|
|
settings, no matching field, or a field that is already empty — so the
|
|
caller can skip a retry that would fail identically.
|
|
"""
|
|
try:
|
|
with zipfile.ZipFile(BytesIO(file_bytes)) as zf:
|
|
if _PROJECT_SETTINGS_PATH not in zf.namelist():
|
|
return None
|
|
entries = [(info, zf.read(info.filename)) for info in zf.infolist()]
|
|
settings = json.loads(zf.read(_PROJECT_SETTINGS_PATH).decode("utf-8", "replace"))
|
|
except (zipfile.BadZipFile, OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
return None
|
|
if not isinstance(settings, dict):
|
|
return None
|
|
|
|
# Match on the normalised name so the slicer's spelling finds the config's.
|
|
# Only `*_gcode` keys are eligible, so a same-stem non-template setting
|
|
# can never be caught by the fold.
|
|
blanked: list[str] = []
|
|
for key, value in settings.items():
|
|
if not key.endswith("_gcode") or _normalise_option(key) != option:
|
|
continue
|
|
if isinstance(value, str) and value:
|
|
settings[key] = ""
|
|
elif isinstance(value, list) and any(value):
|
|
# Preserve the container type — a per-extruder template is a list,
|
|
# and handing the CLI a bare string where it expects one would
|
|
# trade this parse error for a different one.
|
|
settings[key] = [""] * len(value)
|
|
else:
|
|
continue
|
|
blanked.append(key)
|
|
if not blanked:
|
|
return None
|
|
|
|
out = BytesIO()
|
|
try:
|
|
with zipfile.ZipFile(out, "w") as zf_out:
|
|
for info, data in entries:
|
|
if info.filename == _PROJECT_SETTINGS_PATH:
|
|
data = json.dumps(settings, indent=4).encode("utf-8")
|
|
# Carry each member's original compression across so the copy
|
|
# stays a 3MF the slicer reads the same way as the original.
|
|
zf_out.writestr(info, data, compress_type=info.compress_type)
|
|
except (OSError, ValueError):
|
|
return None
|
|
logger.debug("Preview slice: emptied custom G-code field(s) %s for retry", ", ".join(blanked))
|
|
return out.getvalue()
|
|
|
|
|
|
async def get_preview_filaments(
|
|
*,
|
|
kind: str,
|
|
source_id: int,
|
|
plate_id: int,
|
|
file_bytes: bytes,
|
|
file_name: str,
|
|
api_url: str,
|
|
request_id: str | None = None,
|
|
timeout_seconds: float | None = None,
|
|
) -> list[dict] | None:
|
|
"""Run a preview slice for ``plate_id``, parse the resulting slice_info,
|
|
and return the per-plate filament list.
|
|
|
|
Uses the file's embedded settings (``slice_without_profiles``) since the
|
|
slot mapping is a model property, independent of any user-picked profile
|
|
triplet. A slice killed by an unparsable custom G-code template is retried
|
|
once with that template blanked, still on the file's own settings.
|
|
|
|
Returns ``None`` when the preview slice fails — the caller should fall
|
|
back to whatever heuristic it has (typically the project_filaments +
|
|
painted-face approach in ``threemf_tools``).
|
|
"""
|
|
h = _content_hash(file_bytes)
|
|
key: _PreviewCacheKey = (kind, source_id, plate_id, h)
|
|
cached = _preview_cache.get(key)
|
|
if cached is not None:
|
|
_preview_cache.move_to_end(key)
|
|
return cached
|
|
|
|
lock = _preview_locks.setdefault(key, asyncio.Lock())
|
|
async with lock:
|
|
# Re-check after acquiring the lock — another coroutine may have
|
|
# populated the cache while we were waiting on it.
|
|
cached = _preview_cache.get(key)
|
|
if cached is not None:
|
|
_preview_cache.move_to_end(key)
|
|
return cached
|
|
|
|
# Preview slices are bounded the same way as real ones (#2730):
|
|
# a heavy plate can take a long time and must not be cut off
|
|
# while the slicer is visibly working.
|
|
svc_kwargs = {} if timeout_seconds is None else {"timeout_seconds": timeout_seconds}
|
|
|
|
async def _slice(model_bytes: bytes):
|
|
async with SlicerApiService(base_url=api_url, **svc_kwargs) as svc:
|
|
return await svc.slice_without_profiles(
|
|
model_bytes=model_bytes,
|
|
model_filename=file_name,
|
|
plate=plate_id,
|
|
export_3mf=True,
|
|
request_id=request_id,
|
|
)
|
|
|
|
try:
|
|
result = await _slice(file_bytes)
|
|
except SlicerApiError as e:
|
|
# One retry, and only for a custom-G-code template the sidecar
|
|
# cannot parse — a file from a Studio newer than the sidecar. The
|
|
# alternative is to give the caller nothing and let it fall back to
|
|
# its painted-face heuristic, so a retry that reproduces the file's
|
|
# own settings is strictly better than the status quo. Anything
|
|
# else (unreachable sidecar, timeout, bad input) returns as before.
|
|
#
|
|
# Whether a retry is even possible is decided *before* anything is
|
|
# logged, so a slice that recovers never announces itself as a
|
|
# failure. Logging the first attempt at WARNING regardless sent a
|
|
# reader looking for a bug in a path that had already fixed itself
|
|
# twenty seconds later, several screens further down the log.
|
|
retry_bytes = None
|
|
option = _unparsable_gcode_option(str(e))
|
|
if option is not None:
|
|
retry_bytes = _blank_custom_gcode(file_bytes, option)
|
|
if retry_bytes is None:
|
|
logger.warning(
|
|
"Preview slice failed for %s/%s plate %s: %s",
|
|
kind,
|
|
source_id,
|
|
plate_id,
|
|
e,
|
|
)
|
|
return None
|
|
logger.info(
|
|
"Preview slice for %s/%s plate %s hit unparsable custom G-code; retrying without it. "
|
|
"The file's G-code references a setting this slicer build does not know, so it is "
|
|
"probably from a newer Bambu Studio than the sidecar. Original failure: %s",
|
|
kind,
|
|
source_id,
|
|
plate_id,
|
|
e,
|
|
)
|
|
try:
|
|
result = await _slice(retry_bytes)
|
|
except SlicerApiError as retry_exc:
|
|
logger.warning(
|
|
"Preview slice retry without the unparsable G-code also failed for %s/%s plate %s: %s",
|
|
kind,
|
|
source_id,
|
|
plate_id,
|
|
retry_exc,
|
|
)
|
|
return None
|
|
except Exception as retry_exc: # noqa: BLE001 — never break the modal on sidecar issues
|
|
logger.warning("Preview slice retry unexpected error: %s", retry_exc)
|
|
return None
|
|
logger.info("Preview slice for %s/%s plate %s succeeded on retry", kind, source_id, plate_id)
|
|
except Exception as e: # noqa: BLE001 — never break the modal on sidecar issues
|
|
logger.warning("Preview slice unexpected error: %s", e)
|
|
return None
|
|
|
|
filaments = _parse_filaments_from_sliced_3mf(result.content, plate_id)
|
|
# Negative-cache the parse failure: a slice that succeeds but yields
|
|
# no parsable filament data for this plate is a deterministic
|
|
# property of the input. Re-running the slice produces the same
|
|
# result, just N seconds slower. Empty list signals "preview was
|
|
# tried, no usable data" so the caller can fall through.
|
|
cache_value: list[dict] = filaments if filaments is not None else []
|
|
_preview_cache[key] = cache_value
|
|
if len(_preview_cache) > _PREVIEW_CACHE_MAX:
|
|
evicted_key, _ = _preview_cache.popitem(last=False)
|
|
# Drop the matching lock so the dict doesn't grow forever.
|
|
# Safe to discard: the lock isn't held here, and any later
|
|
# request for the same key will mint a fresh lock.
|
|
_preview_locks.pop(evicted_key, None)
|
|
return filaments
|
|
|
|
|
|
def _parse_filaments_from_sliced_3mf(content: bytes, plate_id: int) -> list[dict] | None:
|
|
"""Extract ``<filament>`` entries for ``plate_id`` from a sliced 3MF's
|
|
Metadata/slice_info.config. Returns ``None`` on any parse error so the
|
|
caller knows to fall back."""
|
|
try:
|
|
with zipfile.ZipFile(BytesIO(content)) as zf:
|
|
if "Metadata/slice_info.config" not in zf.namelist():
|
|
return None
|
|
data = zf.read("Metadata/slice_info.config").decode()
|
|
except (zipfile.BadZipFile, OSError):
|
|
return None
|
|
|
|
try:
|
|
root = ET.fromstring(data)
|
|
except ET.ParseError:
|
|
return None
|
|
|
|
for plate_elem in root.findall(".//plate"):
|
|
idx = None
|
|
for meta in plate_elem.findall("metadata"):
|
|
if meta.get("key") == "index":
|
|
try:
|
|
idx = int(meta.get("value", ""))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
break
|
|
if idx != plate_id:
|
|
continue
|
|
out: list[dict] = []
|
|
for f in plate_elem.findall("filament"):
|
|
fid = f.get("id")
|
|
if not fid:
|
|
continue
|
|
try:
|
|
slot_id = int(fid)
|
|
except (ValueError, TypeError):
|
|
continue
|
|
try:
|
|
used_grams = float(f.get("used_g", "0"))
|
|
except (ValueError, TypeError):
|
|
used_grams = 0
|
|
try:
|
|
used_meters = float(f.get("used_m", "0"))
|
|
except (ValueError, TypeError):
|
|
used_meters = 0
|
|
out.append(
|
|
{
|
|
"slot_id": slot_id,
|
|
"type": f.get("type", ""),
|
|
"color": f.get("color", ""),
|
|
"used_grams": round(used_grams, 1),
|
|
"used_meters": used_meters,
|
|
"tray_info_idx": f.get("tray_info_idx", ""),
|
|
},
|
|
)
|
|
return sorted(out, key=lambda x: x["slot_id"])
|
|
return None
|