mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
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.
This commit is contained in:
parent
c384911f7c
commit
f421bb8160
25 changed files with 536 additions and 109 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
backend/tests/unit/test_slicer_preset_values.py
Normal file
87
backend/tests/unit/test_slicer_preset_values.py
Normal file
|
|
@ -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",
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/'),
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}}.',
|
||||
|
|
|
|||
|
|
@ -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}}.',
|
||||
|
|
|
|||
|
|
@ -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}}.',
|
||||
|
|
|
|||
|
|
@ -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}}.',
|
||||
|
|
|
|||
|
|
@ -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}}.',
|
||||
|
|
|
|||
|
|
@ -4279,6 +4279,7 @@ export default {
|
|||
|
||||
// Slice (slicer-API integration via SliceModal)
|
||||
slicerSettings: {
|
||||
presetValuesUnavailable: 'スライサーの既定値を表示しています。選択したプリセットの値を読み取れませんでした。変更しない項目は引き続きプリセットの値が使われます。',
|
||||
filamentDefault: '既定',
|
||||
fromFile: 'ファイル由来',
|
||||
fromFileHint: 'この項目は元ファイルで設計者が変更しています。値は {{value}} です。',
|
||||
|
|
|
|||
|
|
@ -4070,6 +4070,7 @@ export default {
|
|||
},
|
||||
},
|
||||
slicerSettings: {
|
||||
presetValuesUnavailable: '슬라이서 기본값을 표시합니다. 선택한 프리셋의 값을 읽을 수 없었습니다. 변경하지 않은 항목은 계속 프리셋 값을 사용합니다.',
|
||||
filamentDefault: '기본값',
|
||||
fromFile: '파일에서',
|
||||
fromFileHint: '디자이너가 원본 파일에서 이 항목을 변경했습니다. 값은 {{value}}입니다.',
|
||||
|
|
|
|||
|
|
@ -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}}.',
|
||||
|
|
|
|||
|
|
@ -4062,6 +4062,7 @@ export default {
|
|||
},
|
||||
},
|
||||
slicerSettings: {
|
||||
presetValuesUnavailable: 'Показаны значения по умолчанию слайсера: значения выбранного профиля прочитать не удалось. Всё, что вы не измените, по-прежнему берётся из профиля.',
|
||||
filamentDefault: 'По умолчанию',
|
||||
fromFile: 'из файла',
|
||||
fromFileHint: 'Автор модели изменил этот параметр в исходном файле. Значение: {{value}}.',
|
||||
|
|
|
|||
|
|
@ -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}}.',
|
||||
|
|
|
|||
|
|
@ -4312,6 +4312,7 @@ export default {
|
|||
|
||||
// Slice (slicer-API integration via SliceModal)
|
||||
slicerSettings: {
|
||||
presetValuesUnavailable: 'Показано типові значення слайсера: значення вибраного профілю не вдалося прочитати. Усе, чого ви не змінюєте, і далі береться з профілю.',
|
||||
filamentDefault: 'За замовчуванням',
|
||||
fromFile: 'з файлу',
|
||||
fromFileHint: 'Автор моделі змінив цей параметр у вихідному файлі. Значення: {{value}}.',
|
||||
|
|
|
|||
|
|
@ -4267,6 +4267,7 @@ export default {
|
|||
|
||||
// Slice (slicer-API integration via SliceModal)
|
||||
slicerSettings: {
|
||||
presetValuesUnavailable: '当前显示切片器默认值:无法读取所选预设的实际值。未改动的项目仍使用预设。',
|
||||
filamentDefault: '默认',
|
||||
fromFile: '来自文件',
|
||||
fromFileHint: '设计者在源文件中修改了此项,其值为 {{value}}。',
|
||||
|
|
|
|||
|
|
@ -4267,6 +4267,7 @@ export default {
|
|||
|
||||
// Slice (slicer-API integration via SliceModal)
|
||||
slicerSettings: {
|
||||
presetValuesUnavailable: '目前顯示切片器預設值:無法讀取所選預設的實際值。未變更的項目仍使用預設。',
|
||||
filamentDefault: '預設',
|
||||
fromFile: '來自檔案',
|
||||
fromFileHint: '設計者在來源檔案中修改了此項,其值為 {{value}}。',
|
||||
|
|
|
|||
|
|
@ -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 flatten = (v: SettingValue): string => {
|
||||
const serialized = serializeSetting(option, Array.isArray(v) ? v.map(String).join(', ') : v);
|
||||
return Array.isArray(serialized) ? serialized.join(', ') : serialized;
|
||||
};
|
||||
|
||||
const defaultSerialized = serializeSetting(option, Array.isArray(d) ? d.map(String).join(', ') : (d as SettingValue));
|
||||
const defaultString = Array.isArray(defaultSerialized) ? defaultSerialized.join(', ') : defaultSerialized;
|
||||
|
||||
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 one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue