Bladeren bron

Show the picked preset's real values in the process-settings panel

The panel baselined every field on the option schema's compiled-in
defaults, so a preset setting a 0.42mm line width displayed 0 -- the C++
default meaning "derive from the nozzle". Every field was affected; the
Line width group just made it obvious.

Bambuddy cannot answer this itself. A standard-tier pick is only an
{inherits: ...} stub on our side, and local/cloud presets are deltas whose
remainder lives in the profile tree bundled inside the running sidecar.
The values now come from the sidecar's POST /profiles/resolve, which runs
the same resolver /slice does against the same profiles, so what the panel
shows cannot disagree with what a slice produces. Deliberately not the
local orca_profiles resolver: it walks OrcaSlicer's published tree, which
can differ from the image actually installed.

An untouched field shows the preset's value and reverting returns to it.
isModified compares against that baseline too, so fields the preset moved
off the C++ default are no longer flagged as user edits, and values nobody
typed are no longer sent. When the values can't be read -- sidecar offline
or older than the endpoint -- the panel falls back to schema defaults and
says so rather than presenting them as the preset's.

Row layout, from screenshots:

- The control column is anchored to the right edge at a fixed width. It
  had been packed left after a fixed label column, leaving the values
  stranded mid-container with dead space beside them.
- Units are no longer truncated to "mm o...". The cap fitted the common
  "mm" but not "mm or %" or "mm/s² or %".
- The "from file" tick moved ahead of the control it qualifies; it used to
  sit past the unit at the row's right edge, reading as unrelated.

Both the unit and the control keep fixed widths, and the tick's slot is
reserved on rows without one -- sizing any of them to content makes each
row's input land at a different x and the column comes out ragged.

Also fixes a field that could not be cleared: emptying a free-text input
dropped the key, so it snapped back to the baseline and retyping appended
to it ("0.42" + "0.5" = "0.420.5"). The number branch was fixed earlier;
the text branch -- coFloatOrPercent, coString, the vector types -- was
not, and the regression test used a number input so it never caught it.

Requires a sidecar built from orca-slicer-api 4b664b7 or later. Older
images 404 the endpoint, which is handled as the fallback above.
maziggy 4 weken geleden
bovenliggende
commit
f421bb8160

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 60 - 0
backend/app/api/routes/slicer_presets.py

