fix(slicer): wrong-printer slicing + sliced-archive filament list + per-instance MakerWorld compat

Five stacked slice-pipeline bugs that each made the modal's profile picker
  theatrical for 3MF inputs:

  (1) `_strip_3mf_embedded_settings` removed `model_settings.config` /
      `slice_info.config` / `cut_information.xml` along with
      `project_settings.config`. The CLI silently exited after
      "Initializing StaticPrintConfigs" — exit 0, no result.json — and
      Bambuddy masked the failure by re-running with embedded settings
      and the source's bound printer. Strip removed from the dispatch
      path entirely.

  (2) Standard-tier preset stubs lacked the `type` field, so the CLI
      rejected `--load-settings` with rc=-5 ("input preset file is
      invalid") and the same masking fallback fired. Added
      `_SLOT_TO_PROFILE_TYPE` so each stub carries the right
      machine/process/filament discriminator.

  (3) Sliced-archive cards listed every project-wide AMS slot (16+
      swatches for a 2-color print). `slice_and_persist_as_archive` now
      reads `filament_type` / `filament_color` from the sliced output's
      `slice_info.config` (which `ThreeMFParser` already gates on
      `used_g > 0`) instead of inheriting from the source archive.

  (4) SliceModal had no warning when the picked printer profile didn't
      match the source 3MF — the CLI rejects cross-printer slices
      (rc=-16) and fell back to embedded settings, producing wrong-printer
      g-code that errored at print dispatch. Plates response now exposes
      `source_printer_model`; the modal compares against the picked
      profile name and disables Slice + shows an inline warning on
      mismatch.

  (5) MakerWorld URL-paste resolver listed plate instances without
      showing which printer each was sliced for (`/instances/hits`
      omits compatibility info that lives on `design.instances[]
      .extention.modelInfo`). The resolve route now joins both payloads
      by instance ID and forwards `compatibility` + `otherCompatibility`
      onto each hit; the MakerWorld page renders "Sliced for {primary}"
      + "Also marked compatible: ..." per row.

  Tests: 6 unit tests for `extract_source_printer_model_from_3mf`, 1 for
  filtered filament metadata via ThreeMFParser, 2 for makerworld resolve
  compat-merge (happy path + missing modelInfo), 3 frontend SliceModal
  tests for the printer-mismatch warning + Slice-disabled gate. New i18n
  keys `slice.printerMismatch`, `makerworld.slicedFor`,
  `makerworld.alsoCompatible` across all 8 locales.
This commit is contained in:
maziggy
2026-04-28 13:58:56 +02:00
parent 6497eefe60
commit c1f69ee0cc
25 changed files with 688 additions and 127 deletions
@@ -389,9 +389,17 @@ class TestSliceLibraryFile:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_3mf_input_strips_embedded_settings_before_forwarding(
async def test_3mf_input_forwarded_unmodified_to_sidecar(
self, async_client: AsyncClient, db_session, slice_test_setup
):
# 3MF input must be forwarded to the sidecar verbatim — every
# Metadata/*.config the source carries (project_settings,
# model_settings, slice_info, cut_information) is needed by the
# CLI to find plate definitions and baseline config; an earlier
# version of this code stripped them and caused the CLI to
# silently exit immediately after "Initializing StaticPrintConfigs"
# for every 3MF slice. --load-settings overrides the specific
# fields the user changed; the rest comes from the embedded data.
src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "real.3mf"
src_3mf_path.write_bytes(_make_3mf_with_settings({"prime_tower_brim_width": "-1"}))
threemf = LibraryFile(
@@ -431,20 +439,18 @@ class TestSliceLibraryFile:
final = await _wait_for_job(async_client, response.json()["job_id"])
assert final["status"] == "completed", final
# Recover the embedded zip from the multipart body — the strip
# must remove every config that references the original slice's
# printer / filament IDs (otherwise the CLI's input validation
# rejects the new --load-settings triplet, the slice fails, and
# we drop into the embedded-settings fallback). Geometry stays.
# Recover the embedded zip from the multipart body and assert ALL
# the source's Metadata/*.config files are still present — the
# opposite of the previous (broken) "strip everything" test.
body = captured["body"]
pk = body.find(b"PK\x03\x04")
assert pk >= 0, "3MF body not found in multipart payload"
with zipfile.ZipFile(io.BytesIO(body[pk:]), "r") as zin:
names = set(zin.namelist())
assert "Metadata/project_settings.config" not in names
assert "Metadata/model_settings.config" not in names
assert "Metadata/slice_info.config" not in names
assert "Metadata/cut_information.xml" not in names
assert "Metadata/project_settings.config" in names
assert "Metadata/model_settings.config" in names
assert "Metadata/slice_info.config" in names
assert "Metadata/cut_information.xml" in names
assert "3D/3dmodel.model" in names
@@ -113,6 +113,51 @@ class TestThreeMFParserErrorVisibility:
# No assertions about which keys are present — just that it didn't blow up.
assert isinstance(result, dict)
def test_filament_metadata_only_includes_filaments_with_used_g(
self,
tmp_path: Path,
) -> None:
"""slice_and_persist_as_archive uses parsed_metadata.filament_type/color
to populate the new archive's filament list. The parser must filter
out filaments whose used_g==0 — otherwise the resulting archive card
shows every project-wide AMS slot (16+ swatches) for what was
actually a 2-color print on a single plate.
"""
p = tmp_path / "two-of-eighteen.3mf"
with zipfile.ZipFile(p, "w") as zf:
zf.writestr("3D/3dmodel.model", "<model/>")
# 4 declared slots, only 2 actually consumed on this plate.
zf.writestr(
"Metadata/slice_info.config",
"""<?xml version="1.0"?>
<config>
<plate>
<metadata key="index" value="1"/>
<filament id="1" type="PLA" color="#FFFFFF" used_g="25.0" used_m="8.5"/>
<filament id="2" type="PETG" color="#FF0000" used_g="0" used_m="0"/>
<filament id="3" type="PLA" color="#000000" used_g="12.5" used_m="4.2"/>
<filament id="4" type="ABS" color="#00FF00" used_g="0" used_m="0"/>
</plate>
</config>""",
)
result = ThreeMFParser(p).parse()
# Both fields should be comma-joined strings of only the consumed
# filaments — slot 2 (PETG #FF0000) and slot 4 (ABS #00FF00) must
# not appear on the new archive card. The parser dedupes types,
# so both PLA slots collapse into a single "PLA" entry; colors
# are unique per swatch and stay distinct.
types = result.get("filament_type", "")
assert "PLA" in types
assert "PETG" not in types # used_g=0 → excluded
assert "ABS" not in types
colors = result.get("filament_color", "")
assert "#FFFFFF" in colors
assert "#000000" in colors
assert "#FF0000" not in colors
assert "#00FF00" not in colors
class TestZipFileSentinel:
"""Sanity check the sentinel the archive pipeline relies on."""
@@ -30,9 +30,26 @@ def test_standard_emits_inherits_stub():
# treat this as a User-authored profile and reject it against
# system filament/process pairs.
"from": "system",
# `type` is required by the CLI's --load-settings parser. Without
# it the CLI silently exits with rc=-5 ("input preset file is
# invalid"), causing every 3MF slice to fall back to embedded
# settings. See preset_resolver._SLOT_TO_PROFILE_TYPE.
"type": "machine",
}
def test_standard_emits_correct_type_per_slot():
"""Each slot maps to the right `type` value the CLI parser expects:
printer → machine, process → process, filament → filament. Missing or
wrong type causes the CLI to silently exit with rc=-5."""
for slot, expected_type in (("printer", "machine"), ("process", "process"), ("filament", "filament")):
out = preset_resolver._resolve_standard(
PresetRef(source="standard", id="anything"),
slot=slot,
)
assert json.loads(out)["type"] == expected_type, slot
def test_standard_rejects_unknown_slot():
with pytest.raises(HTTPException) as exc:
preset_resolver._resolve_standard(PresetRef(source="standard", id="anything"), slot="bogus")
@@ -136,6 +136,92 @@ class TestResolve:
assert resp.status_code == 200, resp.text
assert resp.json()["already_imported_library_ids"] == [existing.id]
@pytest.mark.asyncio
async def test_merges_compatibility_from_design_into_instances(self, async_client):
"""Per-instance printer compatibility info lives on
``design.instances[].extention.modelInfo`` but not on
``/instances/hits``. Resolve enriches each hit with both
``compatibility`` (primary printer the instance was sliced for) and
``otherCompatibility`` (extra printers the uploader marked it
compatible with) so the frontend can show "sliced for A1 / also
marked compatible with: H2D, P1S".
"""
design_payload = {
"id": 1400373,
"title": "Seed Starter",
"instances": [
{
"id": 1452154,
"extention": {
"modelInfo": {
"compatibility": ["A1"],
"otherCompatibility": ["H2D", "P1S"],
}
},
},
{
"id": 1452158,
"extention": {
"modelInfo": {
"compatibility": ["X1 Carbon"],
"otherCompatibility": [],
}
},
},
],
}
instances_payload = {
"total": 2,
"hits": [
{"id": 1452154, "profileId": 298919107, "title": "9 cells"},
{"id": 1452158, "profileId": 298919564, "title": "12 cells"},
],
}
svc = _fake_service(get_design=design_payload, get_design_instances=instances_payload)
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
resp = await async_client.post(
"/api/v1/makerworld/resolve",
json={"url": "https://makerworld.com/en/models/1400373"},
)
assert resp.status_code == 200, resp.text
instances = resp.json()["instances"]
by_id = {i["id"]: i for i in instances}
assert by_id[1452154]["compatibility"] == ["A1"]
assert by_id[1452154]["otherCompatibility"] == ["H2D", "P1S"]
assert by_id[1452158]["compatibility"] == ["X1 Carbon"]
assert by_id[1452158]["otherCompatibility"] == []
@pytest.mark.asyncio
async def test_resolve_handles_missing_compatibility_gracefully(self, async_client):
"""Older designs (or hits without a matching design.instances entry)
must not crash the resolve response — they just don't get the
compat fields."""
design_payload = {"id": 1400373, "instances": [{"id": 1452154}]} # no extention
instances_payload = {
"total": 2,
"hits": [
{"id": 1452154, "profileId": 298919107},
{"id": 9999999, "profileId": 298919999}, # no design.instances match
],
}
svc = _fake_service(get_design=design_payload, get_design_instances=instances_payload)
with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
resp = await async_client.post(
"/api/v1/makerworld/resolve",
json={"url": "https://makerworld.com/en/models/1400373"},
)
assert resp.status_code == 200, resp.text
instances = resp.json()["instances"]
# First instance: design entry exists but no extention → fields absent or None.
first = next(i for i in instances if i["id"] == 1452154)
assert first.get("compatibility") is None
assert first.get("otherCompatibility") is None
# Second instance: no design entry at all → no enrichment, no crash.
second = next(i for i in instances if i["id"] == 9999999)
assert "compatibility" not in second or second["compatibility"] is None
class TestImport:
"""End-to-end of POST /makerworld/import — mocks the service but exercises
+41
View File
@@ -13,6 +13,7 @@ from backend.app.utils.threemf_tools import (
extract_filament_usage_from_3mf,
extract_plate_extruder_set_from_3mf,
extract_project_filaments_from_3mf,
extract_source_printer_model_from_3mf,
get_cumulative_usage_at_layer,
mm_to_grams,
parse_gcode_layer_filament_usage,
@@ -645,3 +646,43 @@ class TestExtractPlateExtruderSetFrom3mf:
# Top-level metadata still works; missing component model file
# is silently skipped without crashing.
assert extract_plate_extruder_set_from_3mf(zf, plate_id=1) == {2}
# ---------------------------------------------------------------------------
# Tests for extract_source_printer_model_from_3mf — feeds the SliceModal's
# pre-slice mismatch warning. The CLI cannot re-slice a 3MF for a different
# printer, so warning the user up-front avoids producing wrong-printer output.
# ---------------------------------------------------------------------------
class TestExtractSourcePrinterModelFrom3mf:
def test_returns_none_when_project_settings_missing(self):
with _make_3mf_with({"placeholder.txt": "hi"}) as zf:
assert extract_source_printer_model_from_3mf(zf) is None
def test_reads_printer_model_directly(self):
proj = {"printer_model": "Bambu Lab H2D"}
with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
assert extract_source_printer_model_from_3mf(zf) == "Bambu Lab H2D"
def test_falls_back_to_printer_settings_id_with_nozzle_strip(self):
# Older Bambu Studio exports stored the printer under
# printer_settings_id, often with a "0.4 nozzle" suffix the helper
# must strip to match the canonical model name.
proj = {"printer_settings_id": "Bambu Lab A1 0.4 nozzle"}
with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
assert extract_source_printer_model_from_3mf(zf) == "Bambu Lab A1"
def test_settings_id_without_nozzle_suffix_returned_as_is(self):
proj = {"printer_settings_id": "Bambu Lab P1S"}
with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
assert extract_source_printer_model_from_3mf(zf) == "Bambu Lab P1S"
def test_corrupt_json_returns_none_no_exception(self):
with _make_3mf_with({"Metadata/project_settings.config": b"{broken"}) as zf:
assert extract_source_printer_model_from_3mf(zf) is None
def test_empty_string_treated_as_missing(self):
proj = {"printer_model": "", "printer_settings_id": ""}
with _make_3mf_with({"Metadata/project_settings.config": json.dumps(proj)}) as zf:
assert extract_source_printer_model_from_3mf(zf) is None