Browse Source

fix(#422): start g-code anchor + slicer placeholder substitution

  Auto-Print G-code Injection had two reviewer-reported bugs from the initial
  ship:

  1. Start snippets were prepended to the entire plate_X.gcode, landing
     before the printer's own bed-heat / homing / nozzle-prime sequence —
     so a Swapmod start snippet that assumed nozzle-at-temp ran on a cold
     printer (pleite). Anchor injection at "; MACHINE_START_GCODE_END" so
     snippets land where a slicer-side custom-start-gcode would. Files
     without the marker keep prepend behaviour as a fallback with a
     warning log.

  2. Placeholders like "G1 Z{max_layer_z} F600" were written verbatim;
     firmware parsed them as Z1 and crashed the head into the print on
     tall models — real safety bug (DevScarabyte). Added a header parser
     for the 3MF "; HEADER_BLOCK_START..END" block (lowercased keys,
     [units] suffix stripped, spaces -> underscores) and a Prusa-style
     {name} substitution pass over both start and end snippets before
     injection. Supported placeholders: {max_layer_z} / {max_print_height},
     {total_layer_number} / {total_layers}, {total_filament_weight},
     {total_filament_length}, plus any other normalised header key.
     Unknown placeholders are left verbatim with a warning — a typo never
     silently expands to an empty string.

  16 new regression tests across 4 new classes in test_gcode_injection.py
  (anchored injection + missing-marker fallback, placeholder substitution
  including alias resolution + unknown-pass-through, direct unit tests
  for each new helper). All 2195 backend unit tests pass.

  Wiki print-queue page updated with the supported placeholder list and a
  {max_layer_z} safety callout for park moves.
maziggy 1 month ago
parent
commit
02eb5f57dc
3 changed files with 320 additions and 6 deletions
  1. 0 0
      CHANGELOG.md
  2. 99 3
      backend/app/utils/threemf_tools.py
  3. 221 3
      backend/tests/unit/test_gcode_injection.py

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 99 - 3
backend/app/utils/threemf_tools.py

@@ -6,6 +6,7 @@ accurate partial usage reporting for multi-material prints.
 """
 
 import json
+import logging
 import math
 import re
 import zipfile
@@ -13,6 +14,8 @@ from pathlib import Path
 
 import defusedxml.ElementTree as ET
 
+logger = logging.getLogger(__name__)
+
 # Default filament properties
 DEFAULT_FILAMENT_DIAMETER = 1.75  # mm
 DEFAULT_FILAMENT_DENSITY = 1.24  # g/cm³ (PLA)
@@ -442,6 +445,90 @@ def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None
     return filament_usage
 
 
+# Header values exposed as `{placeholder}` substitutions inside snippets.
+# Aliases let users write Prusa-style names (`{max_layer_z}`) that map onto
+# Bambu/Orca header keys (`max_z_height`).
+_HEADER_PLACEHOLDER_ALIASES = {
+    "max_layer_z": "max_z_height",
+    "max_print_height": "max_z_height",
+    "total_layers": "total_layer_number",
+}
+
+_HEADER_KEY_RE = re.compile(r"^;\s*([^:]+?)\s*:\s*(.+?)\s*$")
+_PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
+_START_GCODE_END_MARKER = "; MACHINE_START_GCODE_END"
+
+
+def _parse_3mf_gcode_header(content: str) -> dict[str, str]:
+    """Parse the `; HEADER_BLOCK_START..END` block into a normalised dict.
+
+    Keys are lowercased, ` [units]` suffixes stripped, and spaces converted
+    to underscores so callers can look up `total_layer_number` regardless of
+    whether the source line is `; total layer number: 80` or
+    `; total filament length [mm] : 12155.34`.
+    """
+    header: dict[str, str] = {}
+    in_header = False
+    for raw_line in content.splitlines():
+        line = raw_line.strip()
+        if line == "; HEADER_BLOCK_START":
+            in_header = True
+            continue
+        if line == "; HEADER_BLOCK_END":
+            break
+        if not in_header:
+            continue
+        m = _HEADER_KEY_RE.match(line)
+        if not m:
+            continue
+        key, value = m.group(1), m.group(2)
+        key = re.sub(r"\s*\[[^\]]*\]\s*$", "", key)
+        key = key.strip().lower().replace(" ", "_")
+        header[key] = value
+    return header
+
+
+def _substitute_placeholders(snippet: str, header: dict[str, str]) -> str:
+    """Replace `{var}` placeholders with header values, leaving unknowns intact."""
+
+    def repl(m: re.Match) -> str:
+        name = m.group(1)
+        value = header.get(name)
+        if value is None:
+            alias = _HEADER_PLACEHOLDER_ALIASES.get(name)
+            if alias is not None:
+                value = header.get(alias)
+        if value is None:
+            logger.warning(
+                "G-code injection: placeholder {%s} not found in 3MF header; leaving as-is",
+                name,
+            )
+            return m.group(0)
+        return value
+
+    return _PLACEHOLDER_RE.sub(repl, snippet)
+
+
+def _inject_start_at_marker(content: str, snippet: str) -> str:
+    """Insert snippet immediately before `; MACHINE_START_GCODE_END`.
+
+    The marker sits at the bottom of the printer's startup block — bed heat,
+    homing, and nozzle prime are already done, so injected snippets land in
+    the same place a slicer-side custom-start-gcode would. Falls back to
+    prepending if the marker isn't present (older files / non-Bambu slicers).
+    """
+    marker_idx = content.find(_START_GCODE_END_MARKER)
+    if marker_idx == -1:
+        logger.warning(
+            "G-code injection: '%s' not found, prepending start snippet to whole file",
+            _START_GCODE_END_MARKER,
+        )
+        return snippet.rstrip("\n") + "\n" + content
+    line_start = content.rfind("\n", 0, marker_idx)
+    line_start = 0 if line_start == -1 else line_start + 1
+    return content[:line_start] + snippet.rstrip("\n") + "\n" + content[line_start:]
+
+
 def inject_gcode_into_3mf(
     source_path: Path,
     plate_id: int,
@@ -450,10 +537,16 @@ def inject_gcode_into_3mf(
 ):
     """Create a temp copy of a 3MF with G-code injected at start/end.
 
+    Snippets support `{placeholder}` substitution against values parsed from
+    the 3MF G-code header block (e.g. `{max_layer_z}` → `16.00`). Start
+    snippets are anchored to the `; MACHINE_START_GCODE_END` marker so they
+    run after the printer's own startup (#422). End snippets are appended
+    after the last line of the print.
+
     Args:
         source_path: Path to the original 3MF file.
         plate_id: Plate number (1-indexed) to inject into.
-        start_gcode: G-code to prepend, or None.
+        start_gcode: G-code to insert after printer startup, or None.
         end_gcode: G-code to append, or None.
 
     Returns:
@@ -486,11 +579,14 @@ def inject_gcode_into_3mf(
 
             # Read and modify gcode content
             gcode_content = zf.read(target_gcode).decode("utf-8", errors="ignore")
+            header = _parse_3mf_gcode_header(gcode_content)
 
             if start_gcode:
-                gcode_content = start_gcode + "\n" + gcode_content
+                resolved = _substitute_placeholders(start_gcode, header)
+                gcode_content = _inject_start_at_marker(gcode_content, resolved)
             if end_gcode:
-                gcode_content = gcode_content.rstrip("\n") + "\n" + end_gcode + "\n"
+                resolved = _substitute_placeholders(end_gcode, header)
+                gcode_content = gcode_content.rstrip("\n") + "\n" + resolved + "\n"
 
             # Write modified 3MF to temp file
             with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp:

+ 221 - 3
backend/tests/unit/test_gcode_injection.py

@@ -4,9 +4,12 @@ import tempfile
 import zipfile
 from pathlib import Path
 
-import pytest
-
-from backend.app.utils.threemf_tools import inject_gcode_into_3mf
+from backend.app.utils.threemf_tools import (
+    _inject_start_at_marker,
+    _parse_3mf_gcode_header,
+    _substitute_placeholders,
+    inject_gcode_into_3mf,
+)
 
 
 def _make_temp_path(suffix=".3mf") -> Path:
@@ -205,3 +208,218 @@ class TestInjectGcodeInto3mf:
             source.unlink(missing_ok=True)
             if result:
                 result.unlink(missing_ok=True)
+
+
+# Realistic Bambu / Orca header + startup block — the start-gcode marker is the
+# anchor point #422 reviewers (DevScarabyte, pleite) reported as the correct
+# injection point. Snippets injected before this should land *after* the bed
+# heat / homing / nozzle prime sequence, not before it.
+_BAMBU_GCODE_TEMPLATE = """\
+; HEADER_BLOCK_START
+; BambuStudio 02.06.00.51
+; total layer number: 80
+; total filament length [mm] : 12155.34
+; total filament weight [g] : 36.55
+; max_z_height: 16.00
+; HEADER_BLOCK_END
+; MACHINE_START_GCODE_BEGIN
+M104 S220 ; preheat
+G28 ; home
+M109 S220 ; wait for nozzle
+G92 E0 ; reset extruder
+; MACHINE_START_GCODE_END
+G1 X10 Y10 Z0.2
+G1 X100 Y100 E5
+M104 S0
+"""
+
+
+class TestStartAnchoredInjection:
+    """Tests for #422 follow-up: start g-code injected at MACHINE_START_GCODE_END."""
+
+    def test_start_lands_after_printer_startup(self):
+        """Start snippet sits immediately before MACHINE_START_GCODE_END, not at file head."""
+        source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
+        try:
+            result = inject_gcode_into_3mf(source, 1, "; SWAPMOD-START", None)
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            # Original file head is preserved — snippet does NOT prepend.
+            assert gcode.startswith("; HEADER_BLOCK_START\n")
+            # Snippet sits right above the marker.
+            marker_idx = gcode.index("; MACHINE_START_GCODE_END")
+            snippet_idx = gcode.index("; SWAPMOD-START")
+            assert snippet_idx < marker_idx
+            # Nothing else between snippet and marker except the trailing newline.
+            between = gcode[snippet_idx:marker_idx]
+            assert between == "; SWAPMOD-START\n"
+            # Printer's own startup commands still come BEFORE the snippet.
+            startup_idx = gcode.index("M109 S220")
+            assert startup_idx < snippet_idx
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_no_marker_falls_back_to_prepend(self):
+        """Files without MACHINE_START_GCODE_END (older slicers) keep prepend behaviour."""
+        source = _make_test_3mf("G28\nM400\n")
+        try:
+            result = inject_gcode_into_3mf(source, 1, "; LEGACY-START", None)
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            assert gcode.startswith("; LEGACY-START\n")
+            assert "G28" in gcode
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_end_still_appended_at_eof(self):
+        """End g-code keeps the existing append-to-EOF behaviour even with marker present."""
+        source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
+        try:
+            result = inject_gcode_into_3mf(source, 1, None, "; SWAPMOD-END")
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            assert gcode.endswith("; SWAPMOD-END\n")
+            # Marker anchor is irrelevant for end snippets.
+            assert gcode.index("; SWAPMOD-END") > gcode.index("; MACHINE_START_GCODE_END")
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+
+class TestPlaceholderSubstitution:
+    """Tests for #422 follow-up: {placeholder} substitution from 3MF header values."""
+
+    def test_max_z_height_substituted_in_end_snippet(self):
+        """`G1 Z{max_layer_z}` resolves to the model's actual top-layer Z (DevScarabyte safety bug)."""
+        source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
+        try:
+            # Prusa-style alias: max_layer_z → max_z_height in the Bambu header
+            result = inject_gcode_into_3mf(source, 1, None, "G1 Z{max_layer_z} F600")
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            # max_z_height in the template is 16.00 — the dangerous Z1 fallback is gone.
+            assert "G1 Z16.00 F600" in gcode
+            assert "{max_layer_z}" not in gcode
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_direct_header_key_lookup(self):
+        """Snippets can reference normalised header keys directly without going through aliases."""
+        source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
+        try:
+            result = inject_gcode_into_3mf(
+                source, 1, None, "; layers={total_layer_number} weight={total_filament_weight}"
+            )
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            assert "; layers=80 weight=36.55" in gcode
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_unknown_placeholder_left_intact(self):
+        """A typo or unsupported placeholder is preserved verbatim instead of becoming empty."""
+        source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
+        try:
+            result = inject_gcode_into_3mf(source, 1, None, "; nope={does_not_exist}")
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            assert "; nope={does_not_exist}" in gcode
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_no_placeholders_no_header_required(self):
+        """Snippets without placeholders inject correctly even when the header is absent."""
+        source = _make_test_3mf("G28\nM400\n")
+        try:
+            result = inject_gcode_into_3mf(source, 1, "; PLAIN", None)
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            assert gcode.startswith("; PLAIN\n")
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+
+class TestHeaderParser:
+    """Direct tests for `_parse_3mf_gcode_header`."""
+
+    def test_parses_bambu_header_block(self):
+        header = _parse_3mf_gcode_header(_BAMBU_GCODE_TEMPLATE)
+        assert header["max_z_height"] == "16.00"
+        assert header["total_layer_number"] == "80"
+        # Units suffix is stripped from the key.
+        assert header["total_filament_length"] == "12155.34"
+        assert header["total_filament_weight"] == "36.55"
+
+    def test_ignores_lines_outside_header_block(self):
+        content = "; HEADER_BLOCK_START\n; key: in\n; HEADER_BLOCK_END\n; key: out\n"
+        header = _parse_3mf_gcode_header(content)
+        assert header == {"key": "in"}
+
+    def test_returns_empty_when_no_header(self):
+        assert _parse_3mf_gcode_header("G28\nG1 X0\n") == {}
+
+
+class TestPlaceholderHelper:
+    """Direct tests for `_substitute_placeholders`."""
+
+    def test_substitutes_known_keys(self):
+        assert _substitute_placeholders("Z={a} F={b}", {"a": "10", "b": "600"}) == "Z=10 F=600"
+
+    def test_alias_resolves_to_underlying_key(self):
+        assert _substitute_placeholders("Z={max_layer_z}", {"max_z_height": "16.00"}) == "Z=16.00"
+
+    def test_unknown_left_verbatim(self):
+        assert _substitute_placeholders("{nope}", {}) == "{nope}"
+
+
+class TestStartMarkerHelper:
+    """Direct tests for `_inject_start_at_marker`."""
+
+    def test_inserts_before_marker_line(self):
+        content = "first\nsecond\n; MACHINE_START_GCODE_END\ntail\n"
+        result = _inject_start_at_marker(content, "INJECTED")
+        assert result == "first\nsecond\nINJECTED\n; MACHINE_START_GCODE_END\ntail\n"
+
+    def test_marker_at_start_of_file(self):
+        content = "; MACHINE_START_GCODE_END\nrest\n"
+        result = _inject_start_at_marker(content, "INJECTED")
+        assert result == "INJECTED\n; MACHINE_START_GCODE_END\nrest\n"
+
+    def test_missing_marker_falls_back_to_prepend(self):
+        content = "G28\nG1 X0\n"
+        result = _inject_start_at_marker(content, "INJECTED")
+        assert result == "INJECTED\nG28\nG1 X0\n"

Some files were not shown because too many files changed in this diff