From 53d4fc2e6b8f5ff4a4a2ef823e69ac69f50c69ba Mon Sep 17 00:00:00 2001 From: Jan Jaap Date: Fri, 7 Aug 2026 12:13:24 +0200 Subject: [PATCH] feat(slicer): carry the printer's printable_area on UnifiedPreset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prerequisite for #67. Both halves of that epic need the selected printer's bed size and Bambuddy could not obtain it anywhere: the `Printer` model has no dimensions, `PRINTER_MODEL_MAP` has none, and `printerPresetAxes.ts` states outright that the preset NAME is the only carrier. The only bed geometry Bambuddy reads today comes out of a 3MF, which is why an STL and a bedless 3MF both render on a hardcoded 256x256 plate. `UnifiedPreset` now carries `printable_area` for the printer slot: the bed outline as the slicer's own polygon of `"x"` corner points in bed millimetres, e.g. `["0x0","350x0","350x320","0x320"]` for an H2D. Populated per tier — standard from the sidecar (jappyjan/orca-slicer-api#4, resolved through the bundled profile's `inherits:` chain), orca_cloud and local from the profile content each already holds, cloud across the same name bridge `filament_vendor` rides (#58). Carried raw rather than reduced to width/height: 8 profiles in OrcaSlicer's vendor tree declare 72-point round delta beds, 3 declare 6 points and 3 declare 239, and flattening any of those would hand the viewport a rectangle that lies about where a model may be placed. Consumers reduce it themselves. Only the container shape is normalised — a polygon written as one comma-joined string is split and stray whitespace inside a point is trimmed, both of which occur verbatim in the bundled trees. No hardcoded model->bed map: one existed and was deliberately removed in favour of reading `printable_area` (CHANGELOG:1173). There is consequently no name-parse fallback for this field the way there is for `filament_vendor` — a bed cannot be guessed from a string, so an unresolvable one stays `None`. **Degrades to `None` on every sidecar that has not been rebuilt**, which is the state each deployment is in today. That is the normal state, not an error state, and it is tested explicitly: the old `{name, base_id}` response still yields a complete listing with the bed simply absent. `None` stays distinguishable from a bed of size zero throughout — fewer than three usable points reads as "no bed known", never as a zero-area one, because the first means "fall back to the default plate" and the second means "place everything at the origin". Consuming the value is #69's job; nothing in the viewport is touched here. Refs #68, #67 Co-Authored-By: Claude Opus 5 --- backend/app/api/routes/slicer_presets.py | 123 ++++++++- backend/app/schemas/slicer_presets.py | 35 +++ backend/app/services/slicer_api.py | 24 ++ backend/tests/unit/test_slicer_presets.py | 315 +++++++++++++++++++++- frontend/src/api/client.ts | 17 ++ 5 files changed, 506 insertions(+), 8 deletions(-) diff --git a/backend/app/api/routes/slicer_presets.py b/backend/app/api/routes/slicer_presets.py index aa0fe766a1..72cde68d59 100644 --- a/backend/app/api/routes/slicer_presets.py +++ b/backend/app/api/routes/slicer_presets.py @@ -287,6 +287,15 @@ async def _fetch_orca_cloud_presets( filament_colour=filament_colour, filament_vendor=filament_vendor, ) + if slot == "printer": + # Orca Cloud hands us the whole profile content, so a printer + # profile that states its bed states it right here. A cloud + # profile is a user's own export and is normally already + # flattened; one that only `inherits:` a bundled base states + # nothing and stays `None` — Bambuddy has no copy of the + # slicer's profile tree to walk, which is exactly why the + # sidecar resolves the standard tier. + preset.printable_area = _parse_printable_area(content.get("printable_area")) if slot in ("process", "filament"): # The profile's own compatible-printer list, straight out of # the content Orca already hands us (#2628). Without it the @@ -314,6 +323,14 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset if slot is None: continue preset = UnifiedPreset(id=str(p.id), name=p.name, source="local") + if slot == "printer": + # `LocalPreset.setting` is the full *resolved* profile blob the + # importer stored, so an imported printer preset carries its bed + # here even though no column exists for it. Re-parsing the blob is + # what `_parse_filament_metadata` already does for the filament + # slot; adding a column would need a migration for a value only + # this listing reads. + preset.printable_area = _parse_local_printable_area(p.setting) if slot == "filament": preset.filament_type, preset.filament_colour = _parse_filament_metadata(p.setting) # The MODEL COLUMN, not the stored profile blob (#58). This @@ -398,6 +415,56 @@ def _first_scalar(value: object) -> str | None: return None +def _parse_local_printable_area(setting_json: str | None) -> list[str] | None: + """``printable_area`` out of a stored local-preset blob. + + Defensive parse, same contract as :func:`_parse_filament_metadata`: any + error returns ``None`` so a corrupt row costs one printer its bed rather + than costing the caller the whole local tier. + """ + if not setting_json: + return None + try: + data = json.loads(setting_json) + except (ValueError, TypeError): + return None + if not isinstance(data, dict): + return None + return _parse_printable_area(data.get("printable_area")) + + +def _parse_printable_area(value: object) -> list[str] | None: + """Normalise a declared bed outline to a list of ``"x"`` corner points. + + Container shape only — **never geometry**. Every declared point survives, + in order, because the outline is not always an origin-anchored rectangle: + 8 profiles in OrcaSlicer's vendor tree declare 72-point round delta beds, + 3 declare 6 points and 3 declare 239. Reducing here would flatten those + into a rectangle that lies about where a model may be placed, which is + precisely why the sidecar hands the polygon over raw. + + What does get normalised is the packaging, which the profile trees are not + consistent about — one vendor writes the whole polygon as a single + comma-joined string, and one BBL preset ships a stray space inside a point. + The sidecar already normalises both, so in practice this only fires for the + local and orca_cloud tiers, which read profile JSON directly. + + Anything that is not a polygon of at least 3 usable points returns + ``None``. That keeps ``None`` meaning "no bed known" rather than degrading + into a bed whose bounding box happens to be zero-sized — the two must stay + distinguishable, because a consumer treats the first as "fall back to the + default plate" and the second as "place everything at the origin". + """ + if isinstance(value, str): + raw: list[object] = list(value.split(",")) + elif isinstance(value, list): + raw = list(value) + else: + return None + points = [p.strip() for p in raw if isinstance(p, str) and p.strip()] + return points if len(points) >= 3 else None + + async def _fetch_bundled_presets(db: AsyncSession, *, refresh: bool = False) -> dict[str, list[UnifiedPreset]]: """Standard slicer-bundled profiles via the sidecar's /profiles/bundled. @@ -433,7 +500,23 @@ async def _fetch_bundled_presets(db: AsyncSession, *, refresh: bool = False) -> continue # Bundled presets are addressed by name (the slicer resolves them # by name during the `inherits:` walk), so name doubles as id. - extra: dict[str, str | None] = {} + extra: dict[str, Any] = {} + if slot == "printer": + # The printer's bed outline, resolved by the sidecar through + # the bundled profile's `inherits:` chain — a BBL machine + # preset is a per-nozzle delta that declares no bed of its own + # (measured: the leaf states it for 4/44 presets in OrcaSlicer + # v2.3.2 and 7/56 in BambuStudio v02.07.01.57; through the walk + # it resolves for 44/44 and 56/56). + # + # **Only sidecar images built from orca-slicer-api#4 emit it.** + # Every deployment runs an older one until its image is + # rebuilt, so this reads `None` for the entire standard tier + # today and every consumer must cope with that — it is the + # normal state, not an error state. Read it anyway so a + # rebuilt sidecar starts supplying beds without a second + # change here (same pattern as `filament_vendor`, #58). + extra["printable_area"] = _parse_printable_area(entry.get("printable_area")) if slot == "filament": extra["filament_type"] = entry.get("filament_type") extra["filament_colour"] = entry.get("filament_colour") @@ -527,6 +610,16 @@ def _enrich_cloud_metadata( not merely score badly, it would sit in a different heading from the identically-named local copy of the same spool. + Bed merge (#68): ``printable_area`` rides the same name bridge on the + PRINTER slot. Only the standard tier resolves a real bed (the sidecar + walks the bundled profile's ``inherits:`` chain); Bambu Cloud carries no + profile content at all, and an Orca Cloud or local copy of the same + printer is often a delta that states no geometry. Without the bridge, the + bed a printer reports would depend on which tier happened to win dedup — + invisible to the user and not something they chose. There is no + name-parse fallback for this one: a bed cannot be guessed from a string, + and a hardcoded model→bed map was deliberately removed (CHANGELOG:1173). + Finally, every filament entry left without a vendor after the bridge gets one parsed out of its NAME. This runs across all four tiers, not just the standard one: the local tier's vendor column is nullable and rows imported @@ -567,6 +660,34 @@ def _enrich_cloud_metadata( if not p.filament_vendor: p.filament_vendor = _parse_vendor_from_name(p.name) + # Bed bridge (#68). Same name bridge, printer slot: whichever copy of a + # printer preset knows its bed teaches the ones that don't. + # + # This is what keeps the feature working while deployments are mid-upgrade. + # A user picks "Bambu Lab H2D 0.4 nozzle" out of whichever tier ranks + # highest for them — Bambu Cloud never carries profile content at all, and + # an Orca Cloud or local copy may be a delta that only `inherits:` a + # bundled base. The standard tier is the one that resolves a real bed + # (through the sidecar's inheritance walk), and the identically-named entry + # in it is the same physical printer. Without the bridge the bed would + # depend on which tier happened to win dedup, which is not something the + # user chose or can see. + # + # Only ever fills a gap, and only with a real value: an entry that states + # its own bed keeps it, and `None` is never propagated over anything. + beds_by_name: dict[str, list[str]] = {} + for tier in (local, orca_cloud, standard): + for p in tier["printer"]: + if p.printable_area and p.name not in beds_by_name: + beds_by_name[p.name] = p.printable_area + if beds_by_name: + for tier in (local, orca_cloud, cloud, standard): + for p in tier["printer"]: + if p.printable_area is None: + borrowed = beds_by_name.get(p.name) + if borrowed: + p.printable_area = borrowed + # Compatibility bridge (#2628). Runs over both slots that carry the # list, and in both directions between the cloud tiers — whichever copy # of a profile knows its printers teaches the ones that don't. diff --git a/backend/app/schemas/slicer_presets.py b/backend/app/schemas/slicer_presets.py index 99d6215422..b1bc94ebd4 100644 --- a/backend/app/schemas/slicer_presets.py +++ b/backend/app/schemas/slicer_presets.py @@ -51,6 +51,40 @@ class UnifiedPreset(BaseModel): vendor could be resolved at all; the frontend buckets those into a trailing "Other" group rather than scattering them. + ``printable_area`` is populated for the **printer** slot only: the bed + outline the slicer's own profile tree declares for that printer, as a list + of ``"x"`` corner points in bed millimetres — e.g. + ``["0x0", "350x0", "350x320", "0x320"]`` for an H2D. It is **not** a + width/height pair, and it is deliberately carried in the slicer's own raw + shape: 8 profiles in OrcaSlicer's vendor tree declare 72-point round delta + beds, and reducing here would flatten a round or origin-offset bed into a + rectangle that silently lies about where a model may be placed. Consumers + reduce it themselves, to whatever they need (a bounding box for the + viewport, the polygon for a render outline). + + This is the only per-printer geometry Bambuddy has: the ``Printer`` DB + model has no dimensions, ``PRINTER_MODEL_MAP`` has none, and + ``printerPresetAxes.ts`` states outright that the preset NAME is the only + carrier of model and nozzle. A hardcoded model→bed map is not an option — + one existed and was deliberately removed in favour of reading + ``printable_area`` (CHANGELOG:1173). + + Resolution per tier, best source first: + + - standard → the sidecar's ``printable_area``, resolved through the + bundled profile's ``inherits:`` chain (orca-slicer-api#4). **Only the + rebuilt fork images emit it.** + - orca_cloud → the profile content's own ``printable_area`` + - local → the same key out of the stored resolved profile blob + - cloud → borrowed from a same-named entry in another tier; Bambu + Cloud's list response carries no profile content at all + + ``None`` means no bed could be resolved, and every deployment reads + ``None`` for the whole standard tier until its sidecar image is rebuilt — + that is the state each one is in today, and the frontend must keep working + in it. **``None`` is never "a bed of size zero"**: a zero-area bed would + place a model at the origin, a missing one has to fall back to a default. + ``compatible_printers`` is the slicer's own list of printer-preset names a process / filament preset declares itself valid for. Populated for the local tier (stored at import time); left ``None`` for cloud (no per-preset @@ -68,6 +102,7 @@ class UnifiedPreset(BaseModel): filament_colour: str | None = None filament_vendor: str | None = None compatible_printers: list[str] | None = None + printable_area: list[str] | None = None class UnifiedPresetsBySlot(BaseModel): diff --git a/backend/app/services/slicer_api.py b/backend/app/services/slicer_api.py index 8f4d4d1b9d..bc46c19992 100644 --- a/backend/app/services/slicer_api.py +++ b/backend/app/services/slicer_api.py @@ -216,6 +216,30 @@ async def list_bundled_profiles(self) -> dict: tier on both slicers — colour is a runtime spool attribute, not a profile one — so an empty colour here is data, not a degraded response. + Printer entries additionally carry ``printable_area`` (#68): the bed + outline as a list of ``"x"`` corner points in bed millimetres, + e.g. ``["0x0", "350x0", "350x320", "0x320"]``. **Not** a width/height + pair — the raw polygon is passed through so a round or origin-offset + bed is not silently flattened into a rectangle, and Bambuddy reduces + it itself. Resolved through the same ``inherits:`` walk as the filament + fields, and for the same reason: a BBL machine preset is a per-nozzle + delta that declares no bed of its own, so a walk-less sidecar reports + ``null`` for essentially the whole tier (measured on the bundled trees: + the leaf states it for 4/44 presets in OrcaSlicer v2.3.2 and 7/56 in + BambuStudio v02.07.01.57; through the walk, 44/44 and 56/56). + + **Only sidecar images built from orca-slicer-api#4 emit it at all**, so + every deployment reads ``null`` here until its image is rebuilt. That + is the normal state, not an error state, and callers must keep working + in it — ``None`` means "no bed known, fall back to a default plate", + which is a different instruction from a bed of size zero. + + The listing also contains ``type: "machine_model"`` catalogue entries + ("Bambu Lab H2D" with no nozzle suffix) that describe a printer family + rather than a slicing preset. Those declare no bed anywhere in their + chain and report ``null`` — 11 of 55 printer entries on OrcaSlicer, + 14 of 70 on BambuStudio. + Returns an empty-shaped dict when the sidecar is unreachable so the unified-presets endpoint can degrade to "no standard tier" without crashing the modal — cloud + local-imported profiles still render. diff --git a/backend/tests/unit/test_slicer_presets.py b/backend/tests/unit/test_slicer_presets.py index a8d6e84f19..760caa135f 100644 --- a/backend/tests/unit/test_slicer_presets.py +++ b/backend/tests/unit/test_slicer_presets.py @@ -12,13 +12,18 @@ from __future__ import annotations +import json import time from unittest.mock import AsyncMock, MagicMock, patch import pytest from backend.app.api.routes import slicer_presets as sp -from backend.app.schemas.slicer_presets import UnifiedPreset +from backend.app.schemas.slicer_presets import ( + UnifiedPreset, + UnifiedPresetsBySlot, + UnifiedPresetsResponse, +) def _slot(items: list[tuple[str, str, str]]) -> dict[str, list[UnifiedPreset]]: @@ -1068,9 +1073,7 @@ async def test_standard_tier_takes_the_sidecar_field_when_it_ships_one(self): ): slots = await sp._fetch_bundled_presets(MagicMock()) assert slots["filament"][0].filament_vendor == "Overture Actual" - _, _, _, standard = sp._enrich_cloud_metadata( - _empty_tier(), _empty_tier(), _empty_tier(), slots - ) + _, _, _, standard = sp._enrich_cloud_metadata(_empty_tier(), _empty_tier(), _empty_tier(), slots) assert standard["filament"][0].filament_vendor == "Overture Actual" async def test_standard_tier_falls_back_to_the_name_on_todays_sidecar(self): @@ -1092,9 +1095,7 @@ async def test_standard_tier_falls_back_to_the_name_on_todays_sidecar(self): ): slots = await sp._fetch_bundled_presets(MagicMock()) assert slots["filament"][0].filament_vendor is None - _, _, _, standard = sp._enrich_cloud_metadata( - _empty_tier(), _empty_tier(), _empty_tier(), slots - ) + _, _, _, standard = sp._enrich_cloud_metadata(_empty_tier(), _empty_tier(), _empty_tier(), slots) assert standard["filament"][0].filament_vendor == "Overture" def test_bambu_cloud_borrows_vendor_across_the_name_bridge(self): @@ -1135,3 +1136,303 @@ def test_vendor_bridge_does_not_touch_process_or_printer_slots(self): _, _, local, _ = sp._enrich_cloud_metadata(_empty_tier(), _empty_tier(), local, _empty_tier()) assert local["printer"][0].filament_vendor is None assert local["process"][0].filament_vendor is None + + +def _printer_tier(items: list[tuple[str, str, str]], **kw) -> dict[str, list[UnifiedPreset]]: + """Build a tier dict whose printer slot holds (id, name, source) tuples. + Extra keyword args are applied to every preset built.""" + return { + "printer": [UnifiedPreset(id=i, name=n, source=s, **kw) for i, n, s in items], + "process": [], + "filament": [], + } + + +H2D_BED = ["0x0", "350x0", "350x320", "0x320"] +X1C_BED = ["0x0", "256x0", "256x256", "0x256"] + + +class TestParsePrintableArea: + """``_parse_printable_area`` normalises the CONTAINER, never the geometry. + + The bed outline is a polygon of ``"x"`` corner points, and it is not + always an origin-anchored rectangle: 8 profiles in OrcaSlicer's vendor tree + declare 72-point round delta beds. Every declared point has to survive, in + order — a reduction to width/height here would silently turn a round bed + into a square one that lies about where a model may be placed. + """ + + def test_passes_a_rectangle_through_unchanged(self): + assert sp._parse_printable_area(list(H2D_BED)) == H2D_BED + + def test_keeps_every_point_of_a_non_rectangular_bed(self): + hexagon = ["50x0", "150x0", "200x87", "150x173", "50x173", "0x87"] + assert sp._parse_printable_area(list(hexagon)) == hexagon + + def test_keeps_an_origin_offset_bed_where_it_is(self): + """A bed that does not start at 0x0 must not be slid to the origin — + the offset is the difference between a model on the plate and a model + hanging off it.""" + offset = ["10x20", "260x20", "260x276", "10x276"] + assert sp._parse_printable_area(list(offset)) == offset + + def test_splits_a_polygon_written_as_one_comma_joined_string(self): + """OrcaSlicer's Creality tree writes Ender-5 Max's bed as a single + string rather than an array.""" + assert sp._parse_printable_area("0x0,400x0,400x400,0x400") == [ + "0x0", + "400x0", + "400x400", + "0x400", + ] + + def test_trims_stray_whitespace_inside_a_point(self): + """`Bambu Lab X2D 0.4 nozzle` ships with a trailing space inside its + last point in BambuStudio v02.07.01.57.""" + assert sp._parse_printable_area(["0x0", "256x0", "256x256", "0x256 "]) == X1C_BED + + def test_missing_is_none(self): + assert sp._parse_printable_area(None) is None + + def test_empty_list_is_none_not_a_zero_sized_bed(self): + assert sp._parse_printable_area([]) is None + + def test_fewer_than_three_points_is_none_not_a_zero_sized_bed(self): + """Two points is not a polygon. It must read as "no bed known" so the + consumer falls back to a default plate — NOT as a bed whose bounding + box happens to be 10 x 0, which would place everything at the origin. + `None` and "size zero" have to stay distinguishable.""" + assert sp._parse_printable_area(["0x0", "10x0"]) is None + + def test_non_string_junk_is_dropped(self): + assert sp._parse_printable_area([1, 2, 3]) is None + assert sp._parse_printable_area({"x": 350}) is None + assert sp._parse_printable_area(350) is None + + +class TestPrintableAreaPerTier: + """``printable_area`` per tier (#68). + + This is the only per-printer geometry Bambuddy has — the `Printer` model + carries none, `PRINTER_MODEL_MAP` carries none, and the preset NAME is the + only other carrier. Each tier resolves it from a different place and every + one of them can legitimately come back empty: + + - standard → whatever the sidecar emits. **Today it emits nothing**, so + the whole tier reads `None` until each deployment's image is rebuilt. + - orca_cloud → the inline profile content Orca's sync already returns. + - local → the stored resolved profile blob. + - cloud → nothing of its own ever; it borrows across the name bridge. + + Unlike `filament_vendor` there is NO name-parse fallback: a bed cannot be + guessed from a string, and the hardcoded model→bed map that used to exist + was deliberately removed (CHANGELOG:1173). + """ + + @pytest.mark.asyncio + async def test_standard_tier_reads_the_sidecar_field(self): + sp._bundled_cache = None + svc_mock = MagicMock() + svc_mock.list_bundled_profiles = AsyncMock( + return_value={ + "printer": [ + { + "name": "Bambu Lab H2D 0.4 nozzle", + "base_id": "fdm_bbl_3dp_002_common", + "printable_area": list(H2D_BED), + } + ], + "process": [], + "filament": [], + } + ) + svc_mock.__aenter__ = AsyncMock(return_value=svc_mock) + svc_mock.__aexit__ = AsyncMock(return_value=False) + with ( + patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")), + patch.object(sp, "SlicerApiService", return_value=svc_mock), + ): + slots = await sp._fetch_bundled_presets(MagicMock()) + assert slots["printer"][0].printable_area == H2D_BED + + @pytest.mark.asyncio + async def test_old_sidecar_without_the_field_yields_none_and_still_works(self): + """**The state every existing deployment is in.** The sidecar image is + rebuilt by hand, so until that happens `/profiles/bundled` returns the + old `{name, base_id}` shape. The listing must come back intact with the + bed simply absent — not raise, not drop the printer, not invent a bed. + """ + sp._bundled_cache = None + svc_mock = MagicMock() + svc_mock.list_bundled_profiles = AsyncMock( + return_value={ + "printer": [{"name": "Bambu Lab H2D 0.4 nozzle", "base_id": None}], + "process": [{"name": "0.20mm Standard", "base_id": None}], + "filament": [{"name": "Bambu PLA Basic", "base_id": None, "filament_type": "PLA"}], + } + ) + svc_mock.__aenter__ = AsyncMock(return_value=svc_mock) + svc_mock.__aexit__ = AsyncMock(return_value=False) + with ( + patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")), + patch.object(sp, "SlicerApiService", return_value=svc_mock), + ): + slots = await sp._fetch_bundled_presets(MagicMock()) + assert [p.name for p in slots["printer"]] == ["Bambu Lab H2D 0.4 nozzle"] + assert slots["printer"][0].printable_area is None + # Everything else the endpoint already promised is untouched. + assert slots["printer"][0].id == "Bambu Lab H2D 0.4 nozzle" + assert slots["filament"][0].filament_type == "PLA" + assert len(slots["process"]) == 1 + + @pytest.mark.asyncio + async def test_sidecar_junk_degrades_to_none_rather_than_raising(self): + """A sidecar emitting the wrong shape must cost one printer its bed, + not cost the caller the entire Standard tier.""" + sp._bundled_cache = None + svc_mock = MagicMock() + svc_mock.list_bundled_profiles = AsyncMock( + return_value={ + "printer": [ + {"name": "Broken", "base_id": None, "printable_area": {"width": 256}}, + {"name": "Fine", "base_id": None, "printable_area": list(X1C_BED)}, + ], + "process": [], + "filament": [], + } + ) + svc_mock.__aenter__ = AsyncMock(return_value=svc_mock) + svc_mock.__aexit__ = AsyncMock(return_value=False) + with ( + patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")), + patch.object(sp, "SlicerApiService", return_value=svc_mock), + ): + slots = await sp._fetch_bundled_presets(MagicMock()) + assert slots["printer"][0].printable_area is None + assert slots["printer"][1].printable_area == X1C_BED + + @pytest.mark.asyncio + async def test_bed_is_printer_only_and_does_not_leak_to_other_slots(self): + sp._bundled_cache = None + svc_mock = MagicMock() + svc_mock.list_bundled_profiles = AsyncMock( + return_value={ + "printer": [{"name": "P", "base_id": None, "printable_area": list(X1C_BED)}], + "process": [{"name": "Q", "base_id": None, "printable_area": list(H2D_BED)}], + "filament": [{"name": "R", "base_id": None, "printable_area": list(H2D_BED)}], + } + ) + svc_mock.__aenter__ = AsyncMock(return_value=svc_mock) + svc_mock.__aexit__ = AsyncMock(return_value=False) + with ( + patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")), + patch.object(sp, "SlicerApiService", return_value=svc_mock), + ): + slots = await sp._fetch_bundled_presets(MagicMock()) + assert slots["printer"][0].printable_area == X1C_BED + assert slots["process"][0].printable_area is None + assert slots["filament"][0].printable_area is None + + def test_local_tier_reads_the_stored_profile_blob(self): + assert sp._parse_local_printable_area(json.dumps({"name": "My H2D", "printable_area": H2D_BED})) == H2D_BED + + def test_local_tier_corrupt_blob_degrades_to_none(self): + assert sp._parse_local_printable_area("{ not json") is None + assert sp._parse_local_printable_area(None) is None + assert sp._parse_local_printable_area("[]") is None + assert sp._parse_local_printable_area(json.dumps({"name": "No bed"})) is None + + +class TestPrintableAreaBridge: + """The cross-tier name bridge (#68), the same one `filament_vendor` rides. + + Only the standard tier resolves a real bed. A user picking the same + physical printer out of the Bambu Cloud tier must get the same bed, or the + plate size would depend on which tier happened to win dedup — invisible to + the user and not something they chose. + """ + + def test_cloud_borrows_a_bed_from_the_standard_tier(self): + cloud = _printer_tier([("PF1", "Bambu Lab H2D 0.4 nozzle", "cloud")]) + standard = _printer_tier( + [("Bambu Lab H2D 0.4 nozzle", "Bambu Lab H2D 0.4 nozzle", "standard")], + printable_area=list(H2D_BED), + ) + _, cloud, _, _ = sp._enrich_cloud_metadata(_empty_tier(), cloud, _empty_tier(), standard) + assert cloud["printer"][0].printable_area == H2D_BED + + def test_bridge_never_overwrites_a_bed_an_entry_states_itself(self): + """A tier that knows its own geometry is authoritative. Overwriting it + from a same-named entry elsewhere would swap a user's customised bed + for the stock one.""" + local = _printer_tier([("1", "Bambu Lab H2D 0.4 nozzle", "local")], printable_area=list(X1C_BED)) + standard = _printer_tier( + [("Bambu Lab H2D 0.4 nozzle", "Bambu Lab H2D 0.4 nozzle", "standard")], + printable_area=list(H2D_BED), + ) + _, _, local, _ = sp._enrich_cloud_metadata(_empty_tier(), _empty_tier(), local, standard) + assert local["printer"][0].printable_area == X1C_BED + + def test_no_bed_anywhere_stays_none(self): + """Old sidecar across the board. Every tier keeps `None`; nothing is + invented, and in particular no model→bed map is consulted.""" + cloud = _printer_tier([("PF1", "Bambu Lab H2D 0.4 nozzle", "cloud")]) + standard = _printer_tier([("Bambu Lab H2D 0.4 nozzle", "Bambu Lab H2D 0.4 nozzle", "standard")]) + orca, cloud, local, standard = sp._enrich_cloud_metadata(_empty_tier(), cloud, _empty_tier(), standard) + assert cloud["printer"][0].printable_area is None + assert standard["printer"][0].printable_area is None + + def test_a_differently_named_printer_does_not_borrow(self): + """The bridge keys on the exact name. An A1 mini must not inherit an + H2D's 350x320 bed just because both tiers are populated.""" + cloud = _printer_tier([("PF1", "Bambu Lab A1 mini 0.4 nozzle", "cloud")]) + standard = _printer_tier( + [("Bambu Lab H2D 0.4 nozzle", "Bambu Lab H2D 0.4 nozzle", "standard")], + printable_area=list(H2D_BED), + ) + _, cloud, _, _ = sp._enrich_cloud_metadata(_empty_tier(), cloud, _empty_tier(), standard) + assert cloud["printer"][0].printable_area is None + + def test_bed_bridge_does_not_touch_process_or_filament_slots(self): + """A bed is a printer concept. A process preset sharing a printer's + name must not sprout one.""" + standard = { + "printer": [UnifiedPreset(id="p", name="Shared", source="standard", printable_area=list(H2D_BED))], + "process": [UnifiedPreset(id="q", name="Shared", source="standard")], + "filament": [UnifiedPreset(id="r", name="Shared", source="standard")], + } + _, _, _, standard = sp._enrich_cloud_metadata(_empty_tier(), _empty_tier(), _empty_tier(), standard) + assert standard["process"][0].printable_area is None + assert standard["filament"][0].printable_area is None + + +class TestPrintableAreaSerialisation: + """The field has to reach the wire, not just the dataclass. + + ``GET /slicer/presets`` returns ``UnifiedPresetsResponse`` directly, so the + only thing between a resolved bed and the frontend is the schema. + """ + + def test_response_carries_the_bed_through_serialisation(self): + response = UnifiedPresetsResponse( + standard=UnifiedPresetsBySlot( + printer=[ + UnifiedPreset( + id="Bambu Lab H2D 0.4 nozzle", + name="Bambu Lab H2D 0.4 nozzle", + source="standard", + printable_area=list(H2D_BED), + ) + ] + ) + ) + dumped = response.model_dump() + assert dumped["standard"]["printer"][0]["printable_area"] == H2D_BED + + def test_field_defaults_to_none_and_is_always_present(self): + """Every deployment reads this until its sidecar image is rebuilt. The + key must still be emitted so a consumer can distinguish "no bed" from + "old backend that has never heard of beds".""" + dumped = UnifiedPreset(id="p", name="p", source="standard").model_dump() + assert "printable_area" in dumped + assert dumped["printable_area"] is None diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f91658a33a..b7002ee6d1 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1637,6 +1637,23 @@ export interface UnifiedPreset { // the process / filament dropdowns by the selected printer using this when // present (#1325). compatible_printers?: string[] | null; + // Populated for the PRINTER slot only: the bed outline the slicer's own + // profile tree declares, as a list of `"x"` corner points in bed + // millimetres — e.g. `["0x0","350x0","350x320","0x320"]` for an H2D (#68). + // + // **Not a width/height pair.** The raw polygon is carried through because it + // is not always an origin-anchored rectangle — 8 profiles in OrcaSlicer's + // vendor tree declare 72-point round delta beds — so consumers reduce it + // themselves rather than being handed a rectangle that quietly lies about + // where a model may be placed. + // + // Resolved backend-side through the bundled profile's `inherits:` chain by + // the sidecar, which is the only place that walk can happen. **Only rebuilt + // sidecar images emit it**, so this is `null` across the whole standard tier + // until a deployment's image is rebuilt — that is the normal state, and a + // consumer must fall back to its default plate rather than treating a + // missing bed as a bed of size zero. + printable_area?: string[] | null; } export interface UnifiedPresetsBySlot { printer: UnifiedPreset[];