소스 검색

Match slicer presets on what they declare, not what they are named (issue #2982)

The internal slicer picked PETG for a PLA plate and an A1 process for a
P1S. Both come from the sidecar's bundled-profile listing, fixed in the
sidecar repo; this is the consuming half plus the hardening that keeps an
older sidecar degrading rather than breaking.

Standard-tier presets now carry the compatible_printers the sidecar
reports. That list is the only truthful account of which printer a preset
belongs to, because the bundle ships no process preset named after a P1S,
an X1, an X1E or an H2D Pro -- all ten of the P1S's are named "@BBL X1C"
and name the P1S only in that list. Reading the printer out of the preset
NAME therefore made a P1S look like it had no compatible process at all:
all 198 hid behind "Show all" and the auto-pick fell through to an
alphabetically-first 0.06mm Fine @BBL A1 0.2 nozzle the CLI refused. A
P1S now gets 0.20mm Standard @BBL X1C and 73 filaments instead of 4. An
older sidecar reports nothing here, which leaves the name matcher in
place -- degraded as before, not broken.

Material is now a hard partition in the filament pre-pick rather than a
+10 bonus. A preset stating a different material than the plate asks for
is the wrong preset, not a worse one: wrong nozzle temperature, wrong bed
temperature, wrong flow. A preset stating NO material stays eligible --
unknown is not wrong, and 32 shipped profiles genuinely have none. The
same rule reaches the retain path, which held a slot on
printer-compatibility alone and so cemented a wrong-material pick through
every re-pick. A preset the user chose themselves is exempt: printing
PETG on a plate a designer labelled PLA is a legitimate thing to do, and
this rule exists to correct the auto-pick, not to overrule the user.

Two more, both found while tracing this and neither reported:

Among process presets equally valid for the selected printer, the one
nearest a 0.2mm layer height now wins. Within a tier the list is
alphabetical and Bambu's naming puts the finest height first, so every
slice that did not name its own process silently got 0.08mm Extra Fine on
an X1 Carbon and 0.06mm Fine on an A1 mini -- correct presets, nobody's
default. Ties break toward the coarser, faster height; a name with no
readable height is still pickable when it is the only candidate; a
process the 3MF named still wins outright.

H2DP is aliased to H2D Pro, the same shape as the A1M rename in #1649 --
the bundle spells the model one way in preset names and another in the
printer preset, so an H2D Pro classified all 198 processes as another
printer's. Deliberately narrow: H2DP and a plain H2D are different
machines and must not collapse.

A dropdown the printer filter would empty now shows the unfiltered list
instead. That state was reachable for four printer models and told the
user nothing; a visible preset for the wrong printer can be changed, an
empty dropdown cannot.

Verified against live Orca 2.4.2 and BambuStudio 02.08.02.61 sidecars
over the real 1156- and 1792-profile trees: every one of the eight
printer models tested now auto-picks a 0.20mm process for its own
printer, a PLA plate draws a PLA preset and a PETG plate a PETG one.
Each change was confirmed to fail its tests when reverted.
maziggy 1 주 전
부모
커밋
e9daa2124e

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
CHANGELOG.md


+ 23 - 7
backend/app/api/routes/slicer_presets.py

@@ -309,7 +309,10 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset
 
 
 
 
 def _content_compatible_printers(content: dict) -> list[str] | None:
 def _content_compatible_printers(content: dict) -> list[str] | None:
-    """Pull ``compatible_printers`` out of an inline profile content dict.
+    """Pull ``compatible_printers`` out of a profile content dict.
+
+    Serves both callers that have one: an Orca Cloud profile's inline
+    ``content``, and an entry of the sidecar's bundled listing.
 
 
     Orca profiles carry it as a list of printer-preset names (the same shape
     Orca profiles carry it as a list of printer-preset names (the same shape
     ``orca_profiles.py`` stores on import); a single-printer profile may store
     ``orca_profiles.py`` stores on import); a single-printer profile may store
@@ -403,13 +406,26 @@ async def _fetch_bundled_presets(db: AsyncSession, *, refresh: bool = False) ->
                 continue
                 continue
             # Bundled presets are addressed by name (the slicer resolves them
             # Bundled presets are addressed by name (the slicer resolves them
             # by name during the `inherits:` walk), so name doubles as id.
             # by name during the `inherits:` walk), so name doubles as id.
-            extra: dict[str, str | None] = {}
+            preset = UnifiedPreset(id=name, name=name, source="standard")
             if slot == "filament":
             if slot == "filament":
-                extra["filament_type"] = entry.get("filament_type")
-                extra["filament_colour"] = entry.get("filament_colour")
-            slots[slot].append(
-                UnifiedPreset(id=name, name=name, source="standard", **extra),
-            )
+                preset.filament_type = entry.get("filament_type")
+                preset.filament_colour = entry.get("filament_colour")
+            if slot in ("process", "filament"):
+                # The slicer's own compatible-printer list, and the only
+                # truthful answer for several Bambu printers: the bundle ships
+                # no process preset named after a P1S, an X1, an X1E or an H2D
+                # Pro -- each one is served by another model's preset that
+                # names it here. Inferring the printer from the preset NAME
+                # instead read all 198 as belonging to the model in their
+                # `@BBL` tag, so a P1S had zero compatible processes, the
+                # dropdown hid every one of them, and the auto-pick landed on
+                # an A1 0.2-nozzle process the CLI then refused (#2982).
+                #
+                # Older sidecars don't report the field. They return None here,
+                # which leaves the SliceModal on the name matcher for the
+                # standard tier -- degraded exactly as before, not broken.
+                preset.compatible_printers = _content_compatible_printers(entry)
+            slots[slot].append(preset)
 
 
     _bundled_cache = (now, slots)
     _bundled_cache = (now, slots)
     return slots
     return slots

+ 175 - 0
backend/tests/unit/test_bundled_compatible_printers_2982.py

@@ -0,0 +1,175 @@
+"""Standard-tier presets carry the slicer's own ``compatible_printers`` (#2982).
+
+The sidecar's ``/profiles/bundled`` listing used to report only a name and a
+``base_id``, which left the SliceModal inferring a preset's printer from its
+NAME. That inference cannot work for several Bambu printers, because the bundle
+ships no preset named after them: all ten of a P1S's process presets are named
+``@BBL X1C`` and name the P1S only in ``compatible_printers``. Reading the name
+classified every one of them as belonging to an X1 Carbon, so a P1S had zero
+compatible processes, the dropdown hid all 198, and the auto-pick fell through
+to an alphabetically-first ``0.06mm Fine @BBL A1 0.2 nozzle`` that the CLI then
+refused.
+
+These pin the pass-through, including the graceful degrade for a sidecar too
+old to report the field.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.api.routes import slicer_presets as sp
+
+
+def _sidecar(payload: dict) -> MagicMock:
+    svc = MagicMock()
+    svc.list_bundled_profiles = AsyncMock(return_value=payload)
+    svc.__aenter__ = AsyncMock(return_value=svc)
+    svc.__aexit__ = AsyncMock(return_value=False)
+    return svc
+
+
+async def _fetch(payload: dict) -> dict:
+    sp._bundled_cache = None
+    svc = _sidecar(payload)
+    with (
+        patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
+        patch.object(sp, "SlicerApiService", return_value=svc),
+    ):
+        return await sp._fetch_bundled_presets(MagicMock())
+
+
+P1S = "Bambu Lab P1S 0.4 nozzle"
+
+# The real shape of the shipped bundle: a process preset named for one printer
+# that names several others, the P1S among them.
+X1C_PROCESS = {
+    "name": "0.20mm Standard @BBL X1C",
+    "base_id": "fdm_process_single_0.20",
+    "compatible_printers": [
+        "Bambu Lab X1 Carbon 0.4 nozzle",
+        "Bambu Lab X1 0.4 nozzle",
+        P1S,
+        "Bambu Lab X1E 0.4 nozzle",
+    ],
+}
+A1_FILAMENT = {
+    "name": "Bambu ABS @BBL A1",
+    "base_id": "Bambu ABS @base",
+    "compatible_printers": ["Bambu Lab A1 0.4 nozzle", "Bambu Lab A1 0.6 nozzle"],
+    "filament_type": "ABS",
+    "filament_colour": None,
+}
+
+
+def _payload(**slots) -> dict:
+    base: dict = {"printer": [], "process": [], "filament": []}
+    base.update(slots)
+    return base
+
+
+class TestTheProcessSlot:
+    @pytest.mark.asyncio
+    async def test_carries_the_declared_printer_list(self):
+        slots = await _fetch(_payload(process=[X1C_PROCESS]))
+        assert slots["process"][0].compatible_printers == X1C_PROCESS["compatible_printers"]
+
+    @pytest.mark.asyncio
+    async def test_keeps_a_printer_no_preset_is_named_after(self):
+        slots = await _fetch(_payload(process=[X1C_PROCESS]))
+        assert P1S in (slots["process"][0].compatible_printers or [])
+
+    @pytest.mark.asyncio
+    async def test_an_older_sidecar_leaves_the_field_unset(self):
+        """No field is not an empty list: unset means "said nothing", which
+        keeps the name matcher in play, while an empty list would read as
+        "compatible with no printer at all" and hide the preset everywhere."""
+        slots = await _fetch(
+            _payload(process=[{"name": "0.20mm Standard @BBL X1C", "base_id": None}]),
+        )
+        assert slots["process"][0].compatible_printers is None
+
+    @pytest.mark.asyncio
+    async def test_normalises_a_bare_string(self):
+        slots = await _fetch(
+            _payload(process=[{"name": "Solo", "base_id": None, "compatible_printers": P1S}]),
+        )
+        assert slots["process"][0].compatible_printers == [P1S]
+
+    @pytest.mark.asyncio
+    async def test_an_empty_list_reads_as_no_data(self):
+        slots = await _fetch(
+            _payload(process=[{"name": "Solo", "base_id": None, "compatible_printers": []}]),
+        )
+        assert slots["process"][0].compatible_printers is None
+
+    @pytest.mark.asyncio
+    async def test_a_malformed_value_reads_as_no_data(self):
+        slots = await _fetch(
+            _payload(process=[{"name": "Solo", "base_id": None, "compatible_printers": 7}]),
+        )
+        assert slots["process"][0].compatible_printers is None
+
+    @pytest.mark.asyncio
+    async def test_drops_non_string_entries_but_keeps_the_rest(self):
+        slots = await _fetch(
+            _payload(
+                process=[
+                    {"name": "Solo", "base_id": None, "compatible_printers": [P1S, None, 3, "  "]},
+                ],
+            ),
+        )
+        assert slots["process"][0].compatible_printers == [P1S]
+
+
+class TestTheFilamentSlot:
+    @pytest.mark.asyncio
+    async def test_carries_both_the_printer_list_and_the_material(self):
+        slots = await _fetch(_payload(filament=[A1_FILAMENT]))
+        preset = slots["filament"][0]
+        assert preset.compatible_printers == A1_FILAMENT["compatible_printers"]
+        assert preset.filament_type == "ABS"
+
+    @pytest.mark.asyncio
+    async def test_a_colourless_bundled_profile_stays_colourless(self):
+        """True of the whole BBL tree at every inheritance depth — colour is a
+        spool attribute, not a profile one — so this must not be invented."""
+        slots = await _fetch(_payload(filament=[A1_FILAMENT]))
+        assert slots["filament"][0].filament_colour is None
+
+    @pytest.mark.asyncio
+    async def test_an_unresolvable_material_stays_none(self):
+        """32 shipped filament profiles inherit from a parent the bundle does
+        not contain, so the sidecar reports no material for them. They must
+        still be listed — the picker treats "unknown" as eligible."""
+        slots = await _fetch(
+            _payload(
+                filament=[
+                    {
+                        "name": "PolyLite PLA @BBL H2S",
+                        "base_id": "PolyLite PLA @base",
+                        "filament_type": None,
+                        "compatible_printers": ["Bambu Lab H2S 0.4 nozzle"],
+                    },
+                ],
+            ),
+        )
+        assert len(slots["filament"]) == 1
+        assert slots["filament"][0].filament_type is None
+        assert slots["filament"][0].compatible_printers == ["Bambu Lab H2S 0.4 nozzle"]
+
+
+class TestThePrinterSlot:
+    @pytest.mark.asyncio
+    async def test_printer_presets_carry_no_compatibility_of_their_own(self):
+        """A printer is what compatibility is measured against; a list on one
+        would be meaningless, and the SliceModal never filters that dropdown."""
+        slots = await _fetch(
+            _payload(
+                printer=[
+                    {"name": P1S, "base_id": None, "compatible_printers": ["nonsense"]},
+                ],
+            ),
+        )
+        assert slots["printer"][0].compatible_printers is None
+        assert slots["printer"][0].name == P1S

+ 243 - 0
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -2442,3 +2442,246 @@ describe('pickFilamentForSlot — long-form printer tag (#2628)', () => {
     expect(pick).toEqual({ source: 'cloud', id: 'sunlu-tpu-h2d' });
     expect(pick).toEqual({ source: 'cloud', id: 'sunlu-tpu-h2d' });
   });
   });
 });
 });
+
+describe('SliceModal — material and printer filtering (#2982)', () => {
+  const A1 = 'Bambu Lab A1 0.4 nozzle';
+  const P1S = 'Bambu Lab P1S 0.4 nozzle';
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {}, reason: 'ok' });
+    mockApi.getSlicerPrinterModels.mockResolvedValue({
+      'Bambu Lab A1': 'A1',
+      'Bambu Lab P1S': 'P1S',
+      'Bambu Lab X1 Carbon': 'X1C',
+    });
+    mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
+    mockApi.getLibraryFilePlates.mockResolvedValue({ file_id: 100, filename: 'Plate.3mf', plates: [] });
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+    mockApi.getSliceJob.mockResolvedValue({
+      job_id: 42,
+      status: 'running',
+      kind: 'library_file',
+      source_id: 100,
+      source_name: 'Plate.3mf',
+      created_at: new Date().toISOString(),
+      started_at: null,
+      completed_at: null,
+    });
+  });
+
+  // One PLA plate slot — the case the report is about.
+  function plaPlate() {
+    return {
+      file_id: 100,
+      filename: 'Plate.3mf',
+      plate_id: 1,
+      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, used_meters: 3 }],
+    };
+  }
+
+  function presetsFor(printer: string, filaments: UnifiedPresetsResponse['standard']['filament']) {
+    return makeUnified({
+      standard: {
+        printer: [{ id: printer, name: printer, source: 'standard' }],
+        process: [
+          {
+            id: '0.20mm Standard @BBL X1C',
+            name: '0.20mm Standard @BBL X1C',
+            source: 'standard',
+            compatible_printers: [P1S, 'Bambu Lab X1 Carbon 0.4 nozzle'],
+          },
+        ],
+        filament: filaments,
+      },
+    });
+  }
+
+  it('does not auto-pick a stated PETG for a PLA plate', async () => {
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(plaPlate());
+    mockApi.getSlicerPresets.mockResolvedValue(
+      presetsFor(A1, [
+        { id: 'petg', name: 'eSUN PETG Basic @BBL A1', source: 'standard', filament_type: 'PETG', filament_colour: '#FF0000' },
+        { id: 'pla', name: 'Bambu PLA Basic @BBL A1', source: 'standard', filament_type: 'PLA', filament_colour: '#FFFFFF' },
+      ]),
+    );
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Plate.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText(A1)).toBeDefined());
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
+      expect(body.filament_presets).toEqual([{ source: 'standard', id: 'pla' }]);
+    });
+  });
+
+  it('drops a wrong-material pick once the listing learns the material', async () => {
+    // The sequence a user upgrading their sidecar actually goes through. The
+    // first listing reports no material for anything, so the pre-pick has
+    // only colour to go on and lands on the PETG. The second reports the
+    // materials — and the wrong pick has to go, which it did not, because the
+    // slot was held on printer-compatibility alone.
+    const colourless = presetsFor(A1, [
+      { id: 'petg', name: 'eSUN PETG Basic @BBL A1', source: 'standard', filament_type: null, filament_colour: '#FF0000' },
+      { id: 'pla', name: 'Bambu PLA Basic @BBL A1', source: 'standard', filament_type: null, filament_colour: '#FFFFFF' },
+    ]);
+    const typed = presetsFor(A1, [
+      { id: 'petg', name: 'eSUN PETG Basic @BBL A1', source: 'standard', filament_type: 'PETG', filament_colour: '#FF0000' },
+      { id: 'pla', name: 'Bambu PLA Basic @BBL A1', source: 'standard', filament_type: 'PLA', filament_colour: '#FFFFFF' },
+    ]);
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(plaPlate());
+    mockApi.getSlicerPresets.mockResolvedValueOnce(colourless).mockResolvedValue(typed);
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Plate.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText(A1)).toBeDefined());
+    const user = userEvent.setup();
+
+    // The colour-matched PETG is what the first listing produces.
+    await waitFor(() => {
+      const select = presetSelects().find((el) => el.value.startsWith('standard:p'));
+      expect(select?.value).toBe('standard:petg');
+    });
+
+    await user.click(screen.getByRole('button', { name: 'Refresh' }));
+
+    await waitFor(() => {
+      const select = presetSelects().find((el) => el.value.startsWith('standard:p'));
+      expect(select?.value).toBe('standard:pla');
+    });
+  });
+
+  it('keeps a wrong-material preset the user picked on purpose', async () => {
+    // Printing PETG on a plate a designer labelled PLA is a legitimate thing
+    // to do. The material rule corrects the auto-pick; it must not overrule
+    // a choice made in the dropdown.
+    const pla = { id: 'pla', name: 'Bambu PLA Basic @BBL A1', source: 'standard' as const, filament_type: 'PLA', filament_colour: '#FF0000' };
+    const petg = { id: 'petg', name: 'eSUN PETG Basic @BBL A1', source: 'standard' as const, filament_type: 'PETG', filament_colour: '#FFFFFF' };
+    const matte = { id: 'matte', name: 'Bambu PLA Matte @BBL A1', source: 'standard' as const, filament_type: 'PLA', filament_colour: '#00FF00' };
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(plaPlate());
+    // The refresh has to return a *different* listing, or React Query's
+    // structural sharing hands back the same object and the pre-pick never
+    // re-runs — which would make this test pass without proving anything.
+    mockApi.getSlicerPresets
+      .mockResolvedValueOnce(presetsFor(A1, [pla, petg]))
+      .mockResolvedValue(presetsFor(A1, [pla, petg, matte]));
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Plate.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText(A1)).toBeDefined());
+
+    const user = userEvent.setup();
+    const filamentSelect = presetSelects().find((el) =>
+      Array.from(el.options).some((o) => o.textContent?.includes('eSUN PETG Basic')),
+    );
+    expect(filamentSelect).toBeDefined();
+    await user.selectOptions(filamentSelect!, 'standard:petg');
+
+    // Refreshing re-runs the pre-pick over the same slots. That is exactly
+    // where an un-exempted material rule would quietly undo the choice.
+    await user.click(screen.getByRole('button', { name: 'Refresh' }));
+
+    await waitFor(() => {
+      const select = presetSelects().find((el) => el.value.startsWith('standard:p'));
+      expect(select?.value).toBe('standard:petg');
+    });
+
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => {
+      const [, body] = mockApi.sliceLibraryFile.mock.calls[0];
+      expect(body.filament_presets).toEqual([{ source: 'standard', id: 'petg' }]);
+    });
+  });
+
+  it('shows every process when the printer filter would leave the list empty', async () => {
+    // A P1S against a sidecar too old to report compatible_printers: all 198
+    // processes read as another printer's, so the dropdown held one
+    // auto-picked entry and a "Show all" link, with nothing saying the list
+    // itself was the problem.
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(plaPlate());
+    mockApi.getSlicerPresets.mockResolvedValue(
+      makeUnified({
+        standard: {
+          printer: [{ id: P1S, name: P1S, source: 'standard' }],
+          process: [
+            { id: 'x1c', name: '0.20mm Standard @BBL X1C', source: 'standard' },
+            { id: 'a1', name: '0.06mm Fine @BBL A1 0.2 nozzle', source: 'standard' },
+          ],
+          filament: [
+            { id: 'pla', name: 'Bambu PLA Basic @BBL A1', source: 'standard', filament_type: 'PLA' },
+          ],
+        },
+      }),
+    );
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Plate.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText(P1S)).toBeDefined());
+
+    const processSelect = presetSelects().find((el) =>
+      Array.from(el.options).some((o) => o.textContent?.includes('0.20mm Standard @BBL X1C')),
+    );
+    expect(processSelect).toBeDefined();
+    // Both are visible rather than one hidden behind "Show all".
+    const labels = Array.from(processSelect!.options).map((o) => o.textContent);
+    expect(labels.some((l) => l?.includes('0.20mm Standard @BBL X1C'))).toBe(true);
+    expect(labels.some((l) => l?.includes('0.06mm Fine @BBL A1 0.2 nozzle'))).toBe(true);
+  });
+
+  it('still hides other-printer presets when some do match', async () => {
+    // The guard is for an empty list only — the normal filter must survive.
+    mockApi.getLibraryFileFilamentRequirements.mockResolvedValue(plaPlate());
+    mockApi.getSlicerPresets.mockResolvedValue(
+      makeUnified({
+        standard: {
+          printer: [{ id: P1S, name: P1S, source: 'standard' }],
+          process: [
+            {
+              id: 'x1c',
+              name: '0.20mm Standard @BBL X1C',
+              source: 'standard',
+              compatible_printers: [P1S],
+            },
+            { id: 'a1', name: '0.06mm Fine @BBL A1 0.2 nozzle', source: 'standard' },
+          ],
+          filament: [
+            { id: 'pla', name: 'Bambu PLA Basic @BBL A1', source: 'standard', filament_type: 'PLA' },
+          ],
+        },
+      }),
+    );
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Plate.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByText(P1S)).toBeDefined());
+
+    const processSelect = presetSelects().find((el) =>
+      Array.from(el.options).some((o) => o.textContent?.includes('0.20mm Standard @BBL X1C')),
+    );
+    const labels = Array.from(processSelect!.options).map((o) => o.textContent);
+    expect(labels.some((l) => l?.includes('0.06mm Fine @BBL A1 0.2 nozzle'))).toBe(false);
+  });
+});

+ 266 - 0
frontend/src/__tests__/utils/slicePresetPicker2982.test.ts

@@ -0,0 +1,266 @@
+import { describe, expect, it } from 'vitest';
+
+import type { UnifiedPreset, UnifiedPresetsBySlot, UnifiedPresetsResponse } from '../../api/client';
+import {
+  pickFilamentForSlot,
+  pickProcessDefault,
+  statesDifferentMaterial,
+} from '../../utils/slicePresetPicker';
+import { buildCompatibilityIndex } from '../../utils/slicerPrinterMatch';
+
+// The production registry, trimmed to the models these cases name.
+const index = buildCompatibilityIndex({
+  'Bambu Lab X1 Carbon': 'X1C',
+  'Bambu Lab P1S': 'P1S',
+  'Bambu Lab A1': 'A1',
+  'Bambu Lab A1 mini': 'A1 Mini',
+});
+
+const P1S = 'Bambu Lab P1S 0.4 nozzle';
+const X1C = 'Bambu Lab X1 Carbon 0.4 nozzle';
+const A1 = 'Bambu Lab A1 0.4 nozzle';
+
+function empty(): UnifiedPresetsBySlot {
+  return { printer: [], process: [], filament: [] };
+}
+
+function unified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
+  return {
+    orca_cloud: empty(),
+    cloud: empty(),
+    local: empty(),
+    standard: empty(),
+    cloud_status: 'ok',
+    orca_cloud_status: 'ok',
+    ...overrides,
+  };
+}
+
+function standard(slot: 'process' | 'filament', entries: Partial<UnifiedPreset>[]): UnifiedPresetsResponse {
+  const list = entries.map((e) => ({
+    id: e.name as string,
+    source: 'standard' as const,
+    ...e,
+  })) as UnifiedPreset[];
+  return unified({ standard: { ...empty(), [slot]: list } });
+}
+
+describe('statesDifferentMaterial', () => {
+  it('is true only when both sides state a material and they differ', () => {
+    expect(statesDifferentMaterial({ filament_type: 'PETG' }, 'PLA')).toBe(true);
+  });
+
+  it('is false for the same material, whatever the casing or padding', () => {
+    expect(statesDifferentMaterial({ filament_type: ' pla ' }, 'PLA')).toBe(false);
+  });
+
+  it('is false when the preset states no material', () => {
+    // 32 of the shipped BBL filament profiles inherit from a parent the bundle
+    // does not contain, so their material genuinely cannot be resolved.
+    // "Unknown" has to stay eligible or those slots get nothing.
+    expect(statesDifferentMaterial({ filament_type: null }, 'PLA')).toBe(false);
+    expect(statesDifferentMaterial({ filament_type: '' }, 'PLA')).toBe(false);
+  });
+
+  it('is false when the plate asks for no particular material', () => {
+    expect(statesDifferentMaterial({ filament_type: 'PETG' }, '')).toBe(false);
+  });
+});
+
+describe('pickFilamentForSlot — material is a hard partition (#2982)', () => {
+  it('never picks a stated PETG for a PLA plate, however well the colour fits', () => {
+    // The reported symptom, in the state the fixed sidecar produces: with a
+    // material on both presets the +10 type bonus decides it. What made the
+    // PETG win was the standard tier reporting a null material for all 1156
+    // presets it listed, which left colour and tier the only signals in play.
+    const presets = standard('filament', [
+      { name: 'eSUN PETG Basic @BBL A1', filament_type: 'PETG', filament_colour: '#FF0000' },
+      { name: 'Bambu PLA Basic @BBL A1', filament_type: 'PLA', filament_colour: '#FFFFFF' },
+    ]);
+    const pick = pickFilamentForSlot(presets, { type: 'PLA', color: '#FF0000' }, A1, index);
+    expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL A1' });
+  });
+
+  it('prefers the right material over a higher tier offering the wrong one', () => {
+    const presets = unified({
+      local: {
+        ...empty(),
+        filament: [
+          { id: 'my-petg', name: 'My PETG @BBL A1', source: 'local', filament_type: 'PETG', filament_colour: '#FF0000' },
+        ],
+      },
+      standard: {
+        ...empty(),
+        filament: [
+          { id: 'Bambu PLA Basic @BBL A1', name: 'Bambu PLA Basic @BBL A1', source: 'standard', filament_type: 'PLA', filament_colour: '#FF0000' },
+        ],
+      },
+    });
+    const pick = pickFilamentForSlot(presets, { type: 'PLA', color: '#FF0000' }, A1, index);
+    expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL A1' });
+  });
+
+  it('still picks a preset that states no material at all', () => {
+    // Ordering that only the hard partition produces: on score alone the PETG
+    // wins here, because an unknown material earns no type bonus either.
+    const presets = standard('filament', [
+      { name: 'Mystery @BBL A1', filament_type: null },
+      { name: 'eSUN PETG Basic @BBL A1', filament_type: 'PETG', filament_colour: '#FFFFFF' },
+    ]);
+    const pick = pickFilamentForSlot(presets, { type: 'PLA', color: '#FFFFFF' }, A1, index);
+    expect(pick).toEqual({ source: 'standard', id: 'Mystery @BBL A1' });
+  });
+
+  it('prefers the right material on the right printer over the right material on the wrong one', () => {
+    const presets = standard('filament', [
+      { name: 'Bambu PLA Basic @BBL X1C', filament_type: 'PLA', filament_colour: '#FF0000' },
+      { name: 'Bambu PLA Basic @BBL A1', filament_type: 'PLA', filament_colour: '#FFFFFF' },
+    ]);
+    const pick = pickFilamentForSlot(presets, { type: 'PLA', color: '#FF0000' }, A1, index);
+    expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL A1' });
+  });
+
+  it('falls back to a wrong-material preset rather than leaving the slot empty', () => {
+    // A registry with nothing of the asked-for material still has to fill the
+    // slot: a visible wrong preset can be changed in the dropdown, a null
+    // renders as an empty slot with nothing to act on.
+    const presets = standard('filament', [
+      { name: 'eSUN PETG Basic @BBL A1', filament_type: 'PETG', filament_colour: '#FFFFFF' },
+    ]);
+    const pick = pickFilamentForSlot(presets, { type: 'PLA', color: '#FFFFFF' }, A1, index);
+    expect(pick).toEqual({ source: 'standard', id: 'eSUN PETG Basic @BBL A1' });
+  });
+
+  it('prefers a right-material preset for the wrong printer over a wrong-material one for the right printer', () => {
+    const presets = standard('filament', [
+      { name: 'Bambu PLA Basic @BBL X1C', filament_type: 'PLA', filament_colour: '#FFFFFF' },
+      { name: 'eSUN PETG Basic @BBL A1', filament_type: 'PETG', filament_colour: '#FFFFFF' },
+    ]);
+    const pick = pickFilamentForSlot(presets, { type: 'PLA', color: '#FFFFFF' }, A1, index);
+    expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL X1C' });
+  });
+
+  it('keeps colour as the tie-breaker within one material', () => {
+    const presets = standard('filament', [
+      { name: 'Bambu PLA Matte @BBL A1', filament_type: 'PLA', filament_colour: '#FFFFFF' },
+      { name: 'Bambu PLA Basic @BBL A1', filament_type: 'PLA', filament_colour: '#FF0000' },
+    ]);
+    const pick = pickFilamentForSlot(presets, { type: 'PLA', color: '#FF0000' }, A1, index);
+    expect(pick).toEqual({ source: 'standard', id: 'Bambu PLA Basic @BBL A1' });
+  });
+});
+
+describe('pickProcessDefault — compatible_printers reaches the standard tier (#2982)', () => {
+  // Verbatim from the shipped bundle: every P1S process is named for an X1C
+  // and names the P1S only here.
+  const x1cStandard = {
+    name: '0.20mm Standard @BBL X1C',
+    compatible_printers: [X1C, 'Bambu Lab X1 0.4 nozzle', P1S],
+  };
+
+  it('picks an X1C-named process for a P1S when it declares the P1S', () => {
+    const presets = standard('process', [
+      { name: '0.06mm Fine @BBL A1 0.2 nozzle', compatible_printers: ['Bambu Lab A1 0.2 nozzle'] },
+      x1cStandard,
+    ]);
+    const pick = pickProcessDefault(presets, P1S, index, null);
+    expect(pick).toEqual({ source: 'standard', id: '0.20mm Standard @BBL X1C' });
+  });
+
+  it('does not offer an A1 process to a P1S', () => {
+    // What shipped: with no declared list the name matcher read all 198 as
+    // mismatches, and the fall-through returned the alphabetically first.
+    const presets = standard('process', [
+      { name: '0.06mm Fine @BBL A1 0.2 nozzle', compatible_printers: ['Bambu Lab A1 0.2 nozzle'] },
+      x1cStandard,
+    ]);
+    const pick = pickProcessDefault(presets, P1S, index, null);
+    expect(pick?.id).not.toBe('0.06mm Fine @BBL A1 0.2 nozzle');
+  });
+
+  it('lets a declared list overrule the printer the name implies', () => {
+    const presets = standard('process', [x1cStandard]);
+    expect(pickProcessDefault(presets, P1S, index, null)).toEqual({
+      source: 'standard',
+      id: '0.20mm Standard @BBL X1C',
+    });
+    expect(pickProcessDefault(presets, A1, index, null)).toEqual({
+      source: 'standard',
+      id: '0.20mm Standard @BBL X1C',
+    });
+  });
+});
+
+describe('pickProcessDefault — layer height within equally-valid candidates (#2982)', () => {
+  const heights = (names: string[]) =>
+    standard('process', names.map((name) => ({ name, compatible_printers: [X1C] })));
+
+  it('prefers 0.20mm Standard over the alphabetically first 0.08mm', () => {
+    // Tier order says which source to prefer; within a tier the list is
+    // alphabetical, and Bambu's naming puts the finest — slowest — height
+    // first. Every X1C slice that did not name its own process silently got
+    // 0.08mm Extra Fine.
+    const presets = heights([
+      '0.08mm Extra Fine @BBL X1C',
+      '0.12mm Fine @BBL X1C',
+      '0.20mm Standard @BBL X1C',
+      '0.28mm Extra Draft @BBL X1C',
+    ]);
+    expect(pickProcessDefault(presets, X1C, index, null)?.id).toBe('0.20mm Standard @BBL X1C');
+  });
+
+  it('takes the nearest height when nothing sits exactly at 0.2mm', () => {
+    const presets = heights(['0.08mm Extra Fine @BBL X1C', '0.16mm Optimal @BBL X1C']);
+    expect(pickProcessDefault(presets, X1C, index, null)?.id).toBe('0.16mm Optimal @BBL X1C');
+  });
+
+  it('breaks an equal distance toward the coarser height', () => {
+    // 0.16 and 0.24 are both 0.04 away. The coarser one prints faster, which
+    // is the friendlier default to be wrong in.
+    const presets = heights(['0.16mm Optimal @BBL X1C', '0.24mm Draft @BBL X1C']);
+    expect(pickProcessDefault(presets, X1C, index, null)?.id).toBe('0.24mm Draft @BBL X1C');
+  });
+
+  it('still picks a preset whose name carries no height', () => {
+    const presets = heights(['My Favourite Process']);
+    expect(pickProcessDefault(presets, X1C, index, null)?.id).toBe('My Favourite Process');
+  });
+
+  it('prefers a readable height over a name with none', () => {
+    const presets = heights(['A Nameless Process', '0.20mm Standard @BBL X1C']);
+    expect(pickProcessDefault(presets, X1C, index, null)?.id).toBe('0.20mm Standard @BBL X1C');
+  });
+
+  it('does not let layer height override tier order', () => {
+    const presets = unified({
+      local: {
+        ...empty(),
+        process: [
+          { id: 'mine', name: '0.08mm Mine @BBL X1C', source: 'local', compatible_printers: [X1C] },
+        ],
+      },
+      standard: {
+        ...empty(),
+        process: [
+          { id: 'std', name: '0.20mm Standard @BBL X1C', source: 'standard', compatible_printers: [X1C] },
+        ],
+      },
+    });
+    expect(pickProcessDefault(presets, X1C, index, null)).toEqual({ source: 'local', id: 'mine' });
+  });
+
+  it('does not override a process the 3MF named', () => {
+    const presets = heights(['0.08mm Extra Fine @BBL X1C', '0.20mm Standard @BBL X1C']);
+    const pick = pickProcessDefault(presets, X1C, index, '0.08mm Extra Fine @BBL X1C');
+    expect(pick?.id).toBe('0.08mm Extra Fine @BBL X1C');
+  });
+
+  it('does not let a coarse mismatch beat a compatible fine one', () => {
+    // Compatibility is decided before height is ever consulted.
+    const presets = standard('process', [
+      { name: '0.08mm Extra Fine @BBL X1C', compatible_printers: [X1C] },
+      { name: '0.28mm Extra Draft @BBL A1', compatible_printers: [A1] },
+    ]);
+    expect(pickProcessDefault(presets, X1C, index, null)?.id).toBe('0.08mm Extra Fine @BBL X1C');
+  });
+});

+ 44 - 0
frontend/src/__tests__/utils/slicerPrinterMatch.test.ts

@@ -333,6 +333,50 @@ describe('matchesPrinterModelSuffix (#1649)', () => {
   });
   });
 });
 });
 
 
+describe('H2D Pro spelled H2DP in preset names (#2982)', () => {
+  const H2D_PRO = 'Bambu Lab H2D Pro 0.4 nozzle';
+  const H2D = 'Bambu Lab H2D 0.4 nozzle';
+  const idx = buildCompatibilityIndex(PRINTER_MODELS);
+
+  it('matches the preset-name spelling against the printer-name spelling', () => {
+    // The bundle names every H2D Pro process and filament "@BBL H2DP"; the
+    // printer preset, and PRINTER_MODEL_MAP with it, says "H2D Pro". Without
+    // the alias an H2D Pro read all 198 bundled processes as another
+    // printer's, and got an A1 process auto-picked.
+    expect(matchesPrinterModelSuffix('H2DP', 'H2D Pro')).toBe(true);
+    expect(matchesPrinterModelSuffix('H2D Pro', 'H2DP')).toBe(true);
+  });
+
+  it('does NOT collapse H2DP into the plain H2D', () => {
+    // Different machines. The nozzle-count and build-volume differences make
+    // their presets genuinely non-interchangeable.
+    expect(matchesPrinterModelSuffix('H2DP', 'H2D')).toBe(false);
+    expect(matchesPrinterModelSuffix('H2D', 'H2D Pro')).toBe(false);
+  });
+
+  it('classifies an @BBL H2DP process as compatible with an H2D Pro', () => {
+    expect(
+      presetCompatibility(
+        { name: '0.20mm Balanced Strength @BBL H2DP' },
+        'process',
+        H2D_PRO,
+        idx,
+      ),
+    ).toBe('match');
+  });
+
+  it('still classifies an @BBL H2DP process as a mismatch for a plain H2D', () => {
+    expect(
+      presetCompatibility(
+        { name: '0.20mm Balanced Strength @BBL H2DP' },
+        'process',
+        H2D,
+        idx,
+      ),
+    ).toBe('mismatch');
+  });
+});
+
 describe('presetCompatibility with Bambu cloud A1M rename (#1649)', () => {
 describe('presetCompatibility with Bambu cloud A1M rename (#1649)', () => {
   const A1_MINI = 'Bambu Lab A1 mini 0.4 nozzle';
   const A1_MINI = 'Bambu Lab A1 mini 0.4 nozzle';
   const A1 = 'Bambu Lab A1 0.4 nozzle';
   const A1 = 'Bambu Lab A1 0.4 nozzle';

+ 53 - 8
frontend/src/components/SliceModal.tsx

@@ -1,5 +1,5 @@
 import { Cloud, CloudOff, Cog, Loader2, RefreshCw, X } from 'lucide-react';
 import { Cloud, CloudOff, Cog, Loader2, RefreshCw, X } from 'lucide-react';
-import { useEffect, useId, useMemo, useState } from 'react';
+import { useEffect, useId, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import {
 import {
@@ -32,6 +32,7 @@ import {
   pickDefault,
   pickDefault,
   pickFilamentForSlot,
   pickFilamentForSlot,
   pickProcessDefault,
   pickProcessDefault,
+  statesDifferentMaterial,
   type Slot,
   type Slot,
 } from '../utils/slicePresetPicker';
 } from '../utils/slicePresetPicker';
 
 
@@ -218,6 +219,12 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // entry per AMS slot the plate uses. Pre-pick (effect below) initialises
   // entry per AMS slot the plate uses. Pre-pick (effect below) initialises
   // each slot from the source plate's required (type, colour).
   // each slot from the source plate's required (type, colour).
   const [filamentPresets, setFilamentPresets] = useState<(PresetRef | null)[]>([]);
   const [filamentPresets, setFilamentPresets] = useState<(PresetRef | null)[]>([]);
+  // Slots the user chose a filament for by hand, or by applying a pipeline.
+  // The pre-pick below re-picks a slot whose preset states the wrong material,
+  // and without this it would do that to a deliberate choice too — printing
+  // PETG on a plate a designer labelled PLA is a thing people do on purpose. A
+  // ref rather than state: this must not itself re-trigger the pre-pick.
+  const explicitFilamentSlots = useRef<Set<number>>(new Set());
   // Per-slot colour override, plate-slot-ordered alongside `filamentPresets`.
   // Per-slot colour override, plate-slot-ordered alongside `filamentPresets`.
   // `null` means "not overridden" rather than "no colour": the slot then falls
   // `null` means "not overridden" rather than "no colour": the slot then falls
   // through to the source plate's own colour, and — when the source has none
   // through to the source plate's own colour, and — when the source has none
@@ -579,7 +586,20 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
         const cur = current[i] ?? null;
         const cur = current[i] ?? null;
         if (cur) {
         if (cur) {
           const p = findPreset(data, cur, 'filament');
           const p = findPreset(data, cur, 'filament');
-          if (p && presetCompatibility(p, 'filament', selectedPrinterName, compatIndex) !== 'mismatch') {
+          // Compatible with the printer is not on its own a reason to keep it:
+          // a preset that states a different material than the plate asks for
+          // is the wrong preset however well it fits the machine, and holding
+          // onto one is how a PETG profile survived on a PLA plate through
+          // every re-pick (#2982). An explicit choice is exempt — the material
+          // rule exists to correct the auto-pick, not to overrule the user.
+          const wrongMaterial = p
+            && !explicitFilamentSlots.current.has(i)
+            && statesDifferentMaterial(p, slot.type);
+          if (
+            p
+            && !wrongMaterial
+            && presetCompatibility(p, 'filament', selectedPrinterName, compatIndex) !== 'mismatch'
+          ) {
             return cur;
             return cur;
           }
           }
         }
         }
@@ -598,9 +618,14 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   // onto whatever the new plate happens to call slot 2. Same slot count keeps
   // onto whatever the new plate happens to call slot 2. Same slot count keeps
   // them: that is a re-pick of presets, not a different plate layout.
   // them: that is a re-pick of presets, not a different plate layout.
   useEffect(() => {
   useEffect(() => {
-    setFilamentColours((current) =>
-      current.length === filamentSlots.length ? current : filamentSlots.map(() => null),
-    );
+    setFilamentColours((current) => {
+      if (current.length === filamentSlots.length) return current;
+      // A renumbered slot list invalidates the record of which slots the user
+      // chose for the same reason it invalidates the colours: slot 2 of the new
+      // plate is not slot 2 of the old one.
+      explicitFilamentSlots.current = new Set();
+      return filamentSlots.map(() => null);
+    });
   }, [filamentSlots]);
   }, [filamentSlots]);
 
 
   const enqueueMutation = useMutation({
   const enqueueMutation = useMutation({
@@ -817,6 +842,10 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                       for (let i = 0; i < next.length; i++) {
                       for (let i = 0; i < next.length; i++) {
                         if (i < picked.filament_presets.length) {
                         if (i < picked.filament_presets.length) {
                           next[i] = picked.filament_presets[i];
                           next[i] = picked.filament_presets[i];
+                          // Applying a pipeline is as deliberate as picking
+                          // from the dropdown, so the slots it fills are
+                          // exempt from the material re-pick too.
+                          explicitFilamentSlots.current.add(i);
                         }
                         }
                       }
                       }
                       return next;
                       return next;
@@ -1034,15 +1063,16 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                       slot="filament"
                       slot="filament"
                       data={presetsQuery.data}
                       data={presetsQuery.data}
                       value={filamentPresets[idx] ?? null}
                       value={filamentPresets[idx] ?? null}
-                      onChange={(ref) =>
+                      onChange={(ref) => {
+                        explicitFilamentSlots.current.add(idx);
                         setFilamentPresets((current) => {
                         setFilamentPresets((current) => {
                           const next = current.length === filamentSlots.length
                           const next = current.length === filamentSlots.length
                             ? [...current]
                             ? [...current]
                             : filamentSlots.map((_, i) => current[i] ?? null);
                             : filamentSlots.map((_, i) => current[i] ?? null);
                           next[idx] = ref;
                           next[idx] = ref;
                           return next;
                           return next;
-                        })
-                      }
+                        });
+                      }}
                       disabled={isEnqueuing || !isUsed || useEmbedded}
                       disabled={isEnqueuing || !isUsed || useEmbedded}
                       // Shown for every filament slot now that it is editable,
                       // Shown for every filament slot now that it is editable,
                       // not just multi-color ones: a single-slot STL is exactly
                       // not just multi-color ones: a single-slot STL is exactly
@@ -1389,9 +1419,13 @@ function PresetDropdown({
     ];
     ];
     const filterByPrinter = slot !== 'printer';
     const filterByPrinter = slot !== 'printer';
     const compatSections: { tierLabel: string; entries: UnifiedPreset[] }[] = [];
     const compatSections: { tierLabel: string; entries: UnifiedPreset[] }[] = [];
+    // The same sections with the printer filter never applied, kept for the
+    // all-filtered-out case below.
+    const unfiltered: { tierLabel: string; entries: UnifiedPreset[] }[] = [];
     const other: UnifiedPreset[] = [];
     const other: UnifiedPreset[] = [];
     for (const { key, label: lk, fallback } of tiers) {
     for (const { key, label: lk, fallback } of tiers) {
       const entries = (data[key] as UnifiedPresetsBySlot)[slot];
       const entries = (data[key] as UnifiedPresetsBySlot)[slot];
+      if (entries.length > 0) unfiltered.push({ tierLabel: t(lk, fallback), entries });
       if (!filterByPrinter) {
       if (!filterByPrinter) {
         if (entries.length > 0) compatSections.push({ tierLabel: t(lk, fallback), entries });
         if (entries.length > 0) compatSections.push({ tierLabel: t(lk, fallback), entries });
         continue;
         continue;
@@ -1416,6 +1450,17 @@ function PresetDropdown({
         compatSections.push({ tierLabel: t(lk, fallback), entries: compatible });
         compatSections.push({ tierLabel: t(lk, fallback), entries: compatible });
       }
       }
     }
     }
+    // Filtering that leaves nothing at all is a statement about our matching,
+    // not about the presets: a P1S has ten usable process presets and read as
+    // having none, because every one of them is named for an X1C and only says
+    // "P1S" in a `compatible_printers` list an older sidecar doesn't report
+    // (#2982). Hiding all of them left a dropdown holding one auto-picked entry
+    // and a "Show all" link, with no hint that the list was the problem. So
+    // when the filter empties the list, show it unfiltered — a visible preset
+    // for the wrong printer is recoverable, an empty dropdown is not.
+    if (compatSections.length === 0 && other.length > 0) {
+      return { sections: unfiltered, otherEntries: [] };
+    }
     return { sections: compatSections, otherEntries: other };
     return { sections: compatSections, otherEntries: other };
   }, [data, slot, t, selectedPrinterName, compatIndex]);
   }, [data, slot, t, selectedPrinterName, compatIndex]);
 
 

+ 102 - 8
frontend/src/utils/slicePresetPicker.ts

@@ -14,10 +14,14 @@
 //   and the caller relies on the order, not a single merged list.
 //   and the caller relies on the order, not a single merged list.
 // - `pickProcessDefault` honours a 3MF's embedded process preset when
 // - `pickProcessDefault` honours a 3MF's embedded process preset when
 //   it exists and isn't printer-incompatible; otherwise prefers a
 //   it exists and isn't printer-incompatible; otherwise prefers a
-//   match-on-printer pick, then unknown-compat, then plain priority.
+//   match-on-printer pick, then unknown-compat, then plain priority --
+//   preferring a middle-of-the-road layer height within each of those
+//   (#2982).
 // - `pickFilamentForSlot` partitions candidates into compatible/unknown
 // - `pickFilamentForSlot` partitions candidates into compatible/unknown
 //   vs mismatch buckets and only consults the mismatch bucket when
 //   vs mismatch buckets and only consults the mismatch bucket when
-//   the compatible bucket is empty (#1851).
+//   the compatible bucket is empty (#1851). Material type partitions the
+//   same way: a preset that states a different material than the plate
+//   asks for is never the right answer (#2982).
 
 
 import type {
 import type {
   PresetRef,
   PresetRef,
@@ -100,16 +104,80 @@ export function pickProcessDefault(
   }
   }
   for (const wanted of ['match', 'unknown'] as const) {
   for (const wanted of ['match', 'unknown'] as const) {
     for (const tier of SLICE_MODAL_TIER_ORDER) {
     for (const tier of SLICE_MODAL_TIER_ORDER) {
-      for (const p of by[tier].process) {
-        if (presetCompatibility(p, 'process', printerName, compatIndex) === wanted) {
-          return { source: p.source, id: p.id };
-        }
-      }
+      const candidates = by[tier].process.filter(
+        (p) => presetCompatibility(p, 'process', printerName, compatIndex) === wanted,
+      );
+      const chosen = preferDefaultLayerHeight(candidates);
+      if (chosen) return { source: chosen.source, id: chosen.id };
     }
     }
   }
   }
   return pickDefault(by, 'process');
   return pickDefault(by, 'process');
 }
 }
 
 
+// Bambu's default layer height, and the one its own presets are named
+// "Standard" at. Used only to order candidates that are equally valid.
+const DEFAULT_LAYER_HEIGHT_MM = 0.2;
+
+// Leading "<n>mm" on a process preset name — "0.20mm Standard @BBL X1C".
+const PROCESS_LAYER_HEIGHT = /^\s*([\d.]+)\s*mm\b/i;
+
+/**
+ * Pick the best of a set of process presets that are all equally compatible.
+ *
+ * Tier order says which *source* to prefer, but within one tier the list is
+ * alphabetical, and alphabetical order on Bambu's naming scheme puts the
+ * finest layer height first. So the auto-pick landed on `0.08mm Extra Fine`
+ * for an X1C and `0.06mm Fine` for an A1 mini — a correct preset, but the
+ * slowest one the slicer ships, silently chosen for every slice that didn't
+ * name its own process (#2982).
+ *
+ * Preferring the height nearest 0.2mm gives the same answer a person would:
+ * `0.20mm Standard` where it exists, and the closest thing to it otherwise.
+ * Ties break toward the coarser height, then toward the earlier name, so the
+ * result stays deterministic. A name with no readable height sorts last but
+ * is still eligible — an imported preset called "My Draft" must remain
+ * pickable when it is the only candidate.
+ */
+function preferDefaultLayerHeight(candidates: UnifiedPreset[]): UnifiedPreset | null {
+  let best: UnifiedPreset | null = null;
+  let bestDistance = Number.POSITIVE_INFINITY;
+  let bestHeight = Number.NEGATIVE_INFINITY;
+  for (const p of candidates) {
+    const match = PROCESS_LAYER_HEIGHT.exec(p.name);
+    const height = match ? Number.parseFloat(match[1]) : Number.NaN;
+    const usable = Number.isFinite(height) && height > 0;
+    const distance = usable
+      ? Math.abs(height - DEFAULT_LAYER_HEIGHT_MM)
+      : Number.POSITIVE_INFINITY;
+    if (best == null || distance < bestDistance
+      || (distance === bestDistance && usable && height > bestHeight)) {
+      best = p;
+      bestDistance = distance;
+      bestHeight = usable ? height : Number.NEGATIVE_INFINITY;
+    }
+  }
+  return best;
+}
+
+/**
+ * True when ``preset`` states a material and it is not the one the plate slot
+ * asks for.
+ *
+ * Deliberately three-valued in effect: a preset with no stated material is not
+ * "different", it is unknown, and stays eligible. 32 profiles in the shipped
+ * BBL bundle inherit from a parent the bundle doesn't contain, so their
+ * material genuinely cannot be resolved — excluding those would leave slots
+ * with nothing to pick.
+ */
+export function statesDifferentMaterial(
+  preset: Pick<UnifiedPreset, 'filament_type'>,
+  requiredType: string,
+): boolean {
+  const required = requiredType.trim().toUpperCase();
+  const stated = (preset.filament_type ?? '').trim().toUpperCase();
+  return Boolean(required) && Boolean(stated) && required !== stated;
+}
+
 export function pickFilamentForSlot(
 export function pickFilamentForSlot(
   by: UnifiedPresetsResponse,
   by: UnifiedPresetsResponse,
   required: { type: string; color: string },
   required: { type: string; color: string },
@@ -136,11 +204,29 @@ export function pickFilamentForSlot(
   // printer-correct alternative exists, which preserves the graceful-degrade
   // printer-correct alternative exists, which preserves the graceful-degrade
   // behaviour for presets registries that genuinely have nothing for the
   // behaviour for presets registries that genuinely have nothing for the
   // selected printer.
   // selected printer.
+  //
+  // Material is the second hard partition (#2982). A preset that states a
+  // material the plate did not ask for is not a worse answer, it is the wrong
+  // one: printing a PLA plate with a PETG profile means the wrong nozzle
+  // temperature, the wrong bed temperature and the wrong flow. It used to be
+  // only a missed +10 bonus, which a colour hit plus a tier bonus could
+  // outweigh — and did, every time, once the standard tier's `filament_type`
+  // turned out to be null for every preset it listed: an A1 mini offered
+  // `Bambu PETG Basic` for a PLA plate, a P1S offered `Bambu PC`. Fixing the
+  // sidecar to report the material restores the signal; skipping a stated
+  // mismatch is what stops a wrong material from ever winning on colour again,
+  // whatever a future registry reports.
+  //
+  // A preset that states NO material stays eligible — that is "don't know",
+  // not "different", and 32 profiles in the shipped bundle inherit from a
+  // parent it doesn't contain, so their material is genuinely unknown. The
+  // same asymmetry `presetCompatibility` applies to printers.
   const reqType = required.type.trim().toUpperCase();
   const reqType = required.type.trim().toUpperCase();
   const reqColor = normalizeColorForCompare(required.color);
   const reqColor = normalizeColorForCompare(required.color);
 
 
   let bestCompatible: { ref: PresetRef; score: number } | null = null;
   let bestCompatible: { ref: PresetRef; score: number } | null = null;
   let bestMismatch: { ref: PresetRef; score: number } | null = null;
   let bestMismatch: { ref: PresetRef; score: number } | null = null;
+  let bestWrongType: { ref: PresetRef; score: number } | null = null;
   for (const tier of SLICE_MODAL_TIER_ORDER) {
   for (const tier of SLICE_MODAL_TIER_ORDER) {
     for (const p of by[tier].filament) {
     for (const p of by[tier].filament) {
       let score = 0;
       let score = 0;
@@ -153,7 +239,11 @@ export function pickFilamentForSlot(
       }
       }
       score += TIER_BONUS[tier];
       score += TIER_BONUS[tier];
       const ref = { source: p.source, id: p.id };
       const ref = { source: p.source, id: p.id };
-      if (presetCompatibility(p, 'filament', printerName, compatIndex) === 'mismatch') {
+      if (statesDifferentMaterial(p, reqType)) {
+        if (bestWrongType == null || score > bestWrongType.score) {
+          bestWrongType = { ref, score };
+        }
+      } else if (presetCompatibility(p, 'filament', printerName, compatIndex) === 'mismatch') {
         if (bestMismatch == null || score > bestMismatch.score) {
         if (bestMismatch == null || score > bestMismatch.score) {
           bestMismatch = { ref, score };
           bestMismatch = { ref, score };
         }
         }
@@ -164,6 +254,10 @@ export function pickFilamentForSlot(
   }
   }
   if (bestCompatible != null) return bestCompatible.ref;
   if (bestCompatible != null) return bestCompatible.ref;
   if (bestMismatch != null) return bestMismatch.ref;
   if (bestMismatch != null) return bestMismatch.ref;
+  // Nothing of the right material anywhere. Better a wrong-material preset the
+  // user can see and change in the dropdown than a null the modal renders as
+  // an empty slot, which is what shipped before the partition existed.
+  if (bestWrongType != null) return bestWrongType.ref;
   // Final fallback when there are no filament presets at all (empty
   // Final fallback when there are no filament presets at all (empty
   // registry) — pickDefault returns null in that case too, but keeping the
   // registry) — pickDefault returns null in that case too, but keeping the
   // call mirrors the rest of the picker logic for shape consistency.
   // call mirrors the rest of the picker logic for shape consistency.

+ 6 - 0
frontend/src/utils/slicerPrinterMatch.ts

@@ -45,6 +45,12 @@ export const EMPTY_COMPATIBILITY_INDEX: PrinterCompatibilityIndex = {
 // (e.g. "X1" ⇄ "X1C") would silently group truly distinct printers.
 // (e.g. "X1" ⇄ "X1C") would silently group truly distinct printers.
 const PRINTER_MODEL_SUFFIX_ALIASES: Record<string, readonly string[]> = {
 const PRINTER_MODEL_SUFFIX_ALIASES: Record<string, readonly string[]> = {
   'A1 MINI': ['A1M'],
   'A1 MINI': ['A1M'],
+  // Same shape, spotted while tracing #2982: the bundle names every H2D Pro
+  // process and filament preset "@BBL H2DP" while the printer preset — and
+  // PRINTER_MODEL_MAP with it — spells the model "H2D Pro". Without the alias
+  // the H2D Pro classified all 198 bundled processes as belonging to another
+  // printer.
+  'H2D PRO': ['H2DP'],
 };
 };
 
 
 /**
 /**

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/index-YOSBC2S2.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DUUtee0j.js"></script>
+    <script type="module" crossorigin src="/assets/index-YOSBC2S2.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CexGkv-b.css">
     <link rel="stylesheet" crossorigin href="/assets/index-CexGkv-b.css">
   </head>
   </head>
   <body>
   <body>

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.