mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user