diff --git a/CHANGELOG.md b/CHANGELOG.md index 3badd86de..aa93a3e22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ All notable changes to Bambuddy will be documented in this file. - **Temperatures on the streaming overlay, and a builder for its URL (#1422, reporter @SMAW)** — The overlay at `/overlay/{printer}` draws live print data over a full-screen camera view for OBS, a wall display or any browser source. It could already be tuned — which fields, what size, what frame rate — but only through query parameters documented in the wiki, and temperatures were not among the fields on offer. Both are now addressed. Nozzle, bed and chamber readings join the list, shown with the target while the heater is still climbing and with the target dropped once it is reached, so a settled hotend reads "220°C" rather than "220 / 220°C" for the rest of the print. Both nozzles appear on a dual-nozzle printer. They are drawn whether or not a print is running, since a preheating machine is exactly when they are worth watching, and each reading appears only when the printer genuinely reports it — chamber temperature stays absent on P1 and A1 models, which publish a value with no sensor behind it. And **Settings → API Keys → Streaming Overlay** now builds the URL for you: pick the printer, tick the fields, set size and frame rate, paste in a token if login is enabled, and copy the result, with an optional preview alongside it. The preview stays off until you ask for it so that leaving the settings page open does not hold a viewer on the printer's single camera connection. Making that preview possible needed one narrow change to the security headers: the overlay path now sends `frame-ancestors 'self'` instead of `'none'`, so Bambuddy's own UI can embed it. Every other page still refuses to be framed at all, `'self'` permits a framer only on this same origin, and embedding the overlay from another host — Home Assistant on a different port, say — is unchanged and still requires `TRUSTED_FRAME_ORIGINS`. Temperatures are not in the default field set, so an overlay URL already pasted into a scene looks exactly the same after upgrading. Translated in all locales, wiki updated, covered by backend and frontend tests. - **The external spool can be hidden from the printer card (#1782, reporter @Arn0uDz)** — An external spool holder that never gets used still occupies a full card's width in the **Filaments** row, next to the AMS units that are actually being used. An eye icon at the right-hand end of that row's header now hides it, and clicking it again brings it back, so nothing is lost behind a settings page you would have to remember. The choice is remembered per printer and stored in the browser, like the card size and the offline-printer filter — one machine in a fleet can be tidied up without touching the others, and nothing changes for anyone else using the same Bambuddy. The icon is deliberately absent on a printer with no AMS: there the external spool is the entire filament section, and hiding it would leave an empty row. That guard also covers the case of an AMS being unplugged from a printer whose external spool was hidden earlier — the spool reappears rather than leaving a blank row behind. On the H2D and H2S both external positions share one card and so hide together. Translated in all locales, wiki updated, covered by frontend tests. -- **The slice dialog can edit the full print-parameter set, not just pick a preset** — Slicing from Bambuddy meant taking a process preset exactly as it came. Anything beyond that — one more wall for a bracket, supports for a single overhang, slower outer walls on a part that keeps scarring — meant going back to Bambu Studio, editing there, and re-exporting. The slice dialog now has a **Process settings** section carrying the whole tree: the same pages, groups and ordering the desktop slicer shows under Print Settings, with the same labels, tooltips, ranges and defaults, because they are extracted from the slicer's own sources rather than hand-picked. The dialog itself widens to make room: on a reasonably sized screen it now uses two columns, with every "what am I slicing with" decision — pipeline, printer, process, filaments, bed type, layout passes — kept together on the left and the settings panel given a column of its own on the right, open and ready rather than folded away. Narrower screens keep the single column and the collapsed panel. The settings a source file's designer changed (#2622) now live in this panel too, marked *from file* against the options they belong to instead of in a separate list further up the dialog -- so there is one place that shows what a slice will actually use. Machine-coupled ones stay flagged and unticked as before, anything the panel has no entry for is listed by name rather than quietly dropped, and typing your own value still wins. Switching on "Use the file's built-in settings" greys the panel out rather than removing it, so the dialog does not appear to lose a feature when that toggle is flipped -- it stays visible, says why it is inactive, and applies nothing. Options that select *which* filament prints a feature -- support base and interface, and the per-region pickers for walls, infill and surfaces -- list the filaments you actually picked on the left rather than asking for a slot number, so "support interface" can be set to the PVA in slot 2 by name. Defaults and ranges are read out of the slicer's C++ initialisers, so a few arrived in source form -- the whole Line width group showed "0." rather than "0" -- and those are now cleaned as the data is generated instead of being papered over at display time. It behaves the way the desktop one does. **Simple / Advanced / Expert** matches the slicer's own visibility tiers, search reaches across every page at once, changed settings are marked and individually revertable, and settings the slicer itself disables in your current configuration are greyed out — infill options with infill at zero, ironing options with ironing off — because Bambuddy evaluates the slicer's own enable rules rather than approximating them. Where a rule cannot be decided with certainty the setting stays editable, on the grounds that a missing control looks like a bug while a redundant one is merely ignored. Edits apply to one slice, are not saved into a preset, and are written after the source file's support configuration and any carried designer settings, so an explicit choice is never silently overridden; an untouched panel produces exactly the request it did before. Parameter names and descriptions are in English even where the rest of Bambuddy is not — several hundred strings lifted verbatim from the slicer, which is a separate job from translating Bambuddy's own interface. The dialog's own wording is translated in all locales. Wiki updated, covered by backend and frontend tests. +- **The slice dialog can edit the full print-parameter set, not just pick a preset** — Slicing from Bambuddy meant taking a process preset exactly as it came. Anything beyond that — one more wall for a bracket, supports for a single overhang, slower outer walls on a part that keeps scarring — meant going back to Bambu Studio, editing there, and re-exporting. The slice dialog now has a **Process settings** section carrying the whole tree: the same pages, groups and ordering the desktop slicer shows under Print Settings, with the same labels, tooltips, ranges and defaults, because they are extracted from the slicer's own sources rather than hand-picked. The dialog itself widens to make room: on a reasonably sized screen it now uses two columns, with every "what am I slicing with" decision — pipeline, printer, process, filaments, bed type, layout passes — kept together on the left and the settings panel given a column of its own on the right, open and ready rather than folded away. Narrower screens keep the single column and the collapsed panel. The settings a source file's designer changed (#2622) now live in this panel too, marked *from file* against the options they belong to instead of in a separate list further up the dialog -- so there is one place that shows what a slice will actually use. Machine-coupled ones stay flagged and unticked as before, anything the panel has no entry for is listed by name rather than quietly dropped, and typing your own value still wins. Switching on "Use the file's built-in settings" greys the panel out rather than removing it, so the dialog does not appear to lose a feature when that toggle is flipped -- it stays visible, says why it is inactive, and applies nothing. Options that select *which* filament prints a feature -- support base and interface, and the per-region pickers for walls, infill and surfaces -- list the filaments you actually picked on the left rather than asking for a slot number, so "support interface" can be set to the PVA in slot 2 by name. Defaults and ranges are read out of the slicer's C++ initialisers, so a few arrived in source form -- the whole Line width group showed "0." rather than "0" -- and those are now cleaned as the data is generated instead of being papered over at display time. Every field starts from the values your picked process preset actually sets, fetched by flattening it through the slicer sidecar -- the same resolver that does the slicing, so the numbers cannot disagree with what a slice produces. A field you never touch shows the preset's value, and reverting returns to it. Where those values cannot be read -- a sidecar that is offline or predates the endpoint -- the panel falls back to the slicer's own defaults and says so, rather than presenting them as if they were your preset's. It behaves the way the desktop one does. **Simple / Advanced / Expert** matches the slicer's own visibility tiers, search reaches across every page at once, changed settings are marked and individually revertable, and settings the slicer itself disables in your current configuration are greyed out — infill options with infill at zero, ironing options with ironing off — because Bambuddy evaluates the slicer's own enable rules rather than approximating them. Where a rule cannot be decided with certainty the setting stays editable, on the grounds that a missing control looks like a bug while a redundant one is merely ignored. Edits apply to one slice, are not saved into a preset, and are written after the source file's support configuration and any carried designer settings, so an explicit choice is never silently overridden; an untouched panel produces exactly the request it did before. Parameter names and descriptions are in English even where the rest of Bambuddy is not — several hundred strings lifted verbatim from the slicer, which is a separate job from translating Bambuddy's own interface. The dialog's own wording is translated in all locales. Wiki updated, covered by backend and frontend tests. ### Changed - **The slice dialog's process and filament lists now leave out presets that belong to another printer** — They were already sorted by compatibility, but a preset for a different Bambu model still appeared, demoted to an "Other printers" group at the bottom of the dropdown. With a large cloud filament library that group is most of the list, so the filtering was doing little for the thing it was meant to help: finding the profile you actually want. Those presets are now held back, with the label reporting how many ("3 hidden") next to a **Show all** link that brings them back for that one dropdown. Two things are never hidden. A preset with no detectable printer — a custom or renamed profile — stays in the list, because absence of evidence is not evidence of incompatibility and hiding those would make people's own imported profiles vanish. And whatever is currently selected stays visible even when the list is collapsed, so a deliberate cross-printer pick, or one restored from a pipeline, is never silently discarded by being dropped from the options. Re-slicing for another printer remains fully supported, so this is a default view rather than a restriction. Fixing this also corrected a screen-reader bug in those dropdowns: the controls sat inside the label wrapping the select, which handed them the entire label as their spoken name. A separate defect surfaced alongside it — the filter did nothing at all when the selected printer was a preset you had edited, because BambuStudio names those copies with a leading "# " and the matcher did not know to look past it. On such a printer every preset read as "compatibility unknown", and profiles listing their compatible printers by name could be ruled out against the very printer they were cloned from. diff --git a/backend/app/api/routes/slicer_presets.py b/backend/app/api/routes/slicer_presets.py index f11e8c2dd..5863bf4b0 100644 --- a/backend/app/api/routes/slicer_presets.py +++ b/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: ""}`` 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), diff --git a/backend/app/services/slicer_api.py b/backend/app/services/slicer_api.py index 648d9169a..9aae838dd 100644 --- a/backend/app/services/slicer_api.py +++ b/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. diff --git a/backend/tests/unit/test_slicer_preset_values.py b/backend/tests/unit/test_slicer_preset_values.py new file mode 100644 index 000000000..2a0bc8735 --- /dev/null +++ b/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", + } diff --git a/frontend/src/__tests__/components/SliceModal.test.tsx b/frontend/src/__tests__/components/SliceModal.test.tsx index 7afadd777..710216e83 100644 --- a/frontend/src/__tests__/components/SliceModal.test.tsx +++ b/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; createSlicerPipeline: ReturnType; getSlicerPrinterModels: ReturnType; + getSlicerPresetValues: ReturnType; }; function makeUnified(overrides: Partial = {}): 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, diff --git a/frontend/src/__tests__/components/SlicerSettingsPanel.test.tsx b/frontend/src/__tests__/components/SlicerSettingsPanel.test.tsx index b78750c6c..7591910b5 100644 --- a/frontend/src/__tests__/components/SlicerSettingsPanel.test.tsx +++ b/frontend/src/__tests__/components/SlicerSettingsPanel.test.tsx @@ -20,12 +20,16 @@ function Harness({ sourceOverrides, initialSelected, filamentChoices, + presetValues, + presetValuesResolved, }: { initial: Record; onChange: (v: Record, s: Record) => void; sourceOverrides?: DesignOverride[]; initialSelected?: string[]; filamentChoices?: FilamentChoice[]; + presetValues?: Record; + 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; + 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(); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5c3f0abbd..5b38e58bd 100644 --- a/frontend/src/api/client.ts +++ b/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; +} + export interface SliceRequest { printer_preset_id?: number; process_preset_id?: number; @@ -7260,6 +7267,21 @@ export const api = { getSlicerPrinterModels: () => request>('/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( + `/slicer/preset-values?source=${encodeURIComponent(ref.source)}&id=${encodeURIComponent(ref.id)}`, + ), + // Local Presets (OrcaSlicer imports) getLocalPresets: () => request('/local-presets/'), diff --git a/frontend/src/components/SliceModal.tsx b/frontend/src/components/SliceModal.tsx index 43b83cc4a..31aa191b9 100644 --- a/frontend/src/components/SliceModal.tsx +++ b/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 | 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) => diff --git a/frontend/src/components/SlicerSettingsPanel.tsx b/frontend/src/components/SlicerSettingsPanel.tsx index 28350063b..87d3e51b1 100644 --- a/frontend/src/components/SlicerSettingsPanel.tsx +++ b/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; + /** 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(null); @@ -165,7 +176,7 @@ export default function SlicerSettingsPanel({ // request harder to read when something goes wrong. const changed: Record = {}; 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({ )} + {!presetValuesResolved && ( +

+ {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.", + )} +

+ )} + {!query.trim() && (
{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]} /> ))} @@ -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 (
+ {/* 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. */}