Files
bambuddy/backend/app/utils/threemf_tools.py
T
maziggy dc82b5ff2a Refactor Spoolman per-filament tracking (PR #277 follow-up)
Extract Spoolman tracking from main.py into dedicated service module,
DRY up repeated helpers, add i18n for new settings UI, and add tests.

- Move ~460 lines from main.py to services/spoolman_tracking.py
- Extract _resolve_spool_tag, _resolve_global_tray_id, build_ams_tray_lookup helpers
- Replace fragile tuple return in get_spoolman_settings() with dict
- Wire 4 new UI strings through i18n (en/de/ja)
- Add 42 backend unit tests (spoolman tracking helpers + 3MF parsing)
- Add 6 frontend tests for weight sync and partial usage toggles
- Update CHANGELOG, wiki, and website docs

Closes PR #277
2026-02-06 10:03:27 +01:00

309 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""3MF file parsing utilities for filament tracking.
This module provides functions to parse Bambu Lab 3MF files and extract
per-layer filament usage data from the embedded G-code. This enables
accurate partial usage reporting for multi-material prints.
"""
import json
import math
import re
import zipfile
from pathlib import Path
import defusedxml.ElementTree as ET
# Default filament properties
DEFAULT_FILAMENT_DIAMETER = 1.75 # mm
DEFAULT_FILAMENT_DENSITY = 1.24 # g/cm³ (PLA)
def parse_gcode_layer_filament_usage(gcode_content: str) -> dict[int, dict[int, float]]:
"""Parse G-code to extract per-layer, per-filament cumulative extrusion in mm.
This function tracks filament extrusion across layers and tool changes,
building a cumulative usage map that can be used to calculate partial
usage at any layer.
Args:
gcode_content: The raw G-code content as a string
Returns:
A nested dictionary mapping layer numbers to filament usage:
{layer: {filament_id: cumulative_mm}, ...}
Example:
{0: {0: 125.5}, 1: {0: 250.0, 1: 50.0}, 2: {0: 375.0, 1: 150.0}}
This shows:
- Layer 0: filament 0 used 125.5mm cumulative
- Layer 1: filament 0 used 250mm cumulative, filament 1 used 50mm
- Layer 2: filament 0 used 375mm cumulative, filament 1 used 150mm
G-code commands parsed:
- M73 L<layer>: Layer change marker
- M620 S<filament>: Filament/tool change (S255 = unload)
- G0/G1/G2/G3 E<amount>: Extrusion moves
"""
layer_filaments: dict[int, dict[int, float]] = {}
current_layer = 0
active_filament: int | None = None
cumulative_extrusion: dict[int, float] = {} # filament_id -> total mm
for line in gcode_content.splitlines():
line = line.strip()
if not line:
continue
# Handle comments - skip but check for layer markers
if line.startswith(";"):
# Some slicers use comment-based layer markers
# e.g., "; CHANGE_LAYER" or ";LAYER_CHANGE"
continue
# Split line into command and inline comment
if ";" in line:
line = line.split(";")[0].strip()
# Extract command and parameters
parts = line.split()
if not parts:
continue
cmd = parts[0].upper()
# Layer change: M73 L<layer>
# Bambu printers use M73 with L parameter for layer indication
if cmd == "M73":
for part in parts[1:]:
part_upper = part.upper()
if part_upper.startswith("L"):
try:
new_layer = int(part[1:])
# Save current state before layer change
if cumulative_extrusion:
layer_filaments[current_layer] = cumulative_extrusion.copy()
current_layer = new_layer
except ValueError:
pass
# Filament change: M620 S<filament>
# Bambu uses M620 for AMS filament switching
# S255 means full unload (no active filament)
elif cmd == "M620":
for part in parts[1:]:
part_upper = part.upper()
if part_upper.startswith("S"):
filament_str = part[1:]
if filament_str == "255":
# Full unload - no active filament
active_filament = None
else:
try:
# Extract digits (e.g., "0A" -> 0, "1" -> 1)
match = re.match(r"(\d+)", filament_str)
if match:
active_filament = int(match.group(1))
except (ValueError, AttributeError):
pass
# Extrusion moves: G0/G1/G2/G3 with E parameter
# Only G1 typically has extrusion, but check all for safety
elif cmd in ("G0", "G1", "G2", "G3"):
if active_filament is None:
continue
for part in parts[1:]:
part_upper = part.upper()
if part_upper.startswith("E"):
try:
extrusion = float(part[1:])
# Only count positive extrusion (not retractions)
if extrusion > 0:
current = cumulative_extrusion.get(active_filament, 0)
cumulative_extrusion[active_filament] = current + extrusion
except ValueError:
pass
# Save final layer state
if cumulative_extrusion:
layer_filaments[current_layer] = cumulative_extrusion.copy()
return layer_filaments
def mm_to_grams(
length_mm: float,
diameter_mm: float = DEFAULT_FILAMENT_DIAMETER,
density_g_cm3: float = DEFAULT_FILAMENT_DENSITY,
) -> float:
"""Convert filament length in mm to weight in grams.
Uses the formula: mass = volume × density
where volume = π × r² × length
Args:
length_mm: Length of filament in millimeters
diameter_mm: Filament diameter in millimeters (default: 1.75)
density_g_cm3: Material density in g/cm³ (default: 1.24 for PLA)
Returns:
Weight in grams
"""
radius_cm = (diameter_mm / 2) / 10 # Convert mm to cm
length_cm = length_mm / 10 # Convert mm to cm
volume_cm3 = math.pi * radius_cm * radius_cm * length_cm
return volume_cm3 * density_g_cm3
def extract_layer_filament_usage_from_3mf(file_path: Path) -> dict[int, dict[int, float]] | None:
"""Extract per-layer filament usage from a 3MF file's embedded G-code.
Args:
file_path: Path to the 3MF file
Returns:
Dictionary mapping layers to filament usage, or None if parsing fails.
Format: {layer: {filament_id: cumulative_mm}, ...}
"""
try:
with zipfile.ZipFile(file_path, "r") as zf:
# Find G-code file(s) - usually plate_1.gcode or Metadata/plate_1.gcode
gcode_files = [f for f in zf.namelist() if f.endswith(".gcode")]
if not gcode_files:
return None
# Use the first G-code file (typically only one per 3MF export)
gcode_path = gcode_files[0]
gcode_content = zf.read(gcode_path).decode("utf-8", errors="ignore")
return parse_gcode_layer_filament_usage(gcode_content)
except Exception:
return None
def get_cumulative_usage_at_layer(
layer_usage: dict[int, dict[int, float]],
target_layer: int,
) -> dict[int, float]:
"""Get cumulative filament usage (in mm) up to and including target_layer.
Args:
layer_usage: The output from parse_gcode_layer_filament_usage()
target_layer: The layer number to get usage for
Returns:
Dictionary of {filament_id: cumulative_mm} for each filament used
up to target_layer. Returns empty dict if no data available.
"""
if not layer_usage:
return {}
# Find the highest recorded layer <= target_layer
# (we store snapshots at layer changes, so we need the closest one)
relevant_layers = [layer for layer in layer_usage if layer <= target_layer]
if not relevant_layers:
return {}
max_layer = max(relevant_layers)
return layer_usage.get(max_layer, {})
def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
"""Extract filament properties (density, diameter, type) from 3MF metadata.
Args:
file_path: Path to the 3MF file
Returns:
Dictionary mapping filament IDs to their properties:
{filament_id: {"diameter": 1.75, "density": 1.24, "type": "PLA"}, ...}
Note: filament_id is 1-based (matches slot_id in slice_info.config)
"""
properties: dict[int, dict] = {}
try:
with zipfile.ZipFile(file_path, "r") as zf:
# Try slice_info.config first for filament types
if "Metadata/slice_info.config" in zf.namelist():
content = zf.read("Metadata/slice_info.config").decode()
root = ET.fromstring(content)
for f in root.findall(".//filament"):
try:
# id is 1-based in slice_info.config
fid = int(f.get("id", 0))
properties[fid] = {
"type": f.get("type", "PLA"),
"diameter": DEFAULT_FILAMENT_DIAMETER,
"density": DEFAULT_FILAMENT_DENSITY,
}
except ValueError:
pass
# Try project_settings.config for density values
if "Metadata/project_settings.config" in zf.namelist():
content = zf.read("Metadata/project_settings.config").decode()
try:
data = json.loads(content)
densities = data.get("filament_density", [])
for i, density in enumerate(densities):
# project_settings uses 0-based indexing, convert to 1-based
fid = i + 1
if fid not in properties:
properties[fid] = {
"type": "",
"diameter": DEFAULT_FILAMENT_DIAMETER,
}
try:
properties[fid]["density"] = float(density)
except (ValueError, TypeError):
properties[fid]["density"] = DEFAULT_FILAMENT_DENSITY
except json.JSONDecodeError:
pass
except Exception:
pass
return properties
def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
"""Extract per-filament total usage from 3MF slice_info.config.
This extracts the slicer-estimated total usage per filament slot,
not the per-layer breakdown.
Args:
file_path: Path to the 3MF file
Returns:
List of filament usage dictionaries:
[{"slot_id": 1, "used_g": 50.5, "type": "PLA", "color": "#FF0000"}, ...]
"""
filament_usage = []
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)
for f in root.findall(".//filament"):
filament_id = f.get("id")
used_g = f.get("used_g", "0")
try:
used_amount = float(used_g)
if filament_id:
filament_usage.append(
{
"slot_id": int(filament_id),
"used_g": used_amount,
"type": f.get("type", ""),
"color": f.get("color", ""),
}
)
except (ValueError, TypeError):
pass
except Exception:
pass
return filament_usage