@@ -32,6 +32,7 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.user import User
+from backend.app.schemas.slicer import PresetRef
 from backend.app.schemas.slicer_presets import (
     UnifiedPreset,
     UnifiedPresetsBySlot,
@@ -46,9 +47,11 @@ from backend.app.services.orca_cloud import (
     OrcaCloudAuthError,
     OrcaCloudError,
 )
+from backend.app.services.preset_resolver import resolve_preset_ref
 from backend.app.services.slicer_api import (
     SlicerApiError,
     SlicerApiService,
+    SlicerApiUnavailableError,
 )
 from backend.app.utils.printer_models import PRINTER_MODEL_MAP
 
@@ -539,6 +542,63 @@ def list_printer_models() -> dict[str, str]:
     return dict(PRINTER_MODEL_MAP)
 
 
+@router.get("/preset-values")
+async def get_preset_values(
+    source: str = Query(..., description="Preset tier: 'local', 'cloud', 'orca_cloud' or 'standard'."),
+    id: str = Query(..., description="Preset id within that tier."),
+    slot: str = Query("process", description="Preset slot. Only 'process' is supported today."),
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
+) -> dict:
+    """Effective values of a preset, with its ``inherits:`` chain flattened.
+
+    Drives the slice modal's process-settings panel: without this the panel can
+    only show the option schema's compiled-in defaults, so a preset that sets a
+    0.42mm line width appears as the C++ default of 0.
+
+    The flattening is done by the *sidecar*, deliberately. A "Standard" pick is
+    only a ``{inherits: "<name>"}`` stub on our side, and even local/cloud
+    presets are deltas — the values live in the profile tree bundled inside the
+    running sidecar image. Bambuddy's own ``orca_profiles`` resolver walks
+    OrcaSlicer's published tree instead, which can disagree with what actually
+    slices; showing numbers from it would be confidently wrong.
+
+    Returns ``{"resolved": false, "values": {}}`` rather than an error whenever
+    the values can't be obtained (sidecar offline, too old for the endpoint, or
+    slicing not configured). The panel then falls back to schema defaults and
+    tells the user the values are indicative, which is a far better outcome
+    than a modal that won't open.
+    """
+    if slot != "process":
+        raise HTTPException(status_code=400, detail="Only the 'process' slot is supported")
+
+    ref = PresetRef(source=source, id=id)
+
+    try:
+        profile_json = await resolve_preset_ref(db, current_user, ref, slot)
+    except HTTPException:
+        # A preset the caller can't resolve is not a reason to break the panel;
+        # the slice itself will report it properly if they go ahead.
+        logger.info("Could not resolve %s preset %s for value lookup", slot, id)
+        return {"resolved": False, "values": {}}
+
+    api_url = await _resolve_slicer_api_url(db)
+    if not api_url:
+        return {"resolved": False, "values": {}}
+
+    service = SlicerApiService(api_url)
+    try:
+        values = await service.resolve_profile(profile_json, "process")
+    except SlicerApiUnavailableError:
+        return {"resolved": False, "values": {}}
+    finally:
+        await service.close()
+
+    if values is None:
+        return {"resolved": False, "values": {}}
+    return {"resolved": True, "values": values}
+
+
 @router.get("/presets", response_model=UnifiedPresetsResponse)
 async def list_unified_presets(
     db: AsyncSession = Depends(get_db),

+ 52 - 0
backend/app/services/slicer_api.py

@@ -10,6 +10,7 @@ under the hood, response body is raw G-code or 3MF with metadata in the
 
 import asyncio
 import io
+import json
 import logging
 import time
 import zipfile
@@ -309,6 +310,57 @@ class SlicerApiService:
             raise SlicerApiUnavailableError(f"Slicer sidecar /health returned {response.status_code}")
         return response.json()
 
+    async def resolve_profile(self, profile_json: str, category: str) -> dict | None:
+        """POST /profiles/resolve — flatten a preset's ``inherits:`` chain.
+
+        Returns the effective key/value map the slicer would actually use, so
+        the slice modal's settings panel can show a preset's real values rather
+        than the option schema's compiled-in defaults (a "Standard" pick is
+        only a ``{inherits: ...}`` stub on our side; everything else it sets
+        lives in the sidecar's bundled profiles).
+
+        This deliberately asks the sidecar rather than resolving locally.
+        Bambuddy has its own ``inherits:`` resolver in ``orca_profiles``, but it
+        walks OrcaSlicer's *published* profile tree, which is not necessarily
+        the one baked into the running sidecar image — values from it would look
+        authoritative and could quietly disagree with what gets sliced.
+
+        Returns ``None`` when the sidecar is too old to have the endpoint, so
+        callers can degrade to schema defaults instead of failing the modal.
+        Genuine transport failures still raise.
+        """
+        try:
+            payload = json.loads(profile_json)
+        except json.JSONDecodeError:
+            logger.warning("Cannot resolve %s preset: content is not valid JSON", category)
+            return None
+
+        try:
+            response = await self._client.post(
+                f"{self.base_url}/profiles/resolve",
+                json={"category": category, "profile": payload},
+                timeout=15.0,
+            )
+        except httpx.RequestError as exc:
+            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
+
+        if response.status_code == 404:
+            # Sidecar predates the endpoint. Not an error — the caller shows
+            # schema defaults and says so.
+            logger.info("Slicer sidecar has no /profiles/resolve; falling back to schema defaults")
+            return None
+        if response.status_code >= 400:
+            logger.warning(
+                "Slicer sidecar /profiles/resolve returned %s: %s",
+                response.status_code,
+                _format_sidecar_error(response),
+            )
+            return None
+
+        body = response.json()
+        resolved = body.get("profile") if isinstance(body, dict) else None
+        return resolved if isinstance(resolved, dict) else None
+
     async def list_bundled_profiles(self) -> dict:
         """GET /profiles/bundled — return the slicer's stock profiles by slot.
 

+ 87 - 0
backend/tests/unit/test_slicer_preset_values.py

@@ -0,0 +1,87 @@
+"""Tests for resolving a preset's effective values via the sidecar.
+
+The slice modal's settings panel needs the values a preset actually sets, not
+the option schema's compiled-in defaults. Only the sidecar can answer that: a
+"Standard" pick is a ``{inherits: ...}`` stub on our side, and local/cloud
+presets are deltas whose remainder lives in the sidecar's bundled profile tree.
+"""
+
+import json
+
+import httpx
+import pytest
+
+from backend.app.services.slicer_api import SlicerApiService, SlicerApiUnavailableError
+
+PROCESS_STUB = json.dumps({"inherits": "0.20mm Standard @BBL X1C", "from": "system"})
+
+
+def _service(handler) -> SlicerApiService:
+    transport = httpx.MockTransport(handler)
+    client = httpx.AsyncClient(transport=transport)
+    return SlicerApiService("http://sidecar:3003", client=client)
+
+
+class TestResolveProfile:
+    @pytest.mark.asyncio
+    async def test_returns_the_flattened_values(self):
+        def handler(request: httpx.Request) -> httpx.Response:
+            assert request.url.path == "/profiles/resolve"
+            body = json.loads(request.content)
+            assert body["category"] == "process"
+            # The stub goes out as an object, not a JSON string.
+            assert body["profile"]["inherits"] == "0.20mm Standard @BBL X1C"
+            return httpx.Response(200, json={"profile": {"line_width": "0.42", "wall_loops": "2"}})
+
+        service = _service(handler)
+        assert await service.resolve_profile(PROCESS_STUB, "process") == {
+            "line_width": "0.42",
+            "wall_loops": "2",
+        }
+
+    @pytest.mark.asyncio
+    async def test_a_sidecar_without_the_endpoint_is_not_an_error(self):
+        # Older images 404 here. The panel degrades to schema defaults and says
+        # so; failing would make the modal unusable against an old sidecar.
+        service = _service(lambda request: httpx.Response(404, json={"message": "Not Found"}))
+        assert await service.resolve_profile(PROCESS_STUB, "process") is None
+
+    @pytest.mark.asyncio
+    async def test_a_sidecar_error_degrades_rather_than_raising(self):
+        service = _service(lambda request: httpx.Response(500, json={"message": "boom"}))
+        assert await service.resolve_profile(PROCESS_STUB, "process") is None
+
+    @pytest.mark.asyncio
+    async def test_unreachable_sidecar_still_raises(self):
+        # Distinct from "too old": the caller reports this as slicing being
+        # unavailable rather than silently showing defaults forever.
+        def handler(request: httpx.Request) -> httpx.Response:
+            raise httpx.ConnectError("refused")
+
+        with pytest.raises(SlicerApiUnavailableError):
+            await _service(handler).resolve_profile(PROCESS_STUB, "process")
+
+    @pytest.mark.asyncio
+    async def test_unparseable_preset_content_returns_none(self):
+        service = _service(lambda request: httpx.Response(200, json={"profile": {}}))
+        assert await service.resolve_profile("not json", "process") is None
+
+    @pytest.mark.asyncio
+    async def test_a_response_without_a_profile_object_returns_none(self):
+        # Guards against reading a differently-shaped body as if it were values.
+        service = _service(lambda request: httpx.Response(200, json={"ok": True}))
+        assert await service.resolve_profile(PROCESS_STUB, "process") is None
+
+    @pytest.mark.asyncio
+    async def test_an_already_flat_preset_round_trips(self):
+        flat = json.dumps({"line_width": "0.45", "type": "process"})
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            body = json.loads(request.content)
+            assert "inherits" not in body["profile"]
+            return httpx.Response(200, json={"profile": json.loads(flat)})
+
+        assert await _service(handler).resolve_profile(flat, "process") == {
+            "line_width": "0.45",
+            "type": "process",
+        }

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

@@ -34,6 +34,7 @@ vi.mock('../../api/client', () => ({
     listSlicerPipelines: vi.fn(),
     createSlicerPipeline: vi.fn(),
     getSlicerPrinterModels: vi.fn(),
+    getSlicerPresetValues: vi.fn(),
   },
 }));
 
@@ -49,6 +50,7 @@ const mockApi = api as unknown as {
   listSlicerPipelines: ReturnType<typeof vi.fn>;
   createSlicerPipeline: ReturnType<typeof vi.fn>;
   getSlicerPrinterModels: ReturnType<typeof vi.fn>;
+  getSlicerPresetValues: ReturnType<typeof vi.fn>;
 };
 
 function makeUnified(overrides: Partial<UnifiedPresetsResponse> = {}): UnifiedPresetsResponse {
@@ -103,6 +105,7 @@ describe('SliceModal', () => {
   beforeEach(() => {
     vi.clearAllMocks();
     mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
+    mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {} });
     mockApi.getSliceJob.mockResolvedValue({
       job_id: 42,
       status: 'running',
@@ -1556,6 +1559,7 @@ describe('SliceModal — process settings in "slice as designed" mode', () => {
   beforeEach(() => {
     vi.clearAllMocks();
     mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
+    mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {} });
     mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
     mockApi.getSlicerPrinterModels.mockResolvedValue({});
     mockApi.getLibraryFilePlates.mockResolvedValue({
@@ -1639,6 +1643,7 @@ describe('SliceModal — process settings layout', () => {
   beforeEach(() => {
     vi.clearAllMocks();
     mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier);
+    mockApi.getSlicerPresetValues.mockResolvedValue({ resolved: true, values: {} });
     mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] });
     mockApi.getLibraryFilePlates.mockResolvedValue({
       file_id: 100,

+ 103 - 0
frontend/src/__tests__/components/SlicerSettingsPanel.test.tsx

@@ -20,12 +20,16 @@ function Harness({
   sourceOverrides,
   initialSelected,
   filamentChoices,
+  presetValues,
+  presetValuesResolved,
 }: {
   initial: Record<string, SettingValue>;
   onChange: (v: Record<string, SettingValue>, s: Record<string, string | string[]>) => void;
   sourceOverrides?: DesignOverride[];
   initialSelected?: string[];
   filamentChoices?: FilamentChoice[];
+  presetValues?: Record<string, SettingValue>;
+  presetValuesResolved?: boolean;
 }) {
   const [values, setValues] = useState(initial);
   const [selected, setSelected] = useState(new Set(initialSelected ?? []));
@@ -37,6 +41,8 @@ function Harness({
         onChange(v, s);
       }}
       filamentChoices={filamentChoices}
+      presetValues={presetValues}
+      presetValuesResolved={presetValuesResolved}
       sourceOverrides={sourceOverrides}
       sourceSelected={selected}
       onToggleSource={(key, on) =>
@@ -58,6 +64,8 @@ async function renderPanel(
     sourceOverrides?: DesignOverride[];
     initialSelected?: string[];
     filamentChoices?: FilamentChoice[];
+    presetValues?: Record<string, SettingValue>;
+    presetValuesResolved?: boolean;
   } = {},
 ) {
   const onChange = vi.fn();
@@ -180,6 +188,18 @@ describe('SlicerSettingsPanel', () => {
     expect(input).toHaveValue(null);
   });
 
+  it('lets a free-text field be emptied too', async () => {
+    // coFloatOrPercent / coString / vector options render as text rather than
+    // number inputs, and the same drop-the-key-on-empty bug lived on that
+    // branch after the number branch was fixed.
+    const user = userEvent.setup();
+    await renderPanel();
+
+    const input = await showOption(user, 'Default', 'line_width');
+    await user.clear(input);
+    expect(input).toHaveValue('');
+  });
+
   it('clears every override from the header reset', async () => {
     const user = userEvent.setup();
     const { onChange } = await renderPanel({ layer_height: '0.16' });
@@ -249,6 +269,19 @@ describe("SlicerSettingsPanel — the source file's own settings", () => {
     expect(input).toHaveValue(2);
   });
 
+  it("puts the file's tick before the control it qualifies", async () => {
+    // A checkbox that gates a field belongs ahead of it. It used to render
+    // after the unit, out at the row's right edge, reading as unrelated.
+    const user = userEvent.setup();
+    await renderPanel({}, { sourceOverrides, initialSelected: ['wall_loops'] });
+    const control = await showOption(user, 'Wall loops', 'wall loops');
+
+    const row = control.closest('div.group') as HTMLElement;
+    const tick = within(row).getByRole('checkbox');
+    const controlFollowsTick = tick.compareDocumentPosition(control) & Node.DOCUMENT_POSITION_FOLLOWING;
+    expect(controlFollowsTick).toBeTruthy();
+  });
+
   it('flags a machine-coupled setting rather than applying it quietly', async () => {
     const user = userEvent.setup();
     await renderPanel({}, { sourceOverrides, initialSelected: ['wall_loops'] });
@@ -350,3 +383,73 @@ describe('SlicerSettingsPanel — filament-slot options', () => {
     expect(control.tagName).toBe('INPUT');
   });
 });
+
+describe('SlicerSettingsPanel — the picked preset\'s values', () => {
+  it('shows the preset value rather than the compiled-in default', async () => {
+    // The reported bug: line_width defaults to 0 in OrcaSlicer's C++ (meaning
+    // "derive from the nozzle"), so every Line width field read 0 regardless
+    // of what the chosen preset actually sets.
+    const user = userEvent.setup();
+    await renderPanel({}, { presetValues: { line_width: '0.42' } });
+    const input = await showOption(user, 'Default', 'line_width');
+    expect(input).toHaveValue('0.42');
+  });
+
+  it('does not mark a preset value as a user change', async () => {
+    // Comparing against the schema default would flag every field the preset
+    // moved off the C++ default as edited, and send values nobody typed.
+    const { onChange } = await renderPanel({}, { presetValues: { line_width: '0.42' } });
+    await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
+    expect(screen.queryByRole('button', { name: /Reset \d/ })).not.toBeInTheDocument();
+    expect(onChange).not.toHaveBeenCalled();
+  });
+
+  it('sends an edit that differs from the preset', async () => {
+    const user = userEvent.setup();
+    const { onChange } = await renderPanel({}, { presetValues: { line_width: '0.42' } });
+    const input = await showOption(user, 'Default', 'line_width');
+
+    await user.clear(input);
+    await user.type(input, '0.5');
+
+    await waitFor(() => {
+      const [, serialized] = onChange.mock.calls.at(-1)!;
+      expect(serialized.line_width).toBe('0.5');
+    });
+  });
+
+  it('sends nothing for a value retyped to match the preset', async () => {
+    const user = userEvent.setup();
+    const { onChange } = await renderPanel({}, { presetValues: { line_width: '0.42' } });
+    const input = await showOption(user, 'Default', 'line_width');
+
+    await user.clear(input);
+    await user.type(input, '0.42');
+
+    await waitFor(() => expect(onChange).toHaveBeenCalled());
+    const [, serialized] = onChange.mock.calls.at(-1)!;
+    expect(serialized).not.toHaveProperty('line_width');
+  });
+
+  it('reverts to the preset value, not the schema default', async () => {
+    const user = userEvent.setup();
+    await renderPanel({ line_width: '0.5' }, { presetValues: { line_width: '0.42' } });
+    const input = await showOption(user, 'Default', 'line_width');
+    expect(input).toHaveValue('0.5');
+
+    const row = input.closest('div.group') as HTMLElement;
+    await user.click(within(row).getByRole('button', { name: 'Reset to default' }));
+    await waitFor(() => expect(screen.getByLabelText(/^Default/)).toHaveValue('0.42'));
+  });
+
+  it('says so when the preset values could not be read', async () => {
+    await renderPanel({}, { presetValuesResolved: false });
+    await waitFor(() => expect(screen.getByText(/Showing slicer defaults/)).toBeInTheDocument());
+  });
+
+  it('shows no such notice when they resolved', async () => {
+    await renderPanel({}, { presetValues: { line_width: '0.42' }, presetValuesResolved: true });
+    await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
+    expect(screen.queryByText(/Showing slicer defaults/)).not.toBeInTheDocument();
+  });
+});

+ 22 - 0
frontend/src/api/client.ts

@@ -1614,6 +1614,13 @@ export interface PresetRef {
   source: PresetSource;
   id: string;
 }
+export interface SlicerPresetValues {
+  /** False when the sidecar could not supply values; `values` is then empty. */
+  resolved: boolean;
+  /** Flattened key -> value map, in the string forms a process preset stores. */
+  values: Record<string, string | string[]>;
+}
+
 export interface SliceRequest {
   printer_preset_id?: number;
   process_preset_id?: number;
@@ -7260,6 +7267,21 @@ export const api = {
   getSlicerPrinterModels: () =>
     request<Record<string, string>>('/slicer/printer-models'),
 
+  /**
+   * Effective values of a process preset, with its `inherits:` chain flattened
+   * by the slicer sidecar. Powers the slice modal's settings panel, which would
+   * otherwise show the option schema's compiled-in defaults (a preset setting a
+   * 0.42mm line width appears as the C++ default of 0).
+   *
+   * `resolved: false` means the values could not be obtained -- sidecar offline,
+   * too old for the endpoint, or slicing not configured -- and the caller should
+   * fall back to schema defaults rather than treat it as a failure.
+   */
+  getSlicerPresetValues: (ref: PresetRef) =>
+    request<SlicerPresetValues>(
+      `/slicer/preset-values?source=${encodeURIComponent(ref.source)}&id=${encodeURIComponent(ref.id)}`,
+    ),
+
   // Local Presets (OrcaSlicer imports)
   getLocalPresets: () =>
     request<LocalPresetsResponse>('/local-presets/'),

+ 22 - 0
frontend/src/components/SliceModal.tsx

@@ -421,6 +421,26 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
     [printerModelsQuery.data],
   );
 
+  // The picked process preset's effective values, flattened by the sidecar.
+  // Without this the settings panel shows OrcaSlicer's compiled-in defaults —
+  // a preset with a 0.42mm line width would read 0, which is the C++ default
+  // meaning "derive from the nozzle". Keyed on the preset so switching presets
+  // re-baselines the panel.
+  const presetValuesQuery = useQuery({
+    queryKey: ['slicer-preset-values', processPreset?.source, processPreset?.id],
+    queryFn: () => api.getSlicerPresetValues(processPreset as PresetRef),
+    enabled: processPreset != null,
+    // Preset contents only change when the user edits them in the slicer, and
+    // the modal is short-lived; no need to re-fetch while it is open.
+    staleTime: 5 * 60_000,
+  });
+
+  // A failed fetch is not an error the user must act on — the panel falls back
+  // to schema defaults and says so — so treat "no data yet" as unresolved
+  // rather than blocking the panel on it.
+  const presetValues = presetValuesQuery.data?.values as Record<string, SettingValue> | undefined;
+  const presetValuesResolved = presetValuesQuery.data?.resolved ?? presetValuesQuery.isLoading;
+
   // Slot list for the settings panel's filament pickers (support base and
   // interface, and the Multimaterial page's per-region options). Those store a
   // plain integer, so without this the user has to map slot numbers onto their
@@ -1029,6 +1049,8 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                           // so the backend keeps reading them from the file
                           // and keys outside the vendored schema stay faithful.
                           filamentChoices={filamentChoices}
+                          presetValues={presetValues}
+                          presetValuesResolved={presetValuesResolved}
                           sourceOverrides={designOverrides}
                           sourceSelected={designKeys}
                           onToggleSource={(key, on) =>

+ 85 - 38
frontend/src/components/SlicerSettingsPanel.tsx

@@ -23,7 +23,7 @@ import { useTranslation } from 'react-i18next';
 import { Search, RotateCcw, Loader2, ChevronDown } from 'lucide-react';
 
 import { disabledKeys, type ToggleRules } from '../lib/slicerToggle';
-import { defaultForDisplay, displaySidetext, isModified, numericBound, serializeOverrides } from '../lib/slicerSettings';
+import { baselineForDisplay, displaySidetext, isModified, numericBound, serializeOverrides } from '../lib/slicerSettings';
 import type { OptionMode, ProcessOption, ProcessSchema, ProcessUiTree, SettingValue } from '../types/slicerSettings';
 import type { DesignOverride } from '../types/plates';
 
@@ -72,6 +72,15 @@ interface Props {
    * picks instead.
    */
   filamentChoices?: FilamentChoice[];
+  /**
+   * The picked process preset's effective values, flattened by the sidecar.
+   * Used as the baseline an untouched field shows and a revert returns to.
+   * Empty when unavailable, in which case the panel falls back to the option
+   * schema's compiled-in defaults and says the values are indicative.
+   */
+  presetValues?: Record<string, SettingValue>;
+  /** False when the preset's values could not be fetched. */
+  presetValuesResolved?: boolean;
 }
 
 export interface FilamentChoice {
@@ -112,6 +121,8 @@ export default function SlicerSettingsPanel({
   sourceSelected,
   onToggleSource,
   filamentChoices,
+  presetValues,
+  presetValuesResolved = true,
 }: Props) {
   const { t } = useTranslation();
   const [data, setData] = useState<SlicerData | null>(null);
@@ -165,7 +176,7 @@ export default function SlicerSettingsPanel({
     // request harder to read when something goes wrong.
     const changed: Record<string, SettingValue> = {};
     for (const [k, v] of Object.entries(next)) {
-      if (data.schema[k] && isModified(data.schema[k], v)) changed[k] = v;
+      if (data.schema[k] && isModified(data.schema[k], v, presetValues?.[k])) changed[k] = v;
     }
     onChange(next, serializeOverrides(changed, data.schema));
   };
@@ -217,8 +228,8 @@ export default function SlicerSettingsPanel({
 
   const modifiedCount = useMemo(() => {
     if (!data) return 0;
-    return Object.keys(values).filter((k) => data.schema[k] && isModified(data.schema[k], values[k])).length;
-  }, [data, values]);
+    return Object.keys(values).filter((k) => data.schema[k] && isModified(data.schema[k], values[k], presetValues?.[k])).length;
+  }, [data, values, presetValues]);
 
   if (!data) {
     return (
@@ -275,6 +286,15 @@ export default function SlicerSettingsPanel({
         )}
       </div>
 
+      {!presetValuesResolved && (
+        <p className="rounded border border-amber-300 bg-amber-50 px-2 py-1 text-[0.7rem] text-amber-800 dark:border-amber-700/40 dark:bg-amber-900/20 dark:text-amber-200">
+          {t(
+            'slicerSettings.presetValuesUnavailable',
+            "Showing slicer defaults: the picked preset's own values could not be read. Anything you don't change still uses the preset.",
+          )}
+        </p>
+      )}
+
       {!query.trim() && (
         <div className="flex flex-wrap gap-1">
           {visiblePages.map((p) => (
@@ -320,6 +340,7 @@ export default function SlicerSettingsPanel({
                       sourceOn={sourceSelected?.has(key) ?? false}
                       onToggleSource={onToggleSource}
                       filamentChoices={FILAMENT_SLOT_OPTIONS.has(key) ? filamentChoices : undefined}
+                      presetValue={presetValues?.[key]}
                     />
                   ))}
                 </fieldset>
@@ -378,6 +399,8 @@ interface RowProps {
   onToggleSource?: (key: string, on: boolean) => void;
   /** Set only for options whose integer value names a filament slot. */
   filamentChoices?: FilamentChoice[];
+  /** The picked preset's value for this option, when known. */
+  presetValue?: SettingValue;
 }
 
 function OptionRow({
@@ -391,30 +414,40 @@ function OptionRow({
   sourceOn = false,
   onToggleSource,
   filamentChoices,
+  presetValue,
 }: RowProps) {
   const { t } = useTranslation();
-  const modified = isModified(option, value);
+  const modified = isModified(option, value, presetValue);
   const unit = displaySidetext(option);
   // What this slice will actually use, in precedence order: a value typed here
-  // wins, then the designer's value if it is switched on, then the preset's.
+  // wins, then the designer's value if it is switched on, then the preset's own
+  // (or the schema default when the preset's values are unavailable).
   const current =
     value !== undefined
       ? String(value)
       : sourceOn && source
         ? formatSourceValue(source.value)
-        : defaultForDisplay(option);
+        : baselineForDisplay(option, presetValue);
 
   return (
     <div className="flex items-center gap-2 group" title={option.tooltip}>
+      {/* Label takes the slack; the control group is a fixed width anchored to
+          the right edge. Fixed widths on the control *and* the unit are what
+          keep that column straight — sizing either to content makes each row's
+          input land at a different x. */}
       <label
         htmlFor={`slicer-opt-${optionKey}`}
-        className={`flex-1 text-xs truncate ${disabledBySlicer ? 'text-bambu-gray/40' : 'text-bambu-gray'}`}
+        className={`flex min-w-0 flex-1 items-center gap-1 text-xs ${disabledBySlicer ? 'text-bambu-gray/40' : 'text-bambu-gray'}`}
       >
-        {option.label || optionKey}
-        {modified && <span className="ml-1 text-bambu-green" aria-hidden="true">•</span>}
+        {/* Own title: a fixed column truncates more than the old flex-1 label
+            did, and the row's title carries the tooltip, not the name. */}
+        <span className="truncate" title={option.label || optionKey}>
+          {option.label || optionKey}
+        </span>
+        {modified && <span className="shrink-0 text-bambu-green" aria-hidden="true">•</span>}
         {source && (
           <span
-            className={`ml-1.5 rounded px-1 py-0.5 text-[10px] ${
+            className={`shrink-0 rounded px-1 py-0.5 text-[10px] ${
               source.printer_coupled
                 ? 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400'
                 : 'bg-bambu-green/15 text-bambu-green'
@@ -432,31 +465,45 @@ function OptionRow({
         )}
       </label>
 
-      <div className="flex items-center gap-1 shrink-0">
-        <OptionControl
-          id={`slicer-opt-${optionKey}`}
-          option={option}
-          current={current}
-          onChange={onChange}
-          disabled={disabled}
-          filamentChoices={filamentChoices}
-        />
-        {unit && <span className="text-[0.65rem] text-bambu-gray/60 w-10 truncate">{unit}</span>}
-        {source && onToggleSource && (
-          <input
-            type="checkbox"
-            checked={sourceOn}
+      <div className="flex shrink-0 items-center gap-1.5">
+        {/* The "use the file's value" tick comes *before* the control it
+            qualifies, as a checkbox that gates a field conventionally does —
+            it used to sit past the unit, out at the right edge, reading as
+            unrelated to the field. The slot is reserved on every row so rows
+            with and without a source override keep the control column
+            straight. */}
+        <span className="flex w-3 shrink-0 justify-center">
+          {source && onToggleSource && (
+            <input
+              type="checkbox"
+              checked={sourceOn}
+              disabled={disabled}
+              onChange={(e) => onToggleSource(optionKey, e.target.checked)}
+              aria-label={t('slicerSettings.useFromFile', "Use the source file's value for {{option}}", {
+                option: option.label || optionKey,
+              })}
+              title={t('slicerSettings.useFromFile', "Use the source file's value for {{option}}", {
+                option: option.label || optionKey,
+              })}
+              className="w-3 h-3 cursor-pointer disabled:opacity-40"
+            />
+          )}
+        </span>
+        <div className="w-40">
+          <OptionControl
+            id={`slicer-opt-${optionKey}`}
+            option={option}
+            current={current}
+            onChange={onChange}
             disabled={disabled}
-            onChange={(e) => onToggleSource(optionKey, e.target.checked)}
-            aria-label={t('slicerSettings.useFromFile', "Use the source file's value for {{option}}", {
-              option: option.label || optionKey,
-            })}
-            title={t('slicerSettings.useFromFile', "Use the source file's value for {{option}}", {
-              option: option.label || optionKey,
-            })}
-            className="w-3 h-3 cursor-pointer disabled:opacity-40"
+            filamentChoices={filamentChoices}
           />
-        )}
+        </div>
+        {/* Fixed width so the control column stays straight, but wide enough
+            for the longest unit in the schema ("mm/s² or %") — a narrower cap
+            truncated those to "mm o...". Rendered even when empty so rows
+            without a unit keep the revert button aligned. */}
+        <span className="w-16 shrink-0 whitespace-nowrap text-[0.65rem] text-bambu-gray/60">{unit ?? ''}</span>
         <button
           type="button"
           onClick={() => onChange(undefined)}
@@ -498,7 +545,7 @@ function OptionControl({ id, option, current, onChange, disabled, filamentChoice
   // bambu-dark-tertiary are CSS variables that follow the active theme, and
   // `text-white` is remapped to --text-primary in index.css.
   const inputClass =
-    'w-24 rounded border border-bambu-dark-tertiary bg-bambu-dark px-1.5 py-0.5 text-xs text-white focus:border-bambu-green focus:outline-none disabled:opacity-40';
+    'w-full rounded border border-bambu-dark-tertiary bg-bambu-dark px-1.5 py-0.5 text-xs text-white focus:border-bambu-green focus:outline-none disabled:opacity-40';
 
   // Filament-slot pickers come before the generic branches: the value is an
   // integer, but offering a spinner over "1, 2, 3" makes the user map slot
@@ -506,7 +553,7 @@ function OptionControl({ id, option, current, onChange, disabled, filamentChoice
   if (filamentChoices && filamentChoices.length > 0) {
     const selected = filamentChoices.find((c) => String(c.index) === current);
     return (
-      <div className="relative w-24">
+      <div className="relative w-full">
         <select
           id={id}
           value={current}
@@ -547,7 +594,7 @@ function OptionControl({ id, option, current, onChange, disabled, filamentChoice
     // Bambuddy: appearance-none plus our own chevron, so the control matches
     // the app in both themes instead of whatever the browser paints.
     return (
-      <div className="relative w-24">
+      <div className="relative w-full">
         <select
           id={id}
           value={current}
@@ -595,7 +642,7 @@ function OptionControl({ id, option, current, onChange, disabled, filamentChoice
       id={id}
       type="text"
       value={current}
-      onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value)}
+      onChange={(e) => onChange(e.target.value)}
       disabled={disabled}
       className={inputClass}
     />

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

@@ -4279,6 +4279,7 @@ export default {
 
   // Slice (slicer-API integration via SliceModal)
   slicerSettings: {
+    presetValuesUnavailable: 'Es werden Slicer-Standardwerte angezeigt: Die Werte des gewählten Profils konnten nicht gelesen werden. Alles, was Sie nicht ändern, verwendet weiterhin das Profil.',
     filamentDefault: 'Standard',
     fromFile: 'aus Datei',
     fromFileHint: 'Der Designer hat dies in der Quelldatei geändert. Wert: {{value}}.',

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

@@ -4313,6 +4313,7 @@ export default {
 
   // Slice (slicer-API integration via SliceModal)
   slicerSettings: {
+    presetValuesUnavailable: "Showing slicer defaults: the picked preset's own values could not be read. Anything you don't change still uses the preset.",
     filamentDefault: 'Default',
     fromFile: 'from file',
     fromFileHint: 'The designer changed this in the source file. Its value is {{value}}.',

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

@@ -4281,6 +4281,7 @@ export default {
 
   // Slice (slicer-API integration via SliceModal)
   slicerSettings: {
+    presetValuesUnavailable: 'Se muestran los valores predeterminados del laminador: no se pudieron leer los del perfil seleccionado. Todo lo que no cambies seguirá usando el perfil.',
     filamentDefault: 'Predeterminado',
     fromFile: 'del archivo',
     fromFileHint: 'El diseñador cambió esto en el archivo de origen. Su valor es {{value}}.',

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

@@ -4268,6 +4268,7 @@ export default {
 
   // Slice (slicer-API integration via SliceModal)
   slicerSettings: {
+    presetValuesUnavailable: "Valeurs par défaut du trancheur affichées : celles du profil choisi n'ont pas pu être lues. Tout ce que vous ne modifiez pas utilise toujours le profil.",
     filamentDefault: 'Par défaut',
     fromFile: 'du fichier',
     fromFileHint: 'Le concepteur a modifié ce paramètre dans le fichier source. Sa valeur est {{value}}.',

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

@@ -4267,6 +4267,7 @@ export default {
 
   // Slice (slicer-API integration via SliceModal)
   slicerSettings: {
+    presetValuesUnavailable: 'Sono mostrati i valori predefiniti dello slicer: quelli del profilo scelto non sono leggibili. Tutto ciò che non modifichi continua a usare il profilo.',
     filamentDefault: 'Predefinito',
     fromFile: 'dal file',
     fromFileHint: 'Il designer ha modificato questo parametro nel file di origine. Il valore è {{value}}.',

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

@@ -4279,6 +4279,7 @@ export default {
 
   // Slice (slicer-API integration via SliceModal)
   slicerSettings: {
+    presetValuesUnavailable: 'スライサーの既定値を表示しています。選択したプリセットの値を読み取れませんでした。変更しない項目は引き続きプリセットの値が使われます。',
     filamentDefault: '既定',
     fromFile: 'ファイル由来',
     fromFileHint: 'この項目は元ファイルで設計者が変更しています。値は {{value}} です。',

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

@@ -4070,6 +4070,7 @@ export default {
     },
   },
   slicerSettings: {
+    presetValuesUnavailable: '슬라이서 기본값을 표시합니다. 선택한 프리셋의 값을 읽을 수 없었습니다. 변경하지 않은 항목은 계속 프리셋 값을 사용합니다.',
     filamentDefault: '기본값',
     fromFile: '파일에서',
     fromFileHint: '디자이너가 원본 파일에서 이 항목을 변경했습니다. 값은 {{value}}입니다.',

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

@@ -4267,6 +4267,7 @@ export default {
 
   // Slice (slicer-API integration via SliceModal)
   slicerSettings: {
+    presetValuesUnavailable: 'Exibindo os padrões do fatiador: não foi possível ler os valores do perfil escolhido. Tudo o que você não alterar continua usando o perfil.',
     filamentDefault: 'Padrão',
     fromFile: 'do arquivo',
     fromFileHint: 'O designer alterou isto no arquivo de origem. O valor é {{value}}.',

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

@@ -4062,6 +4062,7 @@ export default {
     },
   },
   slicerSettings: {
+    presetValuesUnavailable: 'Показаны значения по умолчанию слайсера: значения выбранного профиля прочитать не удалось. Всё, что вы не измените, по-прежнему берётся из профиля.',
     filamentDefault: 'По умолчанию',
     fromFile: 'из файла',
     fromFileHint: 'Автор модели изменил этот параметр в исходном файле. Значение: {{value}}.',

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

@@ -4268,6 +4268,7 @@ export default {
 
   // Dilimle (SliceModal ile slicer-API entegrasyonu)
   slicerSettings: {
+    presetValuesUnavailable: 'Dilimleyici varsayılanları gösteriliyor: seçilen ön ayarın kendi değerleri okunamadı. Değiştirmediğiniz her şey yine ön ayarı kullanır.',
     filamentDefault: 'Varsayılan',
     fromFile: 'dosyadan',
     fromFileHint: 'Tasarımcı bunu kaynak dosyada değiştirdi. Değeri {{value}}.',

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

@@ -4312,6 +4312,7 @@ export default {
 
   // Slice (slicer-API integration via SliceModal)
   slicerSettings: {
+    presetValuesUnavailable: 'Показано типові значення слайсера: значення вибраного профілю не вдалося прочитати. Усе, чого ви не змінюєте, і далі береться з профілю.',
     filamentDefault: 'За замовчуванням',
     fromFile: 'з файлу',
     fromFileHint: 'Автор моделі змінив цей параметр у вихідному файлі. Значення: {{value}}.',

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

@@ -4267,6 +4267,7 @@ export default {
 
   // Slice (slicer-API integration via SliceModal)
   slicerSettings: {
+    presetValuesUnavailable: '当前显示切片器默认值:无法读取所选预设的实际值。未改动的项目仍使用预设。',
     filamentDefault: '默认',
     fromFile: '来自文件',
     fromFileHint: '设计者在源文件中修改了此项,其值为 {{value}}。',

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

@@ -4267,6 +4267,7 @@ export default {
 
   // Slice (slicer-API integration via SliceModal)
   slicerSettings: {
+    presetValuesUnavailable: '目前顯示切片器預設值:無法讀取所選預設的實際值。未變更的項目仍使用預設。',
     filamentDefault: '預設',
     fromFile: '來自檔案',
     fromFileHint: '設計者在來源檔案中修改了此項,其值為 {{value}}。',

+ 31 - 15
frontend/src/lib/slicerSettings.ts

@@ -41,9 +41,17 @@ export function displaySidetext(option: ProcessOption): string | undefined {
   return s;
 }
 
-/** The schema default, rendered the way the panel's inputs want to display it. */
-export function defaultForDisplay(option: ProcessOption): string {
-  const d = option.default;
+/**
+ * What an untouched field shows.
+ *
+ * The picked preset's own value when we have it, else the option schema's
+ * compiled-in default. The distinction is user-visible: `line_width` defaults
+ * to 0 in OrcaSlicer's C++ (meaning "derive from the nozzle"), while a real
+ * process preset sets something like 0.42 — showing the former for a preset
+ * that sets the latter is simply wrong.
+ */
+export function baselineForDisplay(option: ProcessOption, presetValue?: SettingValue): string {
+  const d = presetValue !== undefined ? presetValue : option.default;
   if (d === undefined) return '';
   // Per-extruder vectors render as a comma-separated list. C++ literal
   // artefacts (`0.`, `0.3f`, `100.%`) are normalised by
@@ -95,20 +103,28 @@ export function serializeOverrides(values: Record<string, SettingValue>, schema:
 }
 
 /**
- * True when an edited value differs from the option's default. Used to mark
- * modified rows and to decide what is worth sending: an override equal to the
- * default is noise in the process JSON.
+ * True when an edited value differs from the baseline this slice would
+ * otherwise use. Marks modified rows, and decides what is worth sending: an
+ * override equal to what the preset already says is noise in the process JSON.
+ *
+ * The baseline is the preset's value when known. Comparing against the schema
+ * default instead would flag every field the preset moved off the C++ default
+ * as "changed by the user", and would send back values nobody typed.
  */
-export function isModified(option: ProcessOption, value: SettingValue | undefined): boolean {
+export function isModified(
+  option: ProcessOption,
+  value: SettingValue | undefined,
+  presetValue?: SettingValue,
+): boolean {
   if (value === undefined || value === '') return false;
-  const serialized = serializeSetting(option, value);
-  const asString = Array.isArray(serialized) ? serialized.join(', ') : serialized;
-
-  const d = option.default;
-  if (d === undefined) return asString !== '';
 
-  const defaultSerialized = serializeSetting(option, Array.isArray(d) ? d.map(String).join(', ') : (d as SettingValue));
-  const defaultString = Array.isArray(defaultSerialized) ? defaultSerialized.join(', ') : defaultSerialized;
+  const flatten = (v: SettingValue): string => {
+    const serialized = serializeSetting(option, Array.isArray(v) ? v.map(String).join(', ') : v);
+    return Array.isArray(serialized) ? serialized.join(', ') : serialized;
+  };
 
-  return asString !== defaultString;
+  const asString = flatten(value);
+  const baseline = presetValue !== undefined ? presetValue : option.default;
+  if (baseline === undefined) return asString !== '';
+  return asString !== flatten(baseline as SettingValue);
 }

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-i4ueH5ac.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-DGpcnZPX.js"></script>
+    <script type="module" crossorigin src="/assets/index-i4ueH5ac.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-D4VkH83v.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff