Explorar o código

Take the layer height from the plate that actually printed

The archive card, the library file details and the slice dialog all read
the layer height from a 3MF's project_settings.config. That records the
project's settings and can still describe an earlier process or another
plate; the plate's own G-code - what the printer executes - was never
consulted for it, because the parser read the first 4KB, enough for the
layer count in the header block but not for the config block that carries
layer_height 14-25KB in. A print running at 0.08 on the H2C archived as
0.2 with the layer count from the same file correct beside it.

The plate G-code now wins wherever the two disagree, and the plate that
was printed is the one read - the header parse used to take the first
gcode entry in the zip regardless of which plate the archive was for.
Source 3MFs, which carry no G-code, keep the project value as before.

---

Stop carrying a file's layer height over the preset you picked

Bambuddy carries a designer's process deviations across a re-slice
(#2622) and pre-ticked every one that was not machine-coupled.
layer_height is one MakerWorld projects routinely carry, so picking
"0.08mm High Quality" for a file whose designer had moved layer height to
0.2 sliced at 0.2 while the dropdown still read 0.08 - the same 0.2 the
settings panel showed, tagged "from file".

Layer height and first layer height are now classified preset_defining
and treated like the machine-coupled keys: offered, never pre-selected.
The flag travels on DesignOverride so the modal and the backend agree,
and the panel's badge names the conflict and shows the preset's own value
next to the file's, so ticking one is a deliberate choice.
maziggy hai 2 semanas
pai
achega
7e77bf5833

+ 2 - 0
CHANGELOG.md

@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Fixed
+- **Archive metadata could describe a plate that was never printed** — The layer height on the archive card and in the library's file details came only from the 3MF's `project_settings.config`, and the plate G-code beside it was read for the layer count alone — the first `.gcode` entry in the zip, whatever plate the archive was actually for. A multi-plate export therefore reported plate 1's layer count even when plate 3 ran, and nothing ever cross-checked the layer height against the plate that produced the print. Both now come from the printed plate: its G-code is read (64 KB, enough to reach the config block that carries `layer_height` 14–25 KB in, where 4 KB only ever reached the header), and its value wins over the project's where the two disagree. Source 3MFs, which carry no G-code, keep the project value exactly as before.
+- **Slicing a file could ignore the process preset you picked** — Bambuddy carries a designer's own process deviations across a re-slice (#2622) and pre-ticked all of them except the machine-coupled ones. `layer_height` is one that MakerWorld projects routinely carry, so picking "0.08mm High Quality" for a file whose designer had moved layer height to 0.2 sliced at 0.2 while the dropdown still read 0.08 — the same 0.2 the settings panel showed, tagged "from file". Layer height and first layer height are now treated like the machine-coupled keys: still offered, never pre-selected, and their badge in the settings panel names the conflict and shows the preset's own value beside the file's, so ticking one is a deliberate choice.
 - **Statistics forgot the name of a printer that was deleted with its history kept (#2873, reported by @rembomy)** — Prints by Printer, the per-printer success breakdown, the time-accuracy list and Failures by Printer all resolved the name against the printers that exist right now, so deleting a printer and choosing to keep its prints turned "Ultron" into "Printer 1" everywhere. The runs themselves already recorded the name they printed on, and that is what those breakdowns fall back to now: the last name the id was known by, for as long as its prints are kept. A printer that still exists is named from its own record as before, so a rename shows up immediately rather than after the next print. Covered by backend and frontend regression tests.
 - **Skip Objects went dead for the rest of a print if Bambuddy restarted while it was running** — The object list lives in memory and is filled by the print-start path, which is deliberately suppressed on the first status push after a restart so the print is not archived twice. Everything else that moment restores — the archive, filament attribution, the timelapse baseline — came back; the object list did not, so the printer card saw zero objects and greyed out its Skip button. Measured on the maintainer's H2C: 8 objects loaded at 09:02, a restart at 09:17, and no way to skip anything for the remaining hour of the print. Nothing could recover it either, because the one endpoint that can rebuild the list is only reachable from the modal that the greyed-out button opens. The list is now restored on the way back up, from the archive of the print that is still running and matched on the job id the printer mints per print, so a stale archive cannot lend its objects to someone else's job. Two things behind it changed as well: rebuilding now reads the archived 3MF on disk before asking the printer for a file Bambuddy already has — that request was a full transfer off a machine mid-print, 15 MB in this case, and it cannot succeed at all on a printer that kept the file on internal storage — and the card now treats zero objects as "not loaded yet" rather than "nothing to skip", since a running print always has at least one. A single-object print still greys the button out, which is the case that rule was written for. The plate image in the modal came from the same place and had the same problem: the cover, the top view and the object-ID mask all re-fetched the 3MF from the printer after a restart, three fan-outs at once for one modal, so the picture arrived seconds after the list. They now read the running print's archived file too. Wiki updated. Covered by backend and frontend tests.
 - **A print archived without its 3MF can be given its filament weight by hand (#1820, reported by @ojimpo)** — When the sliced file stays somewhere Bambuddy cannot read, the archive is created from the printer's report alone and carries no weight, so the print is missing from every filament total and there was no way to put it right afterwards: Rescan reads the figure out of the 3MF, and that archive has no file to read. The reporter's H2S print left 46 g of PLA on the spool with nothing recording it, and he corrected Spoolman by hand. **Edit Archive** now has a **Filament used (g)** field. It is written to the print's most recent run as well as to the archive, because the Projects roll-up and the Prometheus counter sum the runs rather than the cards — correcting only the card would have fixed the display and left every aggregate reading the old figure. The value is bounded at 0 to 100 kg, it is sent only when you actually change it, so an ordinary save cannot round off a sliced figure, and emptying the field clears it. A run that measured its own weight through spool tracking keeps that measurement — the correction fills in a run that has none, or one that only ever inherited the archive's estimate, and never overwrites a real measurement with a typed one. Nothing is deducted from Spoolman or internal inventory either: those are charged from what was tracked at the time, and a print that recorded nothing has nothing to reverse. On an archive that does have its 3MF, Rescan still overwrites what you typed — there the file is the authority. Translated in all locales; wiki updated. Covered by backend and frontend tests.

+ 54 - 5
backend/app/services/archive.py

@@ -139,6 +139,14 @@ def swap_plate_suffix(name: str | None, target_plate: int) -> str | None:
     return f"{base}{separator}{target_plate}"
 
 
+# How much of a plate's G-code to scan for header/config values. The header
+# block ends in the first kilobyte; the CONFIG_BLOCK that follows it carries
+# layer_height 14-25KB in (measured across the sliced 3MFs on hand, Bambu
+# Studio and OrcaSlicer alike), so 4KB — what this used to read — could only
+# ever see the header.
+_GCODE_SCAN_BYTES = 64 * 1024
+
+
 class ThreeMFParser:
     """Parser for Bambu Lab 3MF files."""
 
@@ -348,24 +356,65 @@ class ThreeMFParser:
         except Exception:
             pass  # Skip unreadable project settings file
 
+    def _printed_plate_gcode(self, gcode_files: list[str]) -> str:
+        """Return the G-code entry for the plate this archive is about.
+
+        ``plate_number`` is known for a plate-specific export (slice_info sets
+        it) and picking blindly is wrong there: a project sliced with plate 2
+        at 0.08 and plate 1 at 0.2 would otherwise report plate 1's numbers.
+        Falls back to the lowest plate index, then to zip order, so a file
+        whose entries are named some other way still parses as it did before.
+        """
+        if self.plate_number:
+            wanted = f"Metadata/plate_{self.plate_number}.gcode"
+            if wanted in gcode_files:
+                return wanted
+
+        def plate_index(name: str) -> int:
+            match = re.search(r"plate_(\d+)\.gcode$", name)
+            return int(match.group(1)) if match else 10**6
+
+        return min(gcode_files, key=lambda name: (plate_index(name), gcode_files.index(name)))
+
     def _parse_gcode_header(self, zf: zipfile.ZipFile):
-        """Parse G-code file header for total layer count and printer model."""
+        """Parse the printed plate's G-code for what only it can settle.
+
+        The plate's own G-code is the file the printer executes, so where it
+        disagrees with ``project_settings.config`` — the *project's* record,
+        which a multi-plate or per-plate-modified export can leave describing
+        a different plate entirely — the G-code wins.
+        """
         try:
-            # Look for plate_1.gcode or similar
             gcode_files = [f for f in zf.namelist() if f.endswith(".gcode")]
             if not gcode_files:
                 return
 
-            # Read first 4KB of G-code (header contains metadata)
-            gcode_path = gcode_files[0]
+            gcode_path = self._printed_plate_gcode(gcode_files)
+            # 64KB, not 4KB: the header block ends within the first kilobyte,
+            # but the CONFIG_BLOCK that carries layer_height starts right after
+            # it and the keys are alphabetical, so layer_height lands 14-25KB
+            # in on real files. The read is decompress-on-demand, so the cost
+            # of the wider window is a few tens of KB per archived file.
             with zf.open(gcode_path) as f:
-                header = f.read(4096).decode("utf-8", errors="ignore")
+                header = f.read(_GCODE_SCAN_BYTES).decode("utf-8", errors="ignore")
 
             # Look for "; total layer number: XX" pattern
             match = re.search(r";\s*total\s+layer\s+number[:\s]+(\d+)", header, re.IGNORECASE)
             if match:
                 self.metadata["total_layers"] = int(match.group(1))
 
+            # Layer height, overriding project_settings.config when both are
+            # present. The project config records the project's settings and can
+            # describe a plate other than this one; the plate's G-code is what
+            # the printer executes, so it decides. Anchored to the line start so keys ending in
+            # "layer_height" (independent_support_layer_height) can't match.
+            match = re.search(r"^;\s*layer_height\s*=\s*([\d.]+)\s*$", header, re.IGNORECASE | re.MULTILINE)
+            if match:
+                try:
+                    self.metadata["layer_height"] = float(match.group(1))
+                except ValueError:
+                    pass  # Malformed value: keep whatever project_settings gave us
+
             # Total filament usage. The slicer writes the print's totals into
             # the G-code header ("; total filament weight [g] : 126.26"). Only
             # a fallback — slice_info.config is more authoritative when present

+ 35 - 1
backend/app/services/design_settings.py

@@ -53,6 +53,10 @@ class DesignOverride(NamedTuple):
     key: str
     value: Any
     printer_coupled: bool
+    # Set for the handful of keys that *define* the picked process preset —
+    # see :data:`_PRESET_DEFINING`. Offered like printer-coupled ones, never
+    # pre-selected, because the user's preset pick has to win over the file.
+    preset_defining: bool = False
 
 
 # Process keys whose sane value depends on the machine, not on the design intent.
@@ -93,6 +97,29 @@ _PRINTER_COUPLED_SUBSTRINGS: tuple[str, ...] = (
 )
 
 
+# Process keys whose value *is* the preset the user picked. "0.08mm High
+# Quality" is not a name with a layer height attached — the layer height is
+# what the preset is, and the same holds for the first layer it starts on.
+#
+# Carrying these from the file would quietly undo an explicit pick: choose the
+# 0.08 preset for a MakerWorld file whose designer moved layer height to 0.2
+# and, with every non-printer-coupled key pre-selected, the slice comes out at
+# 0.2 while the dropdown still reads 0.08. The designer's value stays on offer
+# — a re-slice that genuinely wants the design's layer height is one tick away
+# — but nothing here is applied without the user saying so.
+_PRESET_DEFINING: frozenset[str] = frozenset(
+    {
+        "layer_height",
+        "initial_layer_print_height",
+    }
+)
+
+
+def is_preset_defining(key: str) -> bool:
+    """Whether this key is the identity of the picked process preset."""
+    return key in _PRESET_DEFINING
+
+
 def is_printer_coupled(key: str) -> bool:
     """Whether carrying this process key across printer models is risky."""
     if key in _PRINTER_COUPLED_EXACT:
@@ -159,7 +186,14 @@ def overrides_from_config(config: Any) -> list[DesignOverride]:
             # Listed as changed but absent from the flattened config — nothing
             # to carry. Seen with keys the slicer renamed between versions.
             continue
-        overrides.append(DesignOverride(key=key, value=config[key], printer_coupled=is_printer_coupled(key)))
+        overrides.append(
+            DesignOverride(
+                key=key,
+                value=config[key],
+                printer_coupled=is_printer_coupled(key),
+                preset_defining=is_preset_defining(key),
+            )
+        )
 
     overrides.sort(key=lambda o: o.key)
     return overrides

+ 6 - 1
backend/tests/integration/test_design_settings_plates.py

@@ -56,7 +56,12 @@ class TestArchivePlatesDesignOverrides:
         overrides = response.json()["design_overrides"]
         assert [o["key"] for o in overrides] == ["outer_wall_speed", "wall_loops"]
         by_key = {o["key"]: o for o in overrides}
-        assert by_key["wall_loops"] == {"key": "wall_loops", "value": "5", "printer_coupled": False}
+        assert by_key["wall_loops"] == {
+            "key": "wall_loops",
+            "value": "5",
+            "printer_coupled": False,
+            "preset_defining": False,
+        }
         assert by_key["outer_wall_speed"]["printer_coupled"] is True
         # The printer slot must never leak into the process list.
         assert "machine_start_gcode" not in by_key

+ 97 - 0
backend/tests/unit/services/test_archive_service.py

@@ -1011,3 +1011,100 @@ class TestThreeMFParserSupportMaterial:
         meta = ThreeMFParser(path).parse()
         assert meta["filament_type"] == "PLA"
         assert meta["filament_color"] == "#FFFFFF,#000000"
+
+
+class TestThreeMFLayerHeightFromPlateGcode:
+    """The plate's G-code outranks project_settings.config on layer height.
+
+    Reported against a print sliced at 0.08 that archived as 0.2: the card
+    reads ``project_settings.config``, which is the *project's* record and can
+    still describe another plate or an earlier process, while the plate G-code
+    is the file the printer actually runs. The value sits in the CONFIG_BLOCK
+    14-25KB into the G-code, past the 4KB header window this parser used to
+    read, so the disagreement had no way to surface.
+    """
+
+    @staticmethod
+    def _plate_gcode(layer_height: str, total_layers: int) -> str:
+        # Mirrors a real Bambu plate: header block first, then the alphabetical
+        # config block, which is what pushes layer_height past 4KB.
+        filler = "".join(f"; config_filler_{i} = {i}\n" for i in range(1200))
+        return (
+            "; HEADER_BLOCK_START\n"
+            f"; total layer number: {total_layers}\n"
+            "; HEADER_BLOCK_END\n"
+            "; CONFIG_BLOCK_START\n"
+            f"{filler}"
+            "; independent_support_layer_height = 0\n"
+            f"; layer_height = {layer_height}\n"
+            "; CONFIG_BLOCK_END\n"
+            "G1 X0 Y0\n"
+        )
+
+    def _write(self, path, *, config_layer_height, plates):
+        import json
+        import zipfile
+
+        with zipfile.ZipFile(path, "w") as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr(
+                "Metadata/project_settings.config",
+                json.dumps({"layer_height": config_layer_height}),
+            )
+            for index, (layer_height, total_layers) in plates.items():
+                zf.writestr(
+                    f"Metadata/plate_{index}.gcode",
+                    self._plate_gcode(layer_height, total_layers),
+                )
+        return path
+
+    def test_gcode_wins_over_project_settings(self, tmp_path):
+        from backend.app.services.archive import ThreeMFParser
+
+        path = self._write(tmp_path / "sliced.3mf", config_layer_height="0.2", plates={1: ("0.08", 59)})
+
+        parsed = ThreeMFParser(path).parse()
+
+        assert parsed["layer_height"] == 0.08
+        assert parsed["total_layers"] == 59
+
+    def test_reads_the_plate_that_was_printed(self, tmp_path):
+        from backend.app.services.archive import ThreeMFParser
+
+        path = self._write(
+            tmp_path / "multi.3mf",
+            config_layer_height="0.2",
+            plates={1: ("0.2", 30), 2: ("0.08", 148)},
+        )
+
+        parsed = ThreeMFParser(path, plate_number=2).parse()
+
+        assert parsed["layer_height"] == 0.08
+        assert parsed["total_layers"] == 148
+
+    def test_falls_back_to_the_lowest_plate_when_none_was_named(self, tmp_path):
+        from backend.app.services.archive import ThreeMFParser
+
+        path = self._write(
+            tmp_path / "unnamed.3mf",
+            config_layer_height="0.2",
+            plates={2: ("0.08", 148), 1: ("0.2", 30)},
+        )
+
+        parsed = ThreeMFParser(path).parse()
+
+        assert parsed["layer_height"] == 0.2
+        assert parsed["total_layers"] == 30
+
+    def test_source_3mf_without_gcode_keeps_the_project_value(self, tmp_path):
+        import json
+        import zipfile
+
+        from backend.app.services.archive import ThreeMFParser
+
+        path = tmp_path / "source.3mf"
+        with zipfile.ZipFile(path, "w") as zf:
+            zf.writestr("3D/3dmodel.model", "<model/>")
+            zf.writestr("Metadata/project_settings.config", json.dumps({"layer_height": "0.28"}))
+
+        assert ThreeMFParser(path).parse()["layer_height"] == 0.28

+ 38 - 0
backend/tests/unit/test_design_settings.py

@@ -16,6 +16,7 @@ from backend.app.services.design_settings import (
     DesignOverride,
     apply_design_overrides,
     extract_design_process_overrides,
+    is_preset_defining,
     is_printer_coupled,
     overrides_from_config,
 )
@@ -88,6 +89,43 @@ class TestClassification:
             assert is_printer_coupled(key) is True, key
 
 
+class TestPresetDefining:
+    """Keys that *are* the picked preset must never be carried without asking.
+
+    "0.08mm High Quality" is its layer height; carrying a file's 0.2 onto it
+    produced a 0.2 slice while the modal's dropdown still read 0.08 — the
+    report this class exists for.
+    """
+
+    def test_layer_heights_define_the_preset(self):
+        assert is_preset_defining("layer_height") is True
+        assert is_preset_defining("initial_layer_print_height") is True
+
+    def test_ordinary_design_tweaks_do_not(self):
+        for key in ("wall_loops", "sparse_infill_density", "brim_type", "outer_wall_speed"):
+            assert is_preset_defining(key) is False, key
+
+    def test_extraction_marks_them(self):
+        config = _config(
+            layer_height="0.2",
+            different_settings_to_system=["wall_loops;layer_height", "", "", ""],
+        )
+        by_key = {o.key: o for o in overrides_from_config(config)}
+
+        assert by_key["layer_height"].preset_defining is True
+        assert by_key["layer_height"].printer_coupled is False
+        assert by_key["wall_loops"].preset_defining is False
+
+    def test_they_still_apply_when_explicitly_selected(self):
+        # Offered, not withheld: a re-slice that genuinely wants the design's
+        # layer height gets it by ticking the box.
+        overrides = [DesignOverride("layer_height", "0.2", False, True)]
+
+        result = apply_design_overrides('{"inherits": "0.08mm High Quality @BBL H2C"}', overrides, ["layer_height"])
+
+        assert json.loads(result)["layer_height"] == "0.2"
+
+
 class TestExtraction:
     def test_reads_the_process_slot_and_classifies_each_key(self):
         overrides = extract_design_process_overrides(_3mf(_config()))

+ 65 - 3
frontend/src/__tests__/components/SliceModal.test.tsx

@@ -349,9 +349,9 @@ describe('SliceModal', () => {
     embedded_printer: 'Bambu Lab A1 0.4 nozzle',
     embedded_process: '0.20mm Standard @BBL A1',
     design_overrides: [
-      { key: 'wall_loops', value: '5', printer_coupled: false },
-      { key: 'sparse_infill_density', value: '100%', printer_coupled: false },
-      { key: 'outer_wall_speed', value: '200', printer_coupled: true },
+      { key: 'wall_loops', value: '5', printer_coupled: false, preset_defining: false },
+      { key: 'sparse_infill_density', value: '100%', printer_coupled: false, preset_defining: false },
+      { key: 'outer_wall_speed', value: '200', printer_coupled: true, preset_defining: false },
     ],
   };
 
@@ -401,6 +401,68 @@ describe('SliceModal', () => {
     expect([...(payload.design_overrides ?? [])].sort()).toEqual(['sparse_infill_density', 'wall_loops']);
   });
 
+  // The file's layer height is the one deviation that must not ride along: it
+  // *is* the process preset the user picked, so carrying it silently sliced a
+  // file at 0.2 while the process dropdown still read "0.08mm High Quality".
+  const designedWithLayerHeight = {
+    ...designedFor,
+    design_overrides: [
+      { key: 'wall_loops', value: '5', printer_coupled: false, preset_defining: false },
+      { key: 'layer_height', value: '0.2', printer_coupled: false, preset_defining: true },
+    ],
+  };
+
+  it("leaves the file's layer height off by default so the picked preset wins", async () => {
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+    mockApi.getLibraryFilePlates.mockResolvedValue(designedWithLayerHeight);
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByRole('button', { name: /^Slice$/ })).toBeEnabled());
+
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
+    const payload = mockApi.sliceLibraryFile.mock.calls[0][1] as { design_overrides?: string[] };
+    expect(payload.design_overrides).toEqual(['wall_loops']);
+  });
+
+  it('still applies the file\'s layer height when the user ticks it', async () => {
+    mockApi.sliceLibraryFile.mockResolvedValue({
+      job_id: 42,
+      status: 'pending',
+      status_url: '/api/v1/slice-jobs/42',
+    });
+    mockApi.getLibraryFilePlates.mockResolvedValue(designedWithLayerHeight);
+
+    renderWithTracker({
+      source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' },
+      onClose: vi.fn(),
+    });
+
+    await waitFor(() => expect(screen.getByRole('button', { name: /^Slice$/ })).toBeEnabled());
+
+    const user = await openDesignSection();
+    // Search rather than page-hop: it cuts across every page. The tick's
+    // aria-label carries the option's schema label, not its key.
+    await user.type(screen.getByPlaceholderText('Search settings'), 'layer height');
+    await waitFor(() => expect(sourceCheckbox('Layer height')).toBeInTheDocument());
+    await user.click(sourceCheckbox('Layer height'));
+    await user.click(screen.getByRole('button', { name: /^Slice$/ }));
+
+    await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled());
+    const payload = mockApi.sliceLibraryFile.mock.calls[0][1] as { design_overrides?: string[] };
+    expect([...(payload.design_overrides ?? [])].sort()).toEqual(['layer_height', 'wall_loops']);
+  });
+
   it('lists every changed setting with its value and flags the machine-coupled ones (#2622)', async () => {
     mockApi.getLibraryFilePlates.mockResolvedValue(designedFor);
 

+ 9 - 2
frontend/src/components/SliceModal.tsx

@@ -492,9 +492,16 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   }, [canUseEmbedded]);
 
   // Pre-tick the printer-independent design settings once the source's list
-  // arrives. Machine-coupled keys stay off until the user opts in explicitly.
+  // arrives. Machine-coupled keys stay off until the user opts in explicitly,
+  // and so do the ones that define the picked preset (layer height, first
+  // layer height): pre-ticking those let a file's 0.2 quietly slice over an
+  // explicitly picked 0.08 preset while the dropdown still read 0.08.
   useEffect(() => {
-    setDesignKeys(new Set(designOverrides.filter((o) => !o.printer_coupled).map((o) => o.key)));
+    setDesignKeys(
+      new Set(
+        designOverrides.filter((o) => !o.printer_coupled && !o.preset_defining).map((o) => o.key),
+      ),
+    );
   }, [designOverrides]);
 
   // Printer pre-pick: defaults to the printer the 3MF was prepared for when

+ 15 - 5
frontend/src/components/SlicerSettingsPanel.tsx

@@ -392,9 +392,11 @@ export default function SlicerSettingsPanel({
                     <span className="font-mono text-bambu-gray">{o.key}</span>
                     <span className="ml-1.5 text-white">{formatSourceValue(o.value)}</span>
                   </span>
-                  {o.printer_coupled && (
+                  {(o.printer_coupled || o.preset_defining) && (
                     <span className="shrink-0 rounded bg-amber-100 px-1 py-0.5 text-[10px] text-amber-700 dark:bg-amber-500/20 dark:text-amber-400">
-                      {t('slicerSettings.fromFilePrinterCoupled', "designer's printer")}
+                      {o.printer_coupled
+                        ? t('slicerSettings.fromFilePrinterCoupled', "designer's printer")
+                        : t('slicerSettings.fromFileOverridesPreset', 'overrides preset')}
                     </span>
                   )}
                 </label>
@@ -470,19 +472,27 @@ function OptionRow({
         {source && (
           <span
             className={`shrink-0 rounded px-1 py-0.5 text-[10px] ${
-              source.printer_coupled
+              source.printer_coupled || source.preset_defining
                 ? 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400'
                 : 'bg-bambu-green/15 text-bambu-green'
             }`}
             title={
               source.printer_coupled
                 ? t('slicerSettings.fromFilePrinterCoupledHint', "Tuned for the printer this file was designed for -- may be wrong or out of range on yours.")
-                : t('slicerSettings.fromFileHint', "The designer changed this in the source file. Its value is {{value}}.", { value: formatSourceValue(source.value) })
+                : source.preset_defining
+                  ? t(
+                      'slicerSettings.fromFileOverridesPresetHint',
+                      'The file sets this to {{value}} where the preset you picked uses {{preset}}. Tick it only if the file should win.',
+                      { value: formatSourceValue(source.value), preset: baselineForDisplay(option, presetValue) },
+                    )
+                  : t('slicerSettings.fromFileHint', "The designer changed this in the source file. Its value is {{value}}.", { value: formatSourceValue(source.value) })
             }
           >
             {source.printer_coupled
               ? t('slicerSettings.fromFilePrinterCoupled', "designer's printer")
-              : t('slicerSettings.fromFile', 'from file')}
+              : source.preset_defining
+                ? t('slicerSettings.fromFileOverridesPreset', 'overrides preset')
+                : t('slicerSettings.fromFile', 'from file')}
           </span>
         )}
       </label>

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -4315,6 +4315,9 @@ export default {
     fromFileHint: 'Der Designer hat dies in der Quelldatei geändert. Wert: {{value}}.',
     fromFilePrinterCoupled: 'Drucker des Designers',
     fromFilePrinterCoupledHint: 'Auf den Drucker abgestimmt, für den diese Datei erstellt wurde – auf Ihrem kann der Wert falsch oder außerhalb des Bereichs sein.',
+    fromFileOverridesPreset: 'überschreibt Preset',
+    fromFileOverridesPresetHint:
+      'Die Datei setzt hier {{value}}, das gewählte Preset verwendet {{preset}}. Nur aktivieren, wenn die Datei Vorrang haben soll.',
     useFromFile: 'Wert aus der Quelldatei für {{option}} verwenden',
     otherFromFile: 'Weitere Einstellungen aus dieser Datei',
     loading: 'Slicer-Einstellungen werden geladen…',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -4350,6 +4350,9 @@ export default {
     fromFileHint: 'The designer changed this in the source file. Its value is {{value}}.',
     fromFilePrinterCoupled: "designer's printer",
     fromFilePrinterCoupledHint: 'Tuned for the printer this file was designed for -- may be wrong or out of range on yours.',
+    fromFileOverridesPreset: 'overrides preset',
+    fromFileOverridesPresetHint:
+      'The file sets this to {{value}} where the preset you picked uses {{preset}}. Tick it only if the file should win.',
     useFromFile: "Use the source file's value for {{option}}",
     otherFromFile: 'Other settings from this file',
     loading: 'Loading slicer settings…',

+ 3 - 0
frontend/src/i18n/locales/es.ts

@@ -4317,6 +4317,9 @@ export default {
     fromFileHint: 'El diseñador cambió esto en el archivo de origen. Su valor es {{value}}.',
     fromFilePrinterCoupled: 'impresora del diseñador',
     fromFilePrinterCoupledHint: 'Ajustado para la impresora para la que se diseñó este archivo: en la tuya puede ser incorrecto o estar fuera de rango.',
+    fromFileOverridesPreset: 'anula el perfil',
+    fromFileOverridesPresetHint:
+      'El archivo lo fija en {{value}} mientras que el perfil elegido usa {{preset}}. Actívalo solo si debe mandar el archivo.',
     useFromFile: 'Usar el valor del archivo de origen para {{option}}',
     otherFromFile: 'Otros ajustes de este archivo',
     loading: 'Cargando ajustes del laminador…',

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -4304,6 +4304,9 @@ export default {
     fromFileHint: 'Le concepteur a modifié ce paramètre dans le fichier source. Sa valeur est {{value}}.',
     fromFilePrinterCoupled: 'imprimante du concepteur',
     fromFilePrinterCoupledHint: "Réglé pour l'imprimante pour laquelle ce fichier a été conçu — peut être incorrect ou hors plage sur la vôtre.",
+    fromFileOverridesPreset: 'remplace le profil',
+    fromFileOverridesPresetHint:
+      'Le fichier impose {{value}} alors que le profil choisi utilise {{preset}}. À cocher uniquement si le fichier doit primer.',
     useFromFile: 'Utiliser la valeur du fichier source pour {{option}}',
     otherFromFile: 'Autres paramètres de ce fichier',
     loading: 'Chargement des paramètres du trancheur…',

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -4303,6 +4303,9 @@ export default {
     fromFileHint: 'Il designer ha modificato questo parametro nel file di origine. Il valore è {{value}}.',
     fromFilePrinterCoupled: 'stampante del designer',
     fromFilePrinterCoupledHint: 'Tarato per la stampante per cui è stato progettato questo file: sulla tua può essere errato o fuori intervallo.',
+    fromFileOverridesPreset: 'sovrascrive il profilo',
+    fromFileOverridesPresetHint:
+      'Il file imposta {{value}} mentre il profilo scelto usa {{preset}}. Selezionalo solo se deve prevalere il file.',
     useFromFile: 'Usa il valore del file di origine per {{option}}',
     otherFromFile: 'Altre impostazioni da questo file',
     loading: 'Caricamento impostazioni dello slicer…',

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -4315,6 +4315,9 @@ export default {
     fromFileHint: 'この項目は元ファイルで設計者が変更しています。値は {{value}} です。',
     fromFilePrinterCoupled: '設計者のプリンター',
     fromFilePrinterCoupledHint: 'このファイルが設計されたプリンター向けの値です。お使いのプリンターでは不適切または範囲外になる場合があります。',
+    fromFileOverridesPreset: 'プリセットを上書き',
+    fromFileOverridesPresetHint:
+      'ファイルはこれを {{value}} に設定していますが、選択したプリセットは {{preset}} です。ファイルを優先する場合のみチェックしてください。',
     useFromFile: '{{option}} に元ファイルの値を使用する',
     otherFromFile: 'このファイルのその他の設定',
     loading: 'スライサー設定を読み込んでいます…',

+ 3 - 0
frontend/src/i18n/locales/ko.ts

@@ -4106,6 +4106,9 @@ export default {
     fromFileHint: '디자이너가 원본 파일에서 이 항목을 변경했습니다. 값은 {{value}}입니다.',
     fromFilePrinterCoupled: '디자이너의 프린터',
     fromFilePrinterCoupledHint: '이 파일이 설계된 프린터에 맞춘 값입니다. 사용 중인 프린터에서는 잘못되거나 범위를 벗어날 수 있습니다.',
+    fromFileOverridesPreset: '프리셋 덮어쓰기',
+    fromFileOverridesPresetHint:
+      '파일은 이 값을 {{value}}(으)로 설정하지만 선택한 프리셋은 {{preset}}을(를) 사용합니다. 파일을 우선할 때만 선택하세요.',
     useFromFile: '{{option}}에 원본 파일의 값 사용',
     otherFromFile: '이 파일의 기타 설정',
     loading: '슬라이서 설정을 불러오는 중…',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4303,6 +4303,9 @@ export default {
     fromFileHint: 'O designer alterou isto no arquivo de origem. O valor é {{value}}.',
     fromFilePrinterCoupled: 'impressora do designer',
     fromFilePrinterCoupledHint: 'Ajustado para a impressora para a qual este arquivo foi projetado — pode estar errado ou fora de faixa na sua.',
+    fromFileOverridesPreset: 'substitui o perfil',
+    fromFileOverridesPresetHint:
+      'O arquivo define {{value}} enquanto o perfil escolhido usa {{preset}}. Marque apenas se o arquivo deve prevalecer.',
     useFromFile: 'Usar o valor do arquivo de origem para {{option}}',
     otherFromFile: 'Outras configurações deste arquivo',
     loading: 'Carregando configurações do fatiador…',

+ 3 - 0
frontend/src/i18n/locales/ru.ts

@@ -4098,6 +4098,9 @@ export default {
     fromFileHint: 'Автор модели изменил этот параметр в исходном файле. Значение: {{value}}.',
     fromFilePrinterCoupled: 'принтер автора',
     fromFilePrinterCoupledHint: 'Подобрано под принтер, для которого создан файл, — на вашем значение может быть неверным или вне диапазона.',
+    fromFileOverridesPreset: 'переопределяет пресет',
+    fromFileOverridesPresetHint:
+      'В файле задано {{value}}, а выбранный пресет использует {{preset}}. Отмечайте, только если приоритет должен быть у файла.',
     useFromFile: 'Использовать значение из исходного файла для «{{option}}»',
     otherFromFile: 'Другие параметры из этого файла',
     loading: 'Загрузка настроек слайсера…',

+ 3 - 0
frontend/src/i18n/locales/tr.ts

@@ -4304,6 +4304,9 @@ export default {
     fromFileHint: 'Tasarımcı bunu kaynak dosyada değiştirdi. Değeri {{value}}.',
     fromFilePrinterCoupled: 'tasarımcının yazıcısı',
     fromFilePrinterCoupledHint: 'Bu dosyanın tasarlandığı yazıcıya göre ayarlanmıştır; sizinkinde yanlış veya aralık dışı olabilir.',
+    fromFileOverridesPreset: 'profili geçersiz kılar',
+    fromFileOverridesPresetHint:
+      'Dosya bunu {{value}} olarak ayarlıyor, seçtiğiniz profil ise {{preset}} kullanıyor. Yalnızca dosya öncelikli olacaksa işaretleyin.',
     useFromFile: '{{option}} için kaynak dosyadaki değeri kullan',
     otherFromFile: 'Bu dosyadaki diğer ayarlar',
     loading: 'Dilimleyici ayarları yükleniyor…',

+ 3 - 0
frontend/src/i18n/locales/uk.ts

@@ -4348,6 +4348,9 @@ export default {
     fromFileHint: 'Автор моделі змінив цей параметр у вихідному файлі. Значення: {{value}}.',
     fromFilePrinterCoupled: 'принтер автора',
     fromFilePrinterCoupledHint: 'Підібрано під принтер, для якого створено файл, — на вашому значення може бути хибним або поза діапазоном.',
+    fromFileOverridesPreset: 'перевизначає пресет',
+    fromFileOverridesPresetHint:
+      'У файлі задано {{value}}, а вибраний пресет використовує {{preset}}. Позначайте, лише якщо перевагу має файл.',
     useFromFile: 'Використовувати значення з вихідного файлу для «{{option}}»',
     otherFromFile: 'Інші параметри з цього файлу',
     loading: 'Завантаження налаштувань слайсера…',

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4303,6 +4303,9 @@ export default {
     fromFileHint: '设计者在源文件中修改了此项,其值为 {{value}}。',
     fromFilePrinterCoupled: '设计者的打印机',
     fromFilePrinterCoupledHint: '针对该文件设计时所用的打印机调校,在你的打印机上可能不正确或超出范围。',
+    fromFileOverridesPreset: '覆盖预设',
+    fromFileOverridesPresetHint:
+      '文件将其设为 {{value}},而所选预设使用 {{preset}}。仅当应以文件为准时才勾选。',
     useFromFile: '对 {{option}} 使用源文件中的值',
     otherFromFile: '此文件中的其他设置',
     loading: '正在加载切片设置…',

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4303,6 +4303,9 @@ export default {
     fromFileHint: '設計者在來源檔案中修改了此項,其值為 {{value}}。',
     fromFilePrinterCoupled: '設計者的印表機',
     fromFilePrinterCoupledHint: '針對該檔案設計時所用的印表機調校,在你的印表機上可能不正確或超出範圍。',
+    fromFileOverridesPreset: '覆寫預設',
+    fromFileOverridesPresetHint:
+      '檔案將其設為 {{value}},而所選預設使用 {{preset}}。僅在應以檔案為準時才勾選。',
     useFromFile: '對 {{option}} 使用來源檔案中的值',
     otherFromFile: '此檔案中的其他設定',
     loading: '正在載入切片設定…',

+ 5 - 0
frontend/src/types/plates.ts

@@ -49,10 +49,15 @@ interface EmbeddedPresets {
 // One process setting the designer deviated on. `printer_coupled` marks the
 // values that only make sense on the machine they were tuned for (speeds,
 // accelerations, prime-tower geometry) — offered, but never pre-selected.
+// `preset_defining` marks the ones that *are* the picked process preset —
+// layer height and first layer height — which must not be carried over an
+// explicit preset pick without the user asking. Also offered, never
+// pre-selected.
 export interface DesignOverride {
   key: string;
   value: unknown;
   printer_coupled: boolean;
+  preset_defining: boolean;
 }
 
 export interface ArchivePlatesResponse extends EmbeddedPresets {

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-BFwS0pWA.js


+ 1 - 1
static/index.html

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

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio