Browse Source

Edit the full print-parameter set from the slice dialog

Slicing from Bambuddy meant taking a process preset as-is; any change
meant a round trip through Bambu Studio. The slice dialog now carries
OrcaSlicer's full process tree -- pages, groups, labels, tooltips,
ranges and defaults extracted from the slicer's own sources.

Enable/disable rules are evaluated from the slicer's own enable_if
expressions via a recursive-descent interpreter (no eval, CSP), with
enum comparisons validated against each option's declared values.
Anything undecidable leaves the field editable rather than greyed.

Overrides apply after the source's support config (#1881) and the
designer's carried tweaks (#2622), so an explicit choice always wins;
an untouched panel sends the same request as before.

Adds slice_engine as a separate setting from preferred_slicer -- where
slicing runs is a different axis from which binary the sidecar drives.
Only the sidecar engine is registered, so no picker renders yet.
maziggy 4 weeks ago
parent
commit
f0500578bd
38 changed files with 2203 additions and 4 deletions
  1. 0 0
      CHANGELOG.md
  2. 9 0
      backend/app/api/routes/library.py
  3. 14 0
      backend/app/schemas/settings.py
  4. 14 1
      backend/app/schemas/slicer.py
  5. 109 0
      backend/app/services/process_overrides.py
  6. 79 0
      backend/tests/unit/test_process_overrides.py
  7. 2 0
      frontend/scripts/check-i18n-parity.mjs
  8. 116 0
      frontend/scripts/generate-slicer-schema.mjs
  9. 179 0
      frontend/src/__tests__/components/SlicerSettingsPanel.test.tsx
  10. 95 0
      frontend/src/__tests__/utils/slicerToggle.test.ts
  11. 12 0
      frontend/src/api/client.ts
  12. 57 0
      frontend/src/components/SliceModal.tsx
  13. 369 0
      frontend/src/components/SlicerSettingsPanel.tsx
  14. 21 0
      frontend/src/i18n/locales/de.ts
  15. 21 0
      frontend/src/i18n/locales/en.ts
  16. 21 0
      frontend/src/i18n/locales/es.ts
  17. 21 0
      frontend/src/i18n/locales/fr.ts
  18. 21 0
      frontend/src/i18n/locales/it.ts
  19. 21 0
      frontend/src/i18n/locales/ja.ts
  20. 21 0
      frontend/src/i18n/locales/ko.ts
  21. 21 0
      frontend/src/i18n/locales/pt-BR.ts
  22. 21 0
      frontend/src/i18n/locales/ru.ts
  23. 21 0
      frontend/src/i18n/locales/tr.ts
  24. 21 0
      frontend/src/i18n/locales/uk.ts
  25. 21 0
      frontend/src/i18n/locales/zh-CN.ts
  26. 21 0
      frontend/src/i18n/locales/zh-TW.ts
  27. 75 0
      frontend/src/lib/sliceEngines.ts
  28. 113 0
      frontend/src/lib/slicerSettings.ts
  29. 469 0
      frontend/src/lib/slicerToggle.ts
  30. 35 0
      frontend/src/pages/SettingsPage.tsx
  31. 64 0
      frontend/src/types/slicerSettings.ts
  32. 0 0
      static/assets/index-Bfjo96N3.js
  33. 1 0
      static/assets/index-DQ9iPYXW.css
  34. 0 1
      static/assets/index-DZYWm6I1.css
  35. 116 0
      static/assets/process-schema-zTidBW1a.js
  36. 0 0
      static/assets/process-toggle-rules-DDBax3G5.js
  37. 0 0
      static/assets/process-ui-tree-BWrKRLV6.js
  38. 2 2
      static/index.html

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


+ 9 - 0
backend/app/api/routes/library.py

@@ -70,6 +70,7 @@ from backend.app.services.design_settings import (
     overrides_from_config,
 )
 from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
+from backend.app.services.process_overrides import apply_process_overrides
 from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
 from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
 from backend.app.utils.threemf_tools import (
@@ -3734,6 +3735,14 @@ async def _run_slicer_with_fallback(
                 request.design_overrides,
             )
 
+    # The user's own edits from the slice modal's settings panel. Applied last
+    # and for every model type (not just 3MF): unlike the two patches above this
+    # doesn't read anything out of the source file, it is what the user typed.
+    # Last write wins, so an explicit choice beats both the carried support
+    # config (#1881) and the designer's tweaks (#2622).
+    if request.process_overrides:
+        presets["process"] = apply_process_overrides(presets["process"], request.process_overrides)
+
     used_embedded_settings = False
     # "Slice as designed" (#2611): honour the file's embedded
     # project_settings.config instead of the picked profile triplet. Only

+ 14 - 0
backend/app/schemas/settings.py

@@ -286,6 +286,19 @@ class AppSettings(BaseModel):
         ),
     )
 
+    # Where slicing runs. Orthogonal to ``preferred_slicer``, which only says
+    # *which slicer binary* the sidecar drives: a browser engine is a different
+    # execution site, not a different binary choice. Kept as its own key so the
+    # two never have to encode impossible combinations.
+    #
+    # Only "sidecar" is implemented today; the slice modal offers a per-job
+    # choice when more than one engine is available, and hides the control
+    # entirely while there is only one.
+    slice_engine: str = Field(
+        default="sidecar",
+        description="Default execution site for slicing: 'sidecar' (server-side API) or 'browser'",
+    )
+
     # Slicer dispatch mode: when True, "Slice" actions open the in-app
     # SliceModal and call the slicer-API sidecar. When False (default), they
     # hand off to the user's local desktop slicer via URI scheme — preserving
@@ -638,6 +651,7 @@ class AppSettingsUpdate(BaseModel):
     camera_view_mode: str | None = None
     preferred_slicer: str | None = None
     open_in_slicer: str | None = None
+    slice_engine: str | None = None
     use_slicer_api: bool | None = None
     orcaslicer_api_url: str | None = None
     bambu_studio_api_url: str | None = None

+ 14 - 1
backend/app/schemas/slicer.py

@@ -1,6 +1,6 @@
 """Pydantic schemas for slice requests."""
 
-from typing import Literal
+from typing import Any, Literal
 
 from pydantic import BaseModel, Field, model_validator
 
@@ -93,6 +93,19 @@ class SliceRequest(BaseModel):
             "else is ignored. ``None``/empty means a plain profile slice."
         ),
     )
+    process_overrides: dict[str, Any] | None = Field(
+        default=None,
+        description=(
+            "The user's own process-setting edits from the slice modal's settings "
+            "panel, as a sparse ``{option_key: value}`` map (layer height, wall "
+            "count, supports, speeds — OrcaSlicer's process parameter set). Written "
+            "into the process JSON *after* the source's support settings and the "
+            "designer's carried tweaks, so an explicit choice here wins over both. "
+            "Values are normalised to the string forms a process preset stores; "
+            "keys that aren't valid config keys are dropped rather than failing "
+            "the slice. ``None``/empty leaves the picked preset untouched."
+        ),
+    )
     use_embedded_settings: bool = Field(
         default=False,
         description=(

+ 109 - 0
backend/app/services/process_overrides.py

@@ -0,0 +1,109 @@
+"""Apply the user's own process-setting choices to an outgoing slice.
+
+Bambuddy's slice modal can edit OrcaSlicer's full process parameter set (layer
+height, wall count, supports, speeds — the same tree the desktop slicer shows
+under Print Settings). Those edits arrive as a sparse ``{key: value}`` map and
+are written into the process JSON that goes out as ``--load-settings``, using
+the same mechanism ``_patch_process_support_settings`` (#1881) and
+``apply_design_overrides`` (#2622) already use.
+
+Precedence is deliberate and is the reason this runs last: the picked preset is
+the base, the source 3MF's support configuration and the designer's own tweaks
+layer on top, and an explicit choice the user made in the modal beats all of
+them. Anything else would silently discard a setting the user just typed.
+
+Values are normalised to the string forms a process preset actually stores
+(``"1"`` for a bool, ``"20%"`` for a percent, a list of strings for the
+per-extruder vector options). The frontend already serialises through the option
+schema, so this is a second line of defence for clients that don't — the slicer
+CLI validates far more strictly than the GUI and a wrongly-typed value fails the
+whole slice rather than being coerced.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+
+logger = logging.getLogger(__name__)
+
+# Config keys are lowercase identifiers. Anything else did not come from the
+# option schema, so it cannot be a real process setting.
+_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*$")
+
+# A process JSON is a flat string map; nesting a structure inside it produces a
+# file the CLI rejects outright.
+_ScalarTypes = (str, int, float, bool)
+
+
+def _normalise_scalar(value: object) -> str | None:
+    """Render one scalar the way a process preset stores it, or ``None`` if it
+    is not a value a process setting can hold."""
+    if isinstance(value, bool):
+        # Checked before int on purpose — bool is a subclass of int, and a
+        # process JSON spells booleans "1"/"0", never "True"/"False".
+        return "1" if value else "0"
+    if isinstance(value, (int, float)):
+        return str(value)
+    if isinstance(value, str):
+        return value
+    return None
+
+
+def normalise_process_overrides(overrides: dict[str, object]) -> dict[str, str | list[str]]:
+    """Filter and normalise a client-supplied override map.
+
+    Keys that don't look like config keys, and values that a process preset
+    cannot hold, are dropped with a warning rather than failing the slice: the
+    user's other settings are still worth applying, and a hard failure here
+    would be reported as "slicing failed" with no clue which field caused it.
+    """
+    clean: dict[str, str | list[str]] = {}
+    for key, value in overrides.items():
+        if not isinstance(key, str) or not _KEY_RE.match(key):
+            logger.warning("Ignoring process override with unusable key: %r", key)
+            continue
+
+        if isinstance(value, list):
+            parts = [_normalise_scalar(v) for v in value]
+            if any(p is None for p in parts):
+                logger.warning("Ignoring process override %s: list contains a non-scalar entry", key)
+                continue
+            clean[key] = [p for p in parts if p is not None]
+            continue
+
+        scalar = _normalise_scalar(value)
+        if scalar is None:
+            logger.warning("Ignoring process override %s: unsupported value type %s", key, type(value).__name__)
+            continue
+        clean[key] = scalar
+
+    return clean
+
+
+def apply_process_overrides(process_json: str, overrides: dict[str, object]) -> str:
+    """Write the user's process settings into the outgoing process JSON.
+
+    Returns ``process_json`` unchanged when there is nothing to apply or the
+    JSON is unparseable, so a bad input degrades to a slice with the picked
+    preset rather than failing it — matching ``apply_design_overrides``.
+    """
+    if not overrides:
+        return process_json
+
+    clean = normalise_process_overrides(overrides)
+    if not clean:
+        return process_json
+
+    try:
+        process_cfg = json.loads(process_json)
+    except json.JSONDecodeError:
+        logger.warning("Process preset JSON is unparseable; skipping %d user override(s)", len(clean))
+        return process_json
+    if not isinstance(process_cfg, dict):
+        return process_json
+
+    process_cfg.update(clean)
+    logger.info("Applying %d user process override(s): %s", len(clean), sorted(clean))
+    return json.dumps(process_cfg)

+ 79 - 0
backend/tests/unit/test_process_overrides.py

@@ -0,0 +1,79 @@
+"""Unit tests for the slice modal's process-setting overrides."""
+
+import json
+
+from backend.app.services.process_overrides import (
+    apply_process_overrides,
+    normalise_process_overrides,
+)
+
+
+def _process(**values: str) -> str:
+    return json.dumps({"inherits": "0.20mm Standard @BBL X1C", **values})
+
+
+class TestNormaliseProcessOverrides:
+    def test_bools_become_the_one_zero_strings_a_preset_stores(self):
+        assert normalise_process_overrides({"enable_support": True}) == {"enable_support": "1"}
+        assert normalise_process_overrides({"enable_support": False}) == {"enable_support": "0"}
+
+    def test_numbers_become_strings(self):
+        assert normalise_process_overrides({"wall_loops": 4, "layer_height": 0.16}) == {
+            "wall_loops": "4",
+            "layer_height": "0.16",
+        }
+
+    def test_strings_pass_through_including_the_percent_sign(self):
+        # The frontend serialises percents with the sign; stripping it here
+        # would silently change the value the slicer sees.
+        assert normalise_process_overrides({"sparse_infill_density": "35%"}) == {"sparse_infill_density": "35%"}
+
+    def test_vector_options_keep_their_list_shape(self):
+        assert normalise_process_overrides({"default_acceleration": [500, 300]}) == {
+            "default_acceleration": ["500", "300"]
+        }
+
+    def test_keys_that_are_not_config_identifiers_are_dropped(self):
+        result = normalise_process_overrides({"wall_loops": 2, "Wall Loops": 3, "__proto__": 1, "a-b": 1, "": 1})
+        assert result == {"wall_loops": "2"}
+
+    def test_values_a_preset_cannot_hold_are_dropped_not_serialised(self):
+        result = normalise_process_overrides({"wall_loops": 2, "nested": {"a": 1}, "none": None})
+        assert result == {"wall_loops": "2"}
+
+    def test_a_list_containing_a_non_scalar_drops_the_whole_key(self):
+        # Half-applying a per-extruder vector would send a shorter list than the
+        # printer has extruders, which is worse than not setting it at all.
+        assert normalise_process_overrides({"default_acceleration": [500, {"a": 1}]}) == {}
+
+
+class TestApplyProcessOverrides:
+    def test_writes_the_users_values_into_the_process_json(self):
+        result = apply_process_overrides(_process(), {"wall_loops": 4, "enable_support": True})
+        assert json.loads(result)["wall_loops"] == "4"
+        assert json.loads(result)["enable_support"] == "1"
+
+    def test_keeps_the_inherits_stub_so_the_preset_still_resolves(self):
+        # A "standard" preset pick is a {inherits: ...} stub; dropping that key
+        # would leave the slicer with a handful of orphaned values.
+        result = apply_process_overrides(_process(), {"wall_loops": 4})
+        assert json.loads(result)["inherits"] == "0.20mm Standard @BBL X1C"
+
+    def test_user_value_wins_over_one_already_in_the_preset(self):
+        result = apply_process_overrides(_process(wall_loops="2"), {"wall_loops": 6})
+        assert json.loads(result)["wall_loops"] == "6"
+
+    def test_empty_overrides_leave_the_json_untouched(self):
+        original = _process(wall_loops="2")
+        assert apply_process_overrides(original, {}) == original
+
+    def test_overrides_that_all_get_dropped_leave_the_json_untouched(self):
+        original = _process(wall_loops="2")
+        assert apply_process_overrides(original, {"Bad Key": 1}) == original
+
+    def test_unparseable_process_json_degrades_to_a_plain_slice(self):
+        # Better a slice with the picked preset than a failed one.
+        assert apply_process_overrides("not json", {"wall_loops": 4}) == "not json"
+
+    def test_non_object_process_json_degrades_to_a_plain_slice(self):
+        assert apply_process_overrides("[1, 2]", {"wall_loops": 4}) == "[1, 2]"

+ 2 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -220,6 +220,7 @@ const FR_COGNATES = [
   'Compact',  // cam-wall status overlay mode — same word in French
   'ntfy, Pushover, Discord, etc.',
   '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
+  'Simple', 'Expert',  // slicer settings visibility tiers — identical words in French
 ];
 
 // Italian cognates.
@@ -365,6 +366,7 @@ const ES_COGNATES = [
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
+  'Simple',  // slicer settings visibility tier — identical word in Spanish
 ];
 
 // Turkish cognates — technical UI labels that Turkish speakers use verbatim

+ 116 - 0
frontend/scripts/generate-slicer-schema.mjs

@@ -0,0 +1,116 @@
+// Regenerates the vendored OrcaSlicer process-settings metadata under
+// src/data/slicer/ from the `three-slicer` npm package.
+//
+// Why vendored and not a runtime dependency: we need three of the package's
+// four data files, trimmed to the *process* tab only, and none of its engine,
+// viewer or React code.
+// Pulling `three-slicer` as a dependency would drag in an 8 MB WASM kernel and
+// a `three@^0.160` peer pin that conflicts with our three@^0.181.
+//
+// Usage:  node scripts/generate-slicer-schema.mjs <path-to-three-slicer-package>
+//
+// The upstream data is AGPL-3.0-or-later, extracted from OrcaSlicer's C++
+// sources — same licence as Bambuddy, so vendoring is clean. Re-run this when
+// bumping to a newer three-slicer release and commit the regenerated output.
+
+import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import { join, resolve } from 'node:path';
+
+const src = process.argv[2];
+if (!src) {
+  console.error('usage: node scripts/generate-slicer-schema.mjs <path-to-three-slicer-package>');
+  process.exit(1);
+}
+
+const OUT_DIR = resolve(import.meta.dirname, '..', 'src', 'data', 'slicer');
+
+const readJson = (p) => JSON.parse(readFileSync(join(src, p), 'utf8'));
+
+const schema = readJson('data/config-schema.json');
+const uiTree = readJson('data/ui-tree.json');
+const toggles = readJson('data/toggle-rules.json');
+
+// --- 1. UI tree, process tab only -----------------------------------------
+// TabPrint::build is the process/print preset — the one whose JSON our slice
+// route patches. Filament and printer presets are separate objects on the
+// sidecar and out of scope for this panel.
+const pages = uiTree['TabPrint::build'];
+if (!Array.isArray(pages)) throw new Error('ui-tree.json has no TabPrint::build array');
+
+// Tab.cpp references that PrintConfig.cpp no longer defines, collected while
+// walking the tree so the run can report them.
+const dropped = [];
+
+// Drop the C++ source line numbers: useful for the extractor, noise for us.
+const trimmedPages = pages.map((page) => ({
+  page: page.page,
+  icon: page.icon,
+  groups: (page.groups ?? []).map((g) => ({
+    group: g.group,
+    options: (g.options ?? []).filter((key) => {
+      if (!schema[key]) {
+        // A handful of Tab.cpp references point at options that no longer
+        // exist in PrintConfig.cpp. Silently dropping them keeps the panel
+        // from rendering a control with no type, label or default.
+        dropped.push(key);
+        return false;
+      }
+      return true;
+    }),
+  })).filter((g) => g.options.length > 0),
+})).filter((p) => p.groups.length > 0);
+
+// --- 2. Schema, trimmed to the options the tree actually references --------
+const referenced = new Set(trimmedPages.flatMap((p) => p.groups.flatMap((g) => g.options)));
+
+// Toggle rules reference options for their *conditions* too (e.g. wall_loops
+// gates have_perimeters). Those must survive the trim or the evaluator reads a
+// default of `undefined` and fails open on a rule it could have decided.
+const CONDITION_KEYS = [
+  'wall_loops', 'sparse_infill_density', 'top_shell_layers', 'bottom_shell_layers',
+  'spiral_mode', 'skirt_loops', 'enable_support', 'raft_layers', 'enable_prime_tower',
+  'support_interface_top_layers', 'support_interface_bottom_layers', 'sparse_infill_pattern',
+  'support_type', 'support_style', 'wall_generator', 'timelapse_type', 'infill_combination',
+  'detect_thin_wall', 'ironing_type', 'default_acceleration', 'adaptive_layer_height',
+];
+for (const k of CONDITION_KEYS) if (schema[k]) referenced.add(k);
+
+// Only the fields the panel renders or the evaluator reads. This is what keeps
+// the vendored payload proportionate: the upstream schema is 384 KB across 907
+// options, most of it source-location bookkeeping we have no use for.
+const KEEP = ['type', 'mode', 'label', 'tooltip', 'sidetext', 'min', 'max', 'enum_values', 'enum_labels', 'default'];
+
+const trimmedSchema = {};
+for (const key of [...referenced].sort()) {
+  const opt = schema[key];
+  const out = {};
+  for (const f of KEEP) if (opt[f] !== undefined) out[f] = opt[f];
+  trimmedSchema[key] = out;
+}
+
+// --- 3. Toggle rules, FFF print options only ------------------------------
+// The other rule groups drive the filament and printer tabs, which this panel
+// does not render.
+const fff = toggles['toggle_print_fff_options'] ?? {};
+const trimmedToggles = {
+  locals: fff.locals ?? {},
+  rules: (fff.rules ?? [])
+    .filter((r) => r.enable_if && Array.isArray(r.fields))
+    // A rule whose fields are all outside our trimmed set can never change
+    // anything the panel shows.
+    .map((r) => ({ fields: r.fields.filter((f) => referenced.has(f)), enable_if: r.enable_if }))
+    .filter((r) => r.fields.length > 0),
+};
+
+mkdirSync(OUT_DIR, { recursive: true });
+const write = (name, data) => {
+  const path = join(OUT_DIR, name);
+  writeFileSync(path, JSON.stringify(data, null, 0) + '\n');
+  return `${name}: ${(readFileSync(path).length / 1024).toFixed(1)} KB`;
+};
+
+console.log(write('process-ui-tree.json', trimmedPages));
+console.log(write('process-schema.json', trimmedSchema));
+console.log(write('process-toggle-rules.json', trimmedToggles));
+console.log(`options: ${Object.keys(trimmedSchema).length}, pages: ${trimmedPages.length}, rules: ${trimmedToggles.rules.length}`);
+if (dropped.length) console.log(`dropped (no schema entry): ${dropped.join(', ')}`);

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

@@ -0,0 +1,179 @@
+import { describe, it, expect, vi } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useState } from 'react';
+
+import { render } from '../utils';
+import SlicerSettingsPanel from '../../components/SlicerSettingsPanel';
+import type { SettingValue } from '../../types/slicerSettings';
+
+/**
+ * The panel is a controlled component: it renders from the `values` prop and
+ * reports edits upward. Driving it with a bare spy would leave every input
+ * frozen at its initial value, so the harness holds state the way SliceModal
+ * does and forwards each call to the spy for assertions.
+ */
+function Harness({
+  initial,
+  onChange,
+}: {
+  initial: Record<string, SettingValue>;
+  onChange: (v: Record<string, SettingValue>, s: Record<string, string | string[]>) => void;
+}) {
+  const [values, setValues] = useState(initial);
+  return (
+    <SlicerSettingsPanel
+      values={values}
+      onChange={(v, s) => {
+        setValues(v);
+        onChange(v, s);
+      }}
+    />
+  );
+}
+
+/** Renders the panel and waits for its dynamically imported metadata. */
+async function renderPanel(initial: Record<string, SettingValue> = {}) {
+  const onChange = vi.fn();
+  render(<Harness initial={initial} onChange={onChange} />);
+  await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument());
+  return { onChange };
+}
+
+/**
+ * Brings one option on screen regardless of which page or visibility tier it
+ * belongs to. Searching spans every page, which is how a user would reach a
+ * setting they know the name of.
+ */
+async function showOption(user: ReturnType<typeof userEvent.setup>, label: string, search: string) {
+  await user.click(screen.getByRole('button', { name: 'Expert' }));
+  const box = screen.getByPlaceholderText('Search settings');
+  await user.clear(box);
+  await user.type(box, search);
+  return waitFor(() => screen.getByLabelText(new RegExp(`^${label}`)));
+}
+
+describe('SlicerSettingsPanel', () => {
+  it('opens on the first page of the slicer parameter tree', async () => {
+    await renderPanel();
+    expect(screen.getByRole('button', { name: 'Quality' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Strength' })).toBeInTheDocument();
+    expect(screen.getByLabelText(/^Layer height/)).toBeInTheDocument();
+  });
+
+  it('reveals more options as the visibility tier widens', async () => {
+    const user = userEvent.setup();
+    await renderPanel();
+
+    // "Slice gap closing radius" is an advanced-tier Quality option.
+    expect(screen.queryByLabelText(/^Slice gap closing radius/)).not.toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: 'Advanced' }));
+    await waitFor(() => expect(screen.getByLabelText(/^Slice gap closing radius/)).toBeInTheDocument());
+  });
+
+  it('searches across every page rather than only the open one', async () => {
+    const user = userEvent.setup();
+    await renderPanel();
+
+    // Enable support lives on the Support page, not the Quality page shown.
+    await user.type(screen.getByPlaceholderText('Search settings'), 'enable support');
+    await waitFor(() => expect(screen.getByLabelText(/^Enable support/)).toBeInTheDocument());
+  });
+
+  it('reports an edit serialised the way a process preset stores it', async () => {
+    const user = userEvent.setup();
+    const { onChange } = await renderPanel();
+
+    const input = screen.getByLabelText(/^Layer height/);
+    await user.clear(input);
+    await user.type(input, '0.16');
+
+    await waitFor(() => {
+      const [values, serialized] = onChange.mock.calls.at(-1)!;
+      expect(values.layer_height).toBe('0.16');
+      expect(serialized.layer_height).toBe('0.16');
+    });
+  });
+
+  it('puts the percent sign back on a percent option', async () => {
+    const user = userEvent.setup();
+    const { onChange } = await renderPanel();
+
+    const input = await showOption(user, 'Sparse infill density', 'sparse infill density');
+    await user.clear(input);
+    await user.type(input, '35');
+
+    // "35" and "35%" are different values to the slicer; the schema decides.
+    await waitFor(() => {
+      const [, serialized] = onChange.mock.calls.at(-1)!;
+      expect(serialized.sparse_infill_density).toBe('35%');
+    });
+  });
+
+  it('sends nothing for a value that equals the preset default', async () => {
+    const user = userEvent.setup();
+    const { onChange } = await renderPanel();
+
+    // wall_loops defaults to 2 — typing it back is not an override.
+    const input = await showOption(user, 'Wall loops', 'wall loops');
+    await user.clear(input);
+    await user.type(input, '2');
+
+    await waitFor(() => {
+      const [values, serialized] = onChange.mock.calls.at(-1)!;
+      expect(values.wall_loops).toBe('2');
+      expect(serialized).not.toHaveProperty('wall_loops');
+    });
+  });
+
+  it('greys out options the slicer disables at the current settings', async () => {
+    // sparse_infill_density at 0 turns off have_infill, which gates the infill
+    // pattern — the same rule the desktop slicer applies.
+    const user = userEvent.setup();
+    await renderPanel({ sparse_infill_density: '0%' });
+    const pattern = await showOption(user, 'Sparse infill pattern', 'sparse infill pattern');
+    expect(pattern).toBeDisabled();
+  });
+
+  it('keeps an option editable while infill is on', async () => {
+    const user = userEvent.setup();
+    await renderPanel({ sparse_infill_density: '15%' });
+    const pattern = await showOption(user, 'Sparse infill pattern', 'sparse infill pattern');
+    expect(pattern).not.toBeDisabled();
+  });
+
+  it('lets a field be emptied without snapping back to the default', async () => {
+    // Regression: dropping the key on an empty input made the control fall
+    // straight back to the preset default, so clearing a value to retype it
+    // appended to the old one ("0.2" + "0.16" = "0.2016").
+    const user = userEvent.setup();
+    await renderPanel();
+
+    const input = screen.getByLabelText(/^Layer height/);
+    await user.clear(input);
+    expect(input).toHaveValue(null);
+  });
+
+  it('clears every override from the header reset', async () => {
+    const user = userEvent.setup();
+    const { onChange } = await renderPanel({ layer_height: '0.16' });
+
+    await user.click(await screen.findByRole('button', { name: /Reset 1/ }));
+
+    const [values, serialized] = onChange.mock.calls.at(-1)!;
+    expect(values).toEqual({});
+    expect(serialized).toEqual({});
+  });
+
+  it('reverts a single option without touching the others', async () => {
+    const user = userEvent.setup();
+    const { onChange } = await renderPanel({ layer_height: '0.16', wall_loops: 4 });
+
+    const row = screen.getByLabelText(/^Layer height/).closest('div.group') as HTMLElement;
+    await user.click(within(row).getByRole('button', { name: 'Reset to default' }));
+
+    const [values] = onChange.mock.calls.at(-1)!;
+    expect(values).not.toHaveProperty('layer_height');
+    expect(values.wall_loops).toBe(4);
+  });
+});

+ 95 - 0
frontend/src/__tests__/utils/slicerToggle.test.ts

@@ -0,0 +1,95 @@
+import { describe, it, expect } from 'vitest';
+
+import processSchema from '../../data/slicer/process-schema.json';
+import processToggles from '../../data/slicer/process-toggle-rules.json';
+import { disabledKeys, makeConfigReader } from '../../lib/slicerToggle';
+import type { ProcessSchema, SettingValue } from '../../types/slicerSettings';
+
+const schema = processSchema as unknown as ProcessSchema;
+const toggles = processToggles as { locals: Record<string, string>; rules: Array<{ fields: string[]; enable_if: string }> };
+
+const disabled = (settings: Record<string, SettingValue>) => disabledKeys(settings, schema, toggles);
+
+describe('makeConfigReader', () => {
+  it('falls back to the schema default when the user has set nothing', () => {
+    expect(makeConfigReader({}, schema).get('wall_loops')).toBe(2);
+  });
+
+  it('prefers a user value over the default', () => {
+    expect(makeConfigReader({ wall_loops: 5 }, schema).get('wall_loops')).toBe(5);
+  });
+
+  it('reads the first entry of a per-extruder vector option', () => {
+    // default_acceleration is coFloats with a default of [500].
+    expect(makeConfigReader({}, schema).get('default_acceleration')).toBe(500);
+  });
+
+  it('treats an empty string as unset so a cleared input falls back to the default', () => {
+    expect(makeConfigReader({ wall_loops: '' }, schema).get('wall_loops')).toBe(2);
+  });
+});
+
+describe('disabledKeys', () => {
+  it('disables wall-dependent options when there are no walls', () => {
+    // have_perimeters = config->opt_int("wall_loops") > 0
+    const off = disabled({ wall_loops: 0 });
+    expect(off.has('seam_position')).toBe(true);
+    expect(off.has('detect_thin_wall')).toBe(true);
+  });
+
+  it('leaves wall-dependent options enabled at the default wall count', () => {
+    const off = disabled({});
+    expect(off.has('seam_position')).toBe(false);
+  });
+
+  it('parses a percent value when deciding an infill condition', () => {
+    // have_infill = config->option<ConfigOptionPercent>("sparse_infill_density")->value > 0
+    expect(disabled({ sparse_infill_density: '0%' }).has('sparse_infill_pattern')).toBe(true);
+    expect(disabled({ sparse_infill_density: '15%' }).has('sparse_infill_pattern')).toBe(false);
+  });
+
+  it('resolves a local that is defined in terms of other locals', () => {
+    // have_support_material = config->opt_bool("enable_support") || have_raft,
+    // and have_raft = config->opt_int("raft_layers") > 0.
+    expect(disabled({ enable_support: false, raft_layers: 0 }).has('support_style')).toBe(true);
+    expect(disabled({ enable_support: false, raft_layers: 3 }).has('support_style')).toBe(false);
+    expect(disabled({ enable_support: true, raft_layers: 0 }).has('support_style')).toBe(false);
+  });
+
+  it('matches a C++ enumerator against the option value it serialises to', () => {
+    // has_ironing = config->opt_enum<IroningType>("ironing_type") != IroningType::NoIroning
+    // The enumerator is `NoIroning`; the config value is "no ironing".
+    expect(disabled({ ironing_type: 'no ironing' }).has('ironing_flow')).toBe(true);
+    expect(disabled({ ironing_type: 'top' }).has('ironing_flow')).toBe(false);
+  });
+
+  it('leaves a field enabled when the enumerator matches no declared value', () => {
+    // support_is_organic tests `smsTreeOrganic`, which support_style spells
+    // "organic" — no transliteration reaches that, so the rule must fail open
+    // rather than disable organic-support fields at every setting.
+    const always = disabled({});
+    const flipped = disabled({ support_style: 'organic', enable_support: true });
+    expect(always.has('tree_support_branch_angle_organic')).toBe(false);
+    expect(flipped.has('tree_support_branch_angle_organic')).toBe(false);
+  });
+
+  it('only ever reports keys that exist in the schema', () => {
+    for (const key of disabled({})) expect(schema[key]).toBeDefined();
+  });
+
+  it('decides most of the vendored rules rather than failing open on nearly all', () => {
+    // Guards against a parser regression that silently degrades to "enable
+    // everything" — which would still pass every assertion above. Measured at
+    // 105 of 152 across these two profiles; the rest need settings these
+    // probes don't touch, or reference locals we deliberately cannot resolve.
+    const off = {
+      wall_loops: 0, sparse_infill_density: '0%', enable_support: false, raft_layers: 0,
+      spiral_mode: false, skirt_loops: 0, enable_prime_tower: false,
+      top_shell_layers: 0, bottom_shell_layers: 0, infill_combination: false,
+    } satisfies Record<string, SettingValue>;
+    const a = disabled({});
+    const b = disabled(off);
+    const decided = toggles.rules.filter((rule) => rule.fields.some((f) => a.has(f) || b.has(f)));
+    expect(decided.length).toBeGreaterThanOrEqual(Math.floor(toggles.rules.length * 0.6));
+  });
+});

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

@@ -1298,6 +1298,9 @@ export interface AppSettings {
   // Desktop "Open in Slicer" override (#1329). Null inherits from
   // preferred_slicer so existing installs behave identically.
   open_in_slicer: 'bambu_studio' | 'orcaslicer' | null;
+  // Where slicing runs, independent of which slicer binary the sidecar drives.
+  // Only 'sidecar' is implemented today; see lib/sliceEngines.ts.
+  slice_engine: 'sidecar' | 'browser';
   // Use the slicer-API sidecar for slicing (in-app modal) vs desktop URI scheme
   use_slicer_api: boolean;
   // Per-install sidecar URLs. Empty string falls back to the env defaults.
@@ -1636,6 +1639,15 @@ export interface SliceRequest {
   // instead of the picked profile triplet. The preset refs above are still
   // required by the backend validator but go unused on this path.
   use_embedded_settings?: boolean;
+  // Process settings the user edited in the slice modal's settings panel,
+  // already serialised into the string forms a process preset stores ("1" for
+  // a bool, "20%" for a percent, a list for the per-extruder vectors). Patched
+  // onto the resolved process JSON after the designer's carried tweaks, so an
+  // explicit choice here wins. Omitted when the panel is untouched.
+  process_overrides?: Record<string, string | string[]>;
+  // Design settings carried from the source 3MF (#2622) — a list of keys the
+  // file flags as changed from the system preset, not values.
+  design_overrides?: string[];
   // Layout passes the slicer runs before slicing (#2548), both off by
   // default because they move or rotate the objects the user laid out.
   // Unlike the fields above these are CLI actions rather than profile

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

@@ -16,7 +16,9 @@ import {
 import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
 import { useToast } from '../contexts/ToastContext';
 import { PlatePickerModal } from './PlatePickerModal';
+import SlicerSettingsPanel from './SlicerSettingsPanel';
 import type { DesignOverride, PlateFilament } from '../types/plates';
+import type { SettingValue } from '../types/slicerSettings';
 import {
   presetCompatibility,
   buildCompatibilityIndex,
@@ -255,6 +257,14 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
   const [designKeys, setDesignKeys] = useState<Set<string>>(new Set());
   const [designExpanded, setDesignExpanded] = useState(false);
 
+  // Process settings the user edited by hand in the settings panel. Two shapes
+  // are kept: the panel's editing values, and the same set serialised into the
+  // string forms a process preset stores. The panel owns the option schema, so
+  // it hands back both rather than making this component re-derive the second.
+  const [processOverrides, setProcessOverrides] = useState<Record<string, SettingValue>>({});
+  const [serializedProcessOverrides, setSerializedProcessOverrides] = useState<Record<string, string | string[]>>({});
+  const [settingsExpanded, setSettingsExpanded] = useState(false);
+
   // Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots
   // with one pick, or save the current selection as a new pipeline.
   const pipelinesQuery = useQuery({
@@ -553,6 +563,12 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
       // which the embedded-settings path never sends — so they are mutually
       // exclusive by construction (#2622).
       ...(!useEmbedded && designKeys.size > 0 ? { design_overrides: [...designKeys] } : {}),
+      // The user's own edits from the settings panel. Like design_overrides
+      // these patch the resolved process JSON, so the embedded-settings path
+      // (which sends no process JSON at all) cannot carry them.
+      ...(!useEmbedded && Object.keys(processOverrides).length > 0
+        ? { process_overrides: serializedProcessOverrides }
+        : {}),
       // Sent only when on. The backend defaults both to false, so omitting
       // them keeps the request identical to what older clients send.
       ...(autoOrient ? { auto_orient: true } : {}),
@@ -889,6 +905,47 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
                   )}
                 </div>
               )}
+
+              {/* Process settings, mirroring OrcaSlicer's own Print Settings
+                  tabs. Collapsed by default: the common case is slicing with a
+                  preset as-is, and 348 options unfolded would bury the preset
+                  pickers above. Hidden entirely in embedded mode, where no
+                  process JSON is sent for these to patch. */}
+              {!useEmbedded && (
+                <div className="rounded border border-bambu-dark-tertiary p-3">
+                  <button
+                    type="button"
+                    onClick={() => setSettingsExpanded((v) => !v)}
+                    className="flex w-full items-center justify-between gap-2 text-left"
+                  >
+                    <span className="text-sm text-white">
+                      {t('slice.processSettings', 'Process settings')}
+                      <span className="block text-xs text-bambu-gray/70">
+                        {t('slice.processSettingsHint', "Adjust the picked preset for this slice. Anything you don't touch stays as the preset defines it.")}
+                      </span>
+                    </span>
+                    <span className="shrink-0 text-xs text-bambu-gray">
+                      {Object.keys(serializedProcessOverrides).length > 0
+                        ? t('slice.processSettingsChanged', '{{count}} changed', {
+                            count: Object.keys(serializedProcessOverrides).length,
+                          })
+                        : t('slice.processSettingsUnchanged', 'Preset defaults')}
+                    </span>
+                  </button>
+                  {settingsExpanded && (
+                    <div className="mt-3 border-t border-bambu-dark-tertiary pt-3">
+                      <SlicerSettingsPanel
+                        values={processOverrides}
+                        onChange={(values, serialized) => {
+                          setProcessOverrides(values);
+                          setSerializedProcessOverrides(serialized);
+                        }}
+                        disabled={isEnqueuing}
+                      />
+                    </div>
+                  )}
+                </div>
+              )}
               {/* Bed-type override (#1337). Always visible, always enabled.
                   The backend patches curr_bed_type on the resolved process
                   JSON before forwarding to the sidecar. */}

+ 369 - 0
frontend/src/components/SlicerSettingsPanel.tsx

@@ -0,0 +1,369 @@
+/**
+ * Process-settings editor mirroring OrcaSlicer's own Print Settings tabs.
+ *
+ * Structure, labels, tooltips, bounds, defaults and enable/disable rules all
+ * come from metadata extracted from OrcaSlicer's C++ sources (see
+ * `src/data/slicer/`), so the pages, groups and ordering match what users see
+ * in the desktop slicer rather than a hand-picked subset.
+ *
+ * Option labels and tooltips are deliberately English-only for now: they are
+ * 348 strings lifted verbatim from `PrintConfig.cpp`, and hand-translating them
+ * into all 13 locales is not viable. The panel's own chrome — mode switch,
+ * search, buttons, empty states — goes through i18n as usual. OrcaSlicer ships
+ * its own translation catalogs for these strings, which is the obvious source
+ * if they are ever picked up.
+ *
+ * Values are held sparsely: only options the user actually changed are tracked
+ * and sent, so a slice with an untouched panel is byte-identical to one from
+ * before this panel existed.
+ */
+
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Search, RotateCcw, Loader2 } from 'lucide-react';
+
+import { disabledKeys, type ToggleRules } from '../lib/slicerToggle';
+import { defaultForDisplay, displaySidetext, isModified, numericBound, serializeOverrides } from '../lib/slicerSettings';
+import type { OptionMode, ProcessOption, ProcessSchema, ProcessUiTree, SettingValue } from '../types/slicerSettings';
+
+interface SlicerData {
+  schema: ProcessSchema;
+  tree: ProcessUiTree;
+  toggles: ToggleRules;
+}
+
+interface Props {
+  values: Record<string, SettingValue>;
+  /**
+   * Reports both the panel's editing state and the same values serialised for
+   * the slice request. Serialising here rather than in the caller keeps the
+   * option schema — the only thing that knows a percent needs its `%` back —
+   * in the one component that has already loaded it.
+   *
+   * `serialized` carries only options that actually differ from their default,
+   * so an untouched panel sends nothing at all.
+   */
+  onChange: (values: Record<string, SettingValue>, serialized: Record<string, string | string[]>) => void;
+  disabled?: boolean;
+}
+
+/** Visibility tiers, in increasing order of how much they reveal. */
+const MODES: OptionMode[] = ['simple', 'advanced', 'expert'];
+const MODE_RANK: Record<string, number> = { simple: 0, advanced: 1, expert: 2, develop: 3 };
+
+export default function SlicerSettingsPanel({ values, onChange, disabled = false }: Props) {
+  const { t } = useTranslation();
+  const [data, setData] = useState<SlicerData | null>(null);
+  const [mode, setMode] = useState<OptionMode>('simple');
+  const [page, setPage] = useState<string | null>(null);
+  const [query, setQuery] = useState('');
+
+  // 150 KB of extracted metadata has no business in the main bundle — it is
+  // only needed once someone opens this panel.
+  useEffect(() => {
+    let cancelled = false;
+    Promise.all([
+      import('../data/slicer/process-schema.json'),
+      import('../data/slicer/process-ui-tree.json'),
+      import('../data/slicer/process-toggle-rules.json'),
+    ]).then(([schema, tree, toggles]) => {
+      if (cancelled) return;
+      setData({
+        schema: (schema.default ?? schema) as unknown as ProcessSchema,
+        tree: (tree.default ?? tree) as unknown as ProcessUiTree,
+        toggles: (toggles.default ?? toggles) as unknown as ToggleRules,
+      });
+    });
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+
+  const off = useMemo(
+    () => (data ? disabledKeys(values, data.schema, data.toggles) : new Set<string>()),
+    [data, values],
+  );
+
+  const emit = (next: Record<string, SettingValue>) => {
+    if (!data) return;
+    // Only genuine deviations are worth sending: an override that equals the
+    // preset's own value is noise in the process JSON and makes the slice
+    // 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;
+    }
+    onChange(next, serializeOverrides(changed, data.schema));
+  };
+
+  const setValue = (key: string, value: SettingValue | undefined) => {
+    const next = { ...values };
+    if (value === undefined) delete next[key];
+    else next[key] = value;
+    emit(next);
+  };
+
+  // Search cuts across every page; without a query we show the selected page.
+  const visiblePages = useMemo(() => {
+    if (!data) return [];
+    const needle = query.trim().toLowerCase();
+    const withinMode = (key: string) => MODE_RANK[data.schema[key]?.mode ?? 'expert'] <= MODE_RANK[mode];
+    const matches = (key: string) => {
+      if (!needle) return true;
+      const opt = data.schema[key];
+      return key.includes(needle) || opt?.label?.toLowerCase().includes(needle) || opt?.tooltip?.toLowerCase().includes(needle);
+    };
+
+    return data.tree
+      .map((p) => ({
+        ...p,
+        groups: p.groups
+          .map((g) => ({ ...g, options: g.options.filter((k) => withinMode(k) && matches(k)) }))
+          .filter((g) => g.options.length > 0),
+      }))
+      .filter((p) => p.groups.length > 0);
+  }, [data, mode, query]);
+
+  const activePage = useMemo(() => {
+    if (visiblePages.length === 0) return null;
+    if (query.trim()) return null; // Searching shows every match, not one page.
+    return visiblePages.find((p) => p.page === page) ?? visiblePages[0];
+  }, [visiblePages, page, query]);
+
+  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]);
+
+  if (!data) {
+    return (
+      <div className="flex items-center justify-center gap-2 py-8 text-sm text-bambu-gray">
+        <Loader2 className="w-4 h-4 animate-spin" />
+        {t('slicerSettings.loading', 'Loading slicer settings...')}
+      </div>
+    );
+  }
+
+  const shownPages = activePage ? [activePage] : visiblePages;
+
+  return (
+    <div className="flex flex-col gap-3">
+      <div className="flex flex-wrap items-center gap-2">
+        <div className="flex rounded overflow-hidden border border-white/10">
+          {MODES.map((m) => (
+            <button
+              key={m}
+              type="button"
+              onClick={() => setMode(m)}
+              disabled={disabled}
+              className={`px-2.5 py-1 text-xs capitalize transition-colors ${
+                mode === m ? 'bg-bambu-green text-black' : 'text-bambu-gray hover:text-white'
+              }`}
+            >
+              {t(`slicerSettings.mode.${m}`, m)}
+            </button>
+          ))}
+        </div>
+
+        <div className="relative flex-1 min-w-[10rem]">
+          <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-bambu-gray" />
+          <input
+            type="search"
+            value={query}
+            onChange={(e) => setQuery(e.target.value)}
+            disabled={disabled}
+            placeholder={t('slicerSettings.searchPlaceholder', 'Search settings')}
+            className="w-full bg-black/30 border border-white/10 rounded pl-7 pr-2 py-1 text-xs text-white placeholder:text-bambu-gray/60"
+          />
+        </div>
+
+        {modifiedCount > 0 && (
+          <button
+            type="button"
+            onClick={() => emit({})}
+            disabled={disabled}
+            className="flex items-center gap-1 text-xs text-bambu-gray hover:text-white"
+          >
+            <RotateCcw className="w-3 h-3" />
+            {t('slicerSettings.resetAll', 'Reset {{count}}', { count: modifiedCount })}
+          </button>
+        )}
+      </div>
+
+      {!query.trim() && (
+        <div className="flex flex-wrap gap-1">
+          {visiblePages.map((p) => (
+            <button
+              key={p.page}
+              type="button"
+              onClick={() => setPage(p.page)}
+              disabled={disabled}
+              className={`px-2 py-1 text-xs rounded transition-colors ${
+                activePage?.page === p.page ? 'bg-white/10 text-white' : 'text-bambu-gray hover:text-white'
+              }`}
+            >
+              {p.page}
+            </button>
+          ))}
+        </div>
+      )}
+
+      {shownPages.length === 0 ? (
+        <p className="py-6 text-center text-xs text-bambu-gray">
+          {t('slicerSettings.noMatches', 'No settings match this search.')}
+        </p>
+      ) : (
+        <div className="flex flex-col gap-4 max-h-[22rem] overflow-y-auto pr-1">
+          {shownPages.map((p) => (
+            <div key={p.page} className="flex flex-col gap-3">
+              {query.trim() && <p className="text-[0.7rem] uppercase tracking-wide text-bambu-gray/70">{p.page}</p>}
+              {p.groups.map((g) => (
+                <fieldset key={`${p.page}:${g.group}`} className="flex flex-col gap-1.5">
+                  <legend className="text-xs font-medium text-white/80 mb-1">{g.group}</legend>
+                  {g.options.map((key) => (
+                    <OptionRow
+                      key={key}
+                      optionKey={key}
+                      option={data.schema[key]}
+                      value={values[key]}
+                      onChange={(v) => setValue(key, v)}
+                      disabled={disabled || off.has(key)}
+                      disabledBySlicer={off.has(key)}
+                    />
+                  ))}
+                </fieldset>
+              ))}
+            </div>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}
+
+interface RowProps {
+  optionKey: string;
+  option: ProcessOption;
+  value: SettingValue | undefined;
+  onChange: (value: SettingValue | undefined) => void;
+  disabled: boolean;
+  /** Greyed because the slicer's own rules turn it off, not because the form is busy. */
+  disabledBySlicer: boolean;
+}
+
+function OptionRow({ optionKey, option, value, onChange, disabled, disabledBySlicer }: RowProps) {
+  const { t } = useTranslation();
+  const modified = isModified(option, value);
+  const unit = displaySidetext(option);
+  const current = value === undefined ? defaultForDisplay(option) : String(value);
+
+  return (
+    <div className="flex items-center gap-2 group" title={option.tooltip}>
+      <label
+        htmlFor={`slicer-opt-${optionKey}`}
+        className={`flex-1 text-xs truncate ${disabledBySlicer ? 'text-bambu-gray/40' : 'text-bambu-gray'}`}
+      >
+        {option.label || optionKey}
+        {modified && <span className="ml-1 text-bambu-green" aria-hidden="true">•</span>}
+      </label>
+
+      <div className="flex items-center gap-1 shrink-0">
+        <OptionControl
+          id={`slicer-opt-${optionKey}`}
+          option={option}
+          current={current}
+          onChange={onChange}
+          disabled={disabled}
+        />
+        {unit && <span className="text-[0.65rem] text-bambu-gray/60 w-10 truncate">{unit}</span>}
+        <button
+          type="button"
+          onClick={() => onChange(undefined)}
+          disabled={disabled || !modified}
+          aria-label={t('slicerSettings.resetOption', 'Reset to default')}
+          className={`p-0.5 transition-opacity ${modified ? 'text-bambu-gray hover:text-white' : 'opacity-0 pointer-events-none'}`}
+        >
+          <RotateCcw className="w-3 h-3" />
+        </button>
+      </div>
+    </div>
+  );
+}
+
+interface ControlProps {
+  id: string;
+  option: ProcessOption;
+  current: string;
+  onChange: (value: SettingValue | undefined) => void;
+  disabled: boolean;
+}
+
+function OptionControl({ id, option, current, onChange, disabled }: ControlProps) {
+  const inputClass = 'bg-black/30 border border-white/10 rounded px-1.5 py-0.5 text-xs text-white disabled:opacity-40 w-24';
+
+  if (option.type === 'coBool') {
+    return (
+      <input
+        id={id}
+        type="checkbox"
+        checked={current === '1' || current === 'true'}
+        onChange={(e) => onChange(e.target.checked)}
+        disabled={disabled}
+        className="w-3.5 h-3.5 cursor-pointer disabled:opacity-40"
+      />
+    );
+  }
+
+  if (option.type === 'coEnum' && option.enum_values) {
+    return (
+      <select
+        id={id}
+        value={current}
+        onChange={(e) => onChange(e.target.value)}
+        disabled={disabled}
+        className={inputClass}
+      >
+        {option.enum_values.map((v, i) => (
+          <option key={v} value={v}>
+            {option.enum_labels?.[i] ?? v}
+          </option>
+        ))}
+      </select>
+    );
+  }
+
+  if (option.type === 'coInt' || option.type === 'coFloat' || option.type === 'coPercent') {
+    return (
+      <input
+        id={id}
+        type="number"
+        value={current.replace('%', '')}
+        min={numericBound(option.min)}
+        max={numericBound(option.max)}
+        step={option.type === 'coInt' ? 1 : 'any'}
+        // An empty field is kept as an empty string rather than dropped.
+        // Dropping it would fall the input straight back to the default, so
+        // clearing a value to retype it would silently append to the old one.
+        // Empty never counts as modified, so nothing is sent for it either way;
+        // the revert button is what actually removes the key.
+        onChange={(e) => onChange(e.target.value)}
+        disabled={disabled}
+        className={inputClass}
+      />
+    );
+  }
+
+  // coFloatOrPercent, the vector types and coString all accept free text: they
+  // hold values like "50%", "0.42" or a comma-separated per-extruder list, none
+  // of which a number input can represent.
+  return (
+    <input
+      id={id}
+      type="text"
+      value={current}
+      onChange={(e) => onChange(e.target.value === '' ? undefined : e.target.value)}
+      disabled={disabled}
+      className={inputClass}
+    />
+  );
+}

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

@@ -1790,6 +1790,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: 'Slice-Engine',
+    sliceEngineSidecar: 'Server-Sidecar',
+    sliceEngineSidecarHint: 'Das Slicing läuft auf dem Server im Slicer-Sidecar-Container.',
+    sliceEngineBrowser: 'Im Browser',
+    sliceEngineBrowserHint: 'Das Slicing läuft auf diesem Gerät, ohne Server.',
     title: 'Einstellungen',
     general: 'Allgemein',
     // Tab names
@@ -4273,7 +4278,23 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    loading: 'Slicer-Einstellungen werden geladen…',
+    mode: {
+      simple: 'Einfach',
+      advanced: 'Erweitert',
+      expert: 'Experte',
+    },
+    searchPlaceholder: 'Einstellungen suchen',
+    resetAll: '{{count}} zurücksetzen',
+    resetOption: 'Auf Standard zurücksetzen',
+    noMatches: 'Keine Einstellungen passen zu dieser Suche.',
+  },
   slice: {
+    processSettings: 'Prozesseinstellungen',
+    processSettingsHint: 'Passen Sie das gewählte Profil für diesen Slice an. Alles, was Sie nicht ändern, bleibt wie im Profil definiert.',
+    processSettingsChanged: '{{count}} geändert',
+    processSettingsUnchanged: 'Profilstandard',
     title: 'Modell slicen',
     action: 'Slicen',
     actionAll: 'Alle {{count}} Plates slicen',

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

@@ -1807,6 +1807,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: 'Slice engine',
+    sliceEngineSidecar: 'Server sidecar',
+    sliceEngineSidecarHint: 'Slicing runs on the server, in the slicer sidecar container.',
+    sliceEngineBrowser: 'In browser',
+    sliceEngineBrowserHint: 'Slicing runs on this device, with no server involved.',
     title: 'Settings',
     general: 'General',
     // Tab names
@@ -4307,7 +4312,23 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    loading: 'Loading slicer settings…',
+    mode: {
+      simple: 'Simple',
+      advanced: 'Advanced',
+      expert: 'Expert',
+    },
+    searchPlaceholder: 'Search settings',
+    resetAll: 'Reset {{count}}',
+    resetOption: 'Reset to default',
+    noMatches: 'No settings match this search.',
+  },
   slice: {
+    processSettings: 'Process settings',
+    processSettingsHint: "Adjust the picked preset for this slice. Anything you don't touch stays as the preset defines it.",
+    processSettingsChanged: '{{count}} changed',
+    processSettingsUnchanged: 'Preset defaults',
     title: 'Slice model',
     action: 'Slice',
     actionAll: 'Slice all {{count}} plates',

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

@@ -1791,6 +1791,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: 'Motor de laminado',
+    sliceEngineSidecar: 'Sidecar del servidor',
+    sliceEngineSidecarHint: 'El laminado se ejecuta en el servidor, en el contenedor sidecar del laminador.',
+    sliceEngineBrowser: 'En el navegador',
+    sliceEngineBrowserHint: 'El laminado se ejecuta en este dispositivo, sin servidor.',
     title: 'Ajustes',
     general: 'General',
     // Tab names
@@ -4275,7 +4280,23 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    loading: 'Cargando ajustes del laminador…',
+    mode: {
+      simple: 'Simple',
+      advanced: 'Avanzado',
+      expert: 'Experto',
+    },
+    searchPlaceholder: 'Buscar ajustes',
+    resetAll: 'Restablecer {{count}}',
+    resetOption: 'Restablecer al valor predeterminado',
+    noMatches: 'Ningún ajuste coincide con esta búsqueda.',
+  },
   slice: {
+    processSettings: 'Ajustes de proceso',
+    processSettingsHint: 'Ajusta el perfil seleccionado para este corte. Todo lo que no toques se mantiene como lo define el perfil.',
+    processSettingsChanged: '{{count}} cambiados',
+    processSettingsUnchanged: 'Valores del perfil',
     title: 'Laminar modelo',
     action: 'Laminar',
     actionAll: 'Laminar las {{count}} bandejas',

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

@@ -1790,6 +1790,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: 'Moteur de découpage',
+    sliceEngineSidecar: 'Sidecar serveur',
+    sliceEngineSidecarHint: "Le découpage s'exécute sur le serveur, dans le conteneur sidecar du trancheur.",
+    sliceEngineBrowser: 'Dans le navigateur',
+    sliceEngineBrowserHint: "Le découpage s'exécute sur cet appareil, sans serveur.",
     title: 'Paramètres',
     general: 'Général',
     // Tab names
@@ -4262,7 +4267,23 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    loading: 'Chargement des paramètres du trancheur…',
+    mode: {
+      simple: 'Simple',
+      advanced: 'Avancé',
+      expert: 'Expert',
+    },
+    searchPlaceholder: 'Rechercher un paramètre',
+    resetAll: 'Réinitialiser {{count}}',
+    resetOption: 'Réinitialiser à la valeur par défaut',
+    noMatches: 'Aucun paramètre ne correspond à cette recherche.',
+  },
   slice: {
+    processSettings: 'Paramètres de process',
+    processSettingsHint: 'Ajustez le profil choisi pour ce découpage. Tout ce que vous ne modifiez pas reste tel que défini par le profil.',
+    processSettingsChanged: '{{count}} modifiés',
+    processSettingsUnchanged: 'Valeurs du profil',
     title: 'Slicer le modèle',
     action: 'Slicer',
     actionAll: 'Slicer les {{count}} plateaux',

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

@@ -1790,6 +1790,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: 'Motore di slicing',
+    sliceEngineSidecar: 'Sidecar del server',
+    sliceEngineSidecarHint: 'Lo slicing viene eseguito sul server, nel container sidecar dello slicer.',
+    sliceEngineBrowser: 'Nel browser',
+    sliceEngineBrowserHint: 'Lo slicing viene eseguito su questo dispositivo, senza server.',
     title: 'Impostazioni',
     general: 'Generale',
     // Tab names
@@ -4261,7 +4266,23 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    loading: 'Caricamento impostazioni dello slicer…',
+    mode: {
+      simple: 'Semplice',
+      advanced: 'Avanzato',
+      expert: 'Esperto',
+    },
+    searchPlaceholder: 'Cerca impostazioni',
+    resetAll: 'Ripristina {{count}}',
+    resetOption: 'Ripristina il valore predefinito',
+    noMatches: 'Nessuna impostazione corrisponde a questa ricerca.',
+  },
   slice: {
+    processSettings: 'Impostazioni di processo',
+    processSettingsHint: 'Regola il profilo scelto per questo slice. Tutto ciò che non tocchi resta come definito dal profilo.',
+    processSettingsChanged: '{{count}} modificate',
+    processSettingsUnchanged: 'Valori del profilo',
     title: 'Slicing modello',
     action: 'Slice',
     actionAll: 'Slicia tutti i {{count}} piatti',

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

@@ -1789,6 +1789,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: 'スライスエンジン',
+    sliceEngineSidecar: 'サーバーサイドカー',
+    sliceEngineSidecarHint: 'スライスはサーバー上のスライサーサイドカーコンテナーで実行されます。',
+    sliceEngineBrowser: 'ブラウザー内',
+    sliceEngineBrowserHint: 'スライスはサーバーを介さず、このデバイス上で実行されます。',
     title: '設定',
     general: '一般',
     // Tab names
@@ -4273,7 +4278,23 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    loading: 'スライサー設定を読み込んでいます…',
+    mode: {
+      simple: 'シンプル',
+      advanced: '詳細',
+      expert: 'エキスパート',
+    },
+    searchPlaceholder: '設定を検索',
+    resetAll: '{{count}} 件をリセット',
+    resetOption: '既定値に戻す',
+    noMatches: 'この検索に一致する設定はありません。',
+  },
   slice: {
+    processSettings: 'プロセス設定',
+    processSettingsHint: 'このスライス用に選択したプリセットを調整します。変更しない項目はプリセットの定義のままです。',
+    processSettingsChanged: '{{count}} 件変更',
+    processSettingsUnchanged: 'プリセットの既定値',
     title: 'モデルをスライス',
     action: 'スライス',
     actionAll: '{{count}} プレートすべてをスライス',

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

@@ -1704,6 +1704,11 @@ export default {
     configureSettings: '유지보수 유형 및 간격 설정'
   },
   settings: {
+    sliceEngine: '슬라이스 엔진',
+    sliceEngineSidecar: '서버 사이드카',
+    sliceEngineSidecarHint: '슬라이싱이 서버의 슬라이서 사이드카 컨테이너에서 실행됩니다.',
+    sliceEngineBrowser: '브라우저에서',
+    sliceEngineBrowserHint: '슬라이싱이 서버 없이 이 기기에서 실행됩니다.',
     title: '설정',
     general: '일반',
     tabs: {
@@ -4064,7 +4069,23 @@ export default {
       },
     },
   },
+  slicerSettings: {
+    loading: '슬라이서 설정을 불러오는 중…',
+    mode: {
+      simple: '간단',
+      advanced: '고급',
+      expert: '전문가',
+    },
+    searchPlaceholder: '설정 검색',
+    resetAll: '{{count}}개 초기화',
+    resetOption: '기본값으로 되돌리기',
+    noMatches: '검색과 일치하는 설정이 없습니다.',
+  },
   slice: {
+    processSettings: '프로세스 설정',
+    processSettingsHint: '이 슬라이스에 사용할 프리셋을 조정합니다. 건드리지 않은 항목은 프리셋 정의를 그대로 따릅니다.',
+    processSettingsChanged: '{{count}}개 변경됨',
+    processSettingsUnchanged: '프리셋 기본값',
     title: '모델 슬라이싱',
     action: '슬라이싱',
     slicing: '슬라이싱 중…',

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

@@ -1790,6 +1790,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: 'Motor de fatiamento',
+    sliceEngineSidecar: 'Sidecar do servidor',
+    sliceEngineSidecarHint: 'O fatiamento roda no servidor, no contêiner sidecar do fatiador.',
+    sliceEngineBrowser: 'No navegador',
+    sliceEngineBrowserHint: 'O fatiamento roda neste dispositivo, sem envolver o servidor.',
     title: 'Configurações',
     general: 'Geral',
     // Tab names
@@ -4261,7 +4266,23 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    loading: 'Carregando configurações do fatiador…',
+    mode: {
+      simple: 'Simples',
+      advanced: 'Avançado',
+      expert: 'Especialista',
+    },
+    searchPlaceholder: 'Buscar configurações',
+    resetAll: 'Redefinir {{count}}',
+    resetOption: 'Redefinir para o padrão',
+    noMatches: 'Nenhuma configuração corresponde a esta busca.',
+  },
   slice: {
+    processSettings: 'Configurações de processo',
+    processSettingsHint: 'Ajuste o perfil escolhido para este fatiamento. Tudo o que você não alterar permanece como o perfil define.',
+    processSettingsChanged: '{{count}} alterados',
+    processSettingsUnchanged: 'Padrões do perfil',
     title: 'Fatiar modelo',
     action: 'Fatiar',
     actionAll: 'Fatiar todas as {{count}} bandejas',

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

@@ -1701,6 +1701,11 @@ export default {
     configureSettings: "Настроить виды обслуживания и интервалы",
   },
   settings: {
+    sliceEngine: 'Движок нарезки',
+    sliceEngineSidecar: 'Серверный sidecar',
+    sliceEngineSidecarHint: 'Нарезка выполняется на сервере, в контейнере sidecar слайсера.',
+    sliceEngineBrowser: 'В браузере',
+    sliceEngineBrowserHint: 'Нарезка выполняется на этом устройстве, без участия сервера.',
     title: "Настройки",
     general: "Общие",
     tabs: {
@@ -4056,7 +4061,23 @@ export default {
       },
     },
   },
+  slicerSettings: {
+    loading: 'Загрузка настроек слайсера…',
+    mode: {
+      simple: 'Простой',
+      advanced: 'Расширенный',
+      expert: 'Эксперт',
+    },
+    searchPlaceholder: 'Поиск параметров',
+    resetAll: 'Сбросить: {{count}}',
+    resetOption: 'Сбросить к значению по умолчанию',
+    noMatches: 'Нет параметров, соответствующих запросу.',
+  },
   slice: {
+    processSettings: 'Параметры процесса',
+    processSettingsHint: 'Настройте выбранный профиль для этой нарезки. Всё, что вы не измените, останется как задано в профиле.',
+    processSettingsChanged: 'изменено: {{count}}',
+    processSettingsUnchanged: 'Значения профиля',
     title: "Нарезка модели",
     action: "Нарезать",
     actionAll: "Нарезать все пластины ({{count}})",

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

@@ -1792,6 +1792,11 @@ export default {
 
   // Ayarlar sayfası
   settings: {
+    sliceEngine: 'Dilimleme motoru',
+    sliceEngineSidecar: 'Sunucu sidecar',
+    sliceEngineSidecarHint: 'Dilimleme sunucuda, dilimleyici sidecar konteynerinde çalışır.',
+    sliceEngineBrowser: 'Tarayıcıda',
+    sliceEngineBrowserHint: 'Dilimleme sunucu olmadan bu cihazda çalışır.',
     title: 'Ayarlar',
     general: 'Genel',
     // Sekme adları
@@ -4262,7 +4267,23 @@ export default {
   },
 
   // Dilimle (SliceModal ile slicer-API entegrasyonu)
+  slicerSettings: {
+    loading: 'Dilimleyici ayarları yükleniyor…',
+    mode: {
+      simple: 'Basit',
+      advanced: 'Gelişmiş',
+      expert: 'Uzman',
+    },
+    searchPlaceholder: 'Ayarlarda ara',
+    resetAll: '{{count}} ayarı sıfırla',
+    resetOption: 'Varsayılana sıfırla',
+    noMatches: 'Bu aramayla eşleşen ayar yok.',
+  },
   slice: {
+    processSettings: 'İşlem ayarları',
+    processSettingsHint: 'Seçilen ön ayarı bu dilimleme için düzenleyin. Dokunmadığınız her şey ön ayardaki gibi kalır.',
+    processSettingsChanged: '{{count}} değişti',
+    processSettingsUnchanged: 'Ön ayar varsayılanları',
     title: 'Modeli dilimle',
     action: 'Dilimle',
     actionAll: 'Tüm {{count}} plakayı dilimle',

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

@@ -1807,6 +1807,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: 'Рушій нарізання',
+    sliceEngineSidecar: 'Серверний sidecar',
+    sliceEngineSidecarHint: 'Нарізання виконується на сервері, у контейнері sidecar слайсера.',
+    sliceEngineBrowser: 'У браузері',
+    sliceEngineBrowserHint: 'Нарізання виконується на цьому пристрої, без сервера.',
     title: "Налаштування",
     general: "Загальні",
     // Tab names
@@ -4306,7 +4311,23 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    loading: 'Завантаження налаштувань слайсера…',
+    mode: {
+      simple: 'Простий',
+      advanced: 'Розширений',
+      expert: 'Експерт',
+    },
+    searchPlaceholder: 'Пошук параметрів',
+    resetAll: 'Скинути: {{count}}',
+    resetOption: 'Скинути до типового значення',
+    noMatches: 'Немає параметрів, що відповідають запиту.',
+  },
   slice: {
+    processSettings: 'Параметри процесу',
+    processSettingsHint: 'Налаштуйте вибраний профіль для цього нарізання. Усе, чого ви не змінили, лишається як визначено профілем.',
+    processSettingsChanged: 'змінено: {{count}}',
+    processSettingsUnchanged: 'Значення профілю',
     title: "Нарізання моделі",
     action: "Нарізати",
     actionAll: "Нарізати всі пластини ({{count}})",

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

@@ -1790,6 +1790,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: '切片引擎',
+    sliceEngineSidecar: '服务器 sidecar',
+    sliceEngineSidecarHint: '切片在服务器上的切片 sidecar 容器中运行。',
+    sliceEngineBrowser: '在浏览器中',
+    sliceEngineBrowserHint: '切片在本设备上运行,不经过服务器。',
     title: '设置',
     general: '通用',
     // Tab names
@@ -4261,7 +4266,23 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    loading: '正在加载切片设置…',
+    mode: {
+      simple: '简单',
+      advanced: '高级',
+      expert: '专家',
+    },
+    searchPlaceholder: '搜索设置',
+    resetAll: '重置 {{count}} 项',
+    resetOption: '恢复默认值',
+    noMatches: '没有与搜索匹配的设置。',
+  },
   slice: {
+    processSettings: '工艺设置',
+    processSettingsHint: '为本次切片调整所选预设。未改动的项目仍按预设定义。',
+    processSettingsChanged: '已更改 {{count}} 项',
+    processSettingsUnchanged: '预设默认值',
     title: '切片模型',
     action: '切片',
     actionAll: '切片全部 {{count}} 个盘面',

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

@@ -1790,6 +1790,11 @@ export default {
 
   // Settings page
   settings: {
+    sliceEngine: '切片引擎',
+    sliceEngineSidecar: '伺服器 sidecar',
+    sliceEngineSidecarHint: '切片在伺服器上的切片 sidecar 容器中執行。',
+    sliceEngineBrowser: '在瀏覽器中',
+    sliceEngineBrowserHint: '切片在本裝置上執行,不經過伺服器。',
     title: '設定',
     general: '通用',
     // Tab names
@@ -4261,7 +4266,23 @@ export default {
   },
 
   // Slice (slicer-API integration via SliceModal)
+  slicerSettings: {
+    loading: '正在載入切片設定…',
+    mode: {
+      simple: '簡易',
+      advanced: '進階',
+      expert: '專家',
+    },
+    searchPlaceholder: '搜尋設定',
+    resetAll: '重設 {{count}} 項',
+    resetOption: '恢復預設值',
+    noMatches: '沒有符合搜尋的設定。',
+  },
   slice: {
+    processSettings: '列印參數',
+    processSettingsHint: '為本次切片調整所選預設。未變更的項目仍依預設定義。',
+    processSettingsChanged: '已變更 {{count}} 項',
+    processSettingsUnchanged: '預設預設值',
     title: '切片模型',
     action: '切片',
     actionAll: '切片全部 {{count}} 個盤面',

+ 75 - 0
frontend/src/lib/sliceEngines.ts

@@ -0,0 +1,75 @@
+/**
+ * Registry of the available slicing engines.
+ *
+ * "Engine" here means *where slicing runs*, which is a separate axis from the
+ * `preferred_slicer` setting (that one only selects which slicer binary the
+ * server-side sidecar drives). Keeping them apart avoids having to represent
+ * combinations that don't exist — there is no browser build of BambuStudio, so
+ * a single dropdown mixing the two would offer choices that cannot work.
+ *
+ * Today exactly one engine is registered. The registry exists so that adding a
+ * browser/WASM engine is a matter of pushing a second entry: the settings card
+ * and the slice modal both derive their UI from `availableEngines()`, so a
+ * second engine makes the pickers appear without either of them changing.
+ *
+ * Deliberately *not* shipping a disabled "In browser" option in the meantime.
+ * An option a user can see but never pick reads as a broken feature, and there
+ * is nothing behind it yet: the WASM engine returns raw G-code, while dispatch
+ * needs a `.gcode.3mf` container, so browser slicing cannot reach a printer
+ * until that packaging exists.
+ */
+
+export type SliceEngineId = 'sidecar' | 'browser';
+
+export interface SliceEngine {
+  id: SliceEngineId;
+  /** i18n key for the human-readable name. */
+  labelKey: string;
+  /** i18n key for the one-line explanation shown under the picker. */
+  descriptionKey: string;
+  /**
+   * False while an engine is defined but not yet usable. Unavailable engines
+   * are never offered; they exist here so the surrounding code can be written
+   * against the full set rather than special-cased later.
+   */
+  available: boolean;
+}
+
+const ENGINES: SliceEngine[] = [
+  {
+    id: 'sidecar',
+    labelKey: 'settings.sliceEngineSidecar',
+    descriptionKey: 'settings.sliceEngineSidecarHint',
+    available: true,
+  },
+  {
+    id: 'browser',
+    labelKey: 'settings.sliceEngineBrowser',
+    descriptionKey: 'settings.sliceEngineBrowserHint',
+    available: false,
+  },
+];
+
+export const DEFAULT_SLICE_ENGINE: SliceEngineId = 'sidecar';
+
+/** Engines a user can actually pick right now. */
+export function availableEngines(): SliceEngine[] {
+  return ENGINES.filter((e) => e.available);
+}
+
+/** True when there is a real choice to present. */
+export function hasEngineChoice(): boolean {
+  return availableEngines().length > 1;
+}
+
+/**
+ * Resolves a stored or per-job engine id to one that can actually run.
+ *
+ * A setting can outlive the engine it names — an install that had browser
+ * slicing enabled and then loaded a build without it must still be able to
+ * slice, so an unavailable id falls back rather than failing.
+ */
+export function resolveEngine(id: string | null | undefined): SliceEngineId {
+  const match = availableEngines().find((e) => e.id === id);
+  return match?.id ?? DEFAULT_SLICE_ENGINE;
+}

+ 113 - 0
frontend/src/lib/slicerSettings.ts

@@ -0,0 +1,113 @@
+/**
+ * Conversion between the settings panel's editing values and the string forms
+ * OrcaSlicer / BambuStudio write into a process preset JSON.
+ *
+ * This matters more than it looks. The values we send are merged into the
+ * `--load-settings` process JSON, and that JSON is parsed by the slicer CLI,
+ * which validates far more strictly than the GUI: a percent option written as
+ * `"20"` instead of `"20%"` is a different value, and a bare `true` where the
+ * config expects `"1"` fails the parse outright. The panel therefore always
+ * serialises through the schema, never by guessing from the JavaScript type.
+ */
+
+import type { ProcessOption, ProcessSchema, SettingValue } from '../types/slicerSettings';
+
+/** Option types whose config value is a per-extruder vector. */
+const VECTOR_TYPES = new Set(['coBools', 'coFloats', 'coFloatsOrPercents']);
+
+export const isVectorOption = (option: ProcessOption): boolean => VECTOR_TYPES.has(option.type);
+
+/**
+ * Numeric bound from the schema, or `undefined` when the extractor left an
+ * unparsed C++ literal behind (`"0.3f"`, `"def_infill_anchor_min->sidetext"`).
+ */
+export function numericBound(bound: number | string | undefined): number | undefined {
+  if (typeof bound === 'number') return Number.isFinite(bound) ? bound : undefined;
+  if (typeof bound !== 'string') return undefined;
+  const n = Number.parseFloat(bound);
+  return Number.isFinite(n) ? n : undefined;
+}
+
+/**
+ * A unit suffix worth showing. A few entries carry an unresolved C++ expression
+ * where the extractor could not follow a reference (`def_x->sidetext`); showing
+ * that to a user would be worse than showing no unit at all.
+ */
+export function displaySidetext(option: ProcessOption): string | undefined {
+  const s = option.sidetext;
+  if (!s || s.includes('->') || s.includes('::')) return 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;
+  if (d === undefined) return '';
+  if (Array.isArray(d)) {
+    // A handful of defaults were mis-extracted from C++ float literals —
+    // `0.f` became [0, "f"]. Drop the stray suffix token rather than render it.
+    return d.filter((v) => v !== 'f').map(String).join(', ');
+  }
+  if (typeof d === 'boolean') return d ? '1' : '0';
+  return String(d);
+}
+
+/**
+ * Serialises one edited value into its process-JSON form.
+ *
+ * Vector options are written back as arrays because that is how the config
+ * stores them; scalars become strings, which is what every Bambu process preset
+ * uses even for numeric options.
+ */
+export function serializeSetting(option: ProcessOption, value: SettingValue): string | string[] {
+  if (isVectorOption(option)) {
+    const parts = Array.isArray(value) ? value.map(String) : String(value).split(',');
+    return parts.map((p) => p.trim()).filter((p) => p !== '');
+  }
+
+  if (option.type === 'coBool') {
+    if (typeof value === 'boolean') return value ? '1' : '0';
+    return value === '1' || value === 'true' || value === 1 ? '1' : '0';
+  }
+
+  const raw = String(value).trim();
+
+  if (option.type === 'coPercent') {
+    // The config spells percents with the sign; the input edits the number.
+    return raw.endsWith('%') ? raw : `${raw}%`;
+  }
+
+  return raw;
+}
+
+/** Serialises the panel's sparse override map for the slice request. */
+export function serializeOverrides(values: Record<string, SettingValue>, schema: ProcessSchema): Record<string, string | string[]> {
+  const out: Record<string, string | string[]> = {};
+  for (const [key, value] of Object.entries(values)) {
+    const option = schema[key];
+    // A key with no schema entry cannot be serialised correctly, and sending it
+    // raw risks a slice failure that is hard to trace back to this panel.
+    if (!option) continue;
+    out[key] = serializeSetting(option, value);
+  }
+  return out;
+}
+
+/**
+ * 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.
+ */
+export function isModified(option: ProcessOption, value: SettingValue | undefined): boolean {
+  if (value === undefined || value === '') return false;
+  const serialized = serializeSetting(option, value);
+  const asString = Array.isArray(serialized) ? serialized.join(', ') : serialized;
+
+  const d = option.default;
+  if (d === undefined) return asString !== '';
+
+  const defaultSerialized = serializeSetting(option, Array.isArray(d) ? d.filter((v) => v !== 'f').map(String).join(', ') : (d as SettingValue));
+  const defaultString = Array.isArray(defaultSerialized) ? defaultSerialized.join(', ') : defaultSerialized;
+
+  return asString !== defaultString;
+}

+ 469 - 0
frontend/src/lib/slicerToggle.ts

@@ -0,0 +1,469 @@
+/**
+ * Evaluates OrcaSlicer's `toggle_print_fff_options` enable/disable rules so our
+ * process-settings panel greys out the same fields the real slicer does.
+ *
+ * The vendored `process-toggle-rules.json` carries the rules verbatim from the
+ * C++ source: each rule is a list of option keys plus an `enable_if` expression
+ * written in C++, referencing named locals that are themselves C++ expressions.
+ * Rather than hand-translate a subset (which is what upstream's own evaluator
+ * does — 11 of 68 locals, the rest silently enabled), this interprets the
+ * expressions directly and resolves locals recursively, so a local defined in
+ * terms of three other locals costs nothing extra to support.
+ *
+ * The cardinal rule is **fail open**: anything we cannot decide with certainty
+ * leaves the field enabled. A wrongly-greyed control hides a setting the user
+ * needs and looks like a bug; a wrongly-enabled one merely lets them set
+ * something the slicer will ignore, which is the pre-existing behaviour of every
+ * other settings surface in Bambuddy. Every `undefined` return below is that
+ * rule being applied, not an oversight.
+ *
+ * Deliberately not `eval` / `new Function`: the expressions are vendored data
+ * rather than user input, but the frontend runs under a CSP without
+ * `unsafe-eval` and a 120-line recursive-descent parser is easier to test than
+ * a regex pipeline that rewrites C++ into JavaScript.
+ */
+
+import type { ProcessSchema, SettingValue } from '../types/slicerSettings';
+
+/**
+ * A read of an enum-typed option, carrying the key so a comparison against a
+ * C++ enumerator can be checked against that option's declared values.
+ */
+interface EnumRead {
+  enumKey: string;
+  value: string | undefined;
+}
+
+/** A resolved expression value. `undefined` means "could not determine". */
+type Value = boolean | number | string | EnumRead | undefined;
+
+const isEnumRead = (v: Value): v is EnumRead => typeof v === 'object' && v !== null && 'enumKey' in v;
+
+/** A bare C++ enumerator (`ipGyroid`, `IroningType::NoIroning`) seen in an expression. */
+const ENUM_SYMBOL = 'enum:';
+
+// --- Config access ---------------------------------------------------------
+
+export interface ConfigReader {
+  /** Raw value for a key: the user's override if set, else the schema default. */
+  get(key: string): Value;
+  has(key: string): boolean;
+}
+
+/** Numeric view of a value: "20%" -> 20, [500] -> 500, "0.42" -> 0.42. */
+function asNumber(v: Value): number | undefined {
+  if (typeof v === 'number') return v;
+  if (typeof v === 'boolean') return v ? 1 : 0;
+  if (typeof v !== 'string') return undefined;
+  const n = Number.parseFloat(v);
+  return Number.isFinite(n) ? n : undefined;
+}
+
+function asBoolean(v: Value): boolean | undefined {
+  if (typeof v === 'boolean') return v;
+  if (typeof v === 'number') return v !== 0;
+  if (v === '1' || v === 'true') return true;
+  if (v === '0' || v === 'false') return false;
+  return undefined;
+}
+
+/**
+ * Reads settings with schema defaults behind them. Vector options (`coFloats`
+ * and friends) are per-extruder; every condition in the rule set tests the
+ * first entry, which is what `opt_float_nullable(key, variant_index)` reads for
+ * the active variant.
+ */
+export function makeConfigReader(settings: Record<string, SettingValue>, schema: ProcessSchema): ConfigReader {
+  const read = (key: string): Value => {
+    let v: unknown = settings[key];
+    if (v === undefined || v === '') v = schema[key]?.default;
+    if (Array.isArray(v)) v = v[0];
+    if (typeof v === 'boolean' || typeof v === 'number' || typeof v === 'string') return v;
+    return undefined;
+  };
+  return { get: read, has: (key) => key in schema };
+}
+
+// --- Tokenizer -------------------------------------------------------------
+
+type Token = { kind: 'num'; value: number } | { kind: 'str'; value: string } | { kind: 'id'; value: string } | { kind: 'op'; value: string };
+
+// Longest-first: `->` must be tried before `-`, `<=` before `<`.
+const OPERATORS = ['->', '||', '&&', '==', '!=', '<=', '>=', '(', ')', ',', '<', '>', '!'];
+
+function tokenize(src: string): Token[] | undefined {
+  const tokens: Token[] = [];
+  let i = 0;
+  while (i < src.length) {
+    const c = src[i];
+    if (c === ' ' || c === '\t' || c === '\n') {
+      i += 1;
+      continue;
+    }
+    if (c === '"') {
+      const end = src.indexOf('"', i + 1);
+      if (end < 0) return undefined;
+      tokens.push({ kind: 'str', value: src.slice(i + 1, end) });
+      i = end + 1;
+      continue;
+    }
+    // C++ float literals carry an `f` suffix (`0.3f`) that the extractor left
+    // intact in a few min/max bounds and defaults.
+    const num = /^\d+(\.\d*)?f?/.exec(src.slice(i));
+    if (num && /^[\d]/.test(c)) {
+      tokens.push({ kind: 'num', value: Number.parseFloat(num[0]) });
+      i += num[0].length;
+      continue;
+    }
+    const op = OPERATORS.find((o) => src.startsWith(o, i));
+    if (op) {
+      tokens.push({ kind: 'op', value: op });
+      i += op.length;
+      continue;
+    }
+    // Identifiers, including the `->`, `::`, `<>` decorations of the C++
+    // accessor forms; the parser strips those apart below.
+    const id = /^[A-Za-z_][A-Za-z0-9_]*(::[A-Za-z_][A-Za-z0-9_]*)*/.exec(src.slice(i));
+    if (id) {
+      tokens.push({ kind: 'id', value: id[0] });
+      i += id[0].length;
+      continue;
+    }
+    return undefined; // Unknown character — fail open.
+  }
+  return tokens;
+}
+
+// --- Parser / evaluator ----------------------------------------------------
+
+/** Accessor names that read a config key named by their first string argument. */
+const ACCESSORS = new Set([
+  'opt_bool',
+  'opt_int',
+  'opt_float',
+  'opt_float_nullable',
+  'opt_int_nullable',
+  'opt_bool_nullable',
+  'opt_enum',
+  'opt_string',
+  'option',
+  'has',
+]);
+
+class Evaluator {
+  private tokens: Token[] = [];
+  private pos = 0;
+
+  private readonly cfg: ConfigReader;
+  private readonly locals: Record<string, string>;
+  private readonly schema: ProcessSchema;
+  /** Locals currently being resolved — guards the (unlikely) cyclic definition. */
+  private readonly resolving: Set<string>;
+  private readonly memo: Map<string, Value>;
+
+  constructor(cfg: ConfigReader, locals: Record<string, string>, schema: ProcessSchema, resolving: Set<string>, memo: Map<string, Value>) {
+    this.cfg = cfg;
+    this.locals = locals;
+    this.schema = schema;
+    this.resolving = resolving;
+    this.memo = memo;
+  }
+
+  evaluate(expr: string): Value {
+    const tokens = tokenize(expr);
+    if (!tokens || tokens.length === 0) return undefined;
+    this.tokens = tokens;
+    this.pos = 0;
+    const value = this.parseOr();
+    // Trailing tokens mean we misread the grammar; don't trust a partial parse.
+    if (this.pos !== this.tokens.length) return undefined;
+    return value;
+  }
+
+  private peek(): Token | undefined {
+    return this.tokens[this.pos];
+  }
+
+  private eatOp(op: string): boolean {
+    const t = this.peek();
+    if (t && t.kind === 'op' && t.value === op) {
+      this.pos += 1;
+      return true;
+    }
+    return false;
+  }
+
+  private parseOr(): Value {
+    let left = this.parseAnd();
+    while (this.eatOp('||')) {
+      const right = this.parseAnd();
+      const l = asBoolean(left);
+      const r = asBoolean(right);
+      // Short-circuit truth survives an unknown operand: `true || ???` is true.
+      if (l === true || r === true) left = true;
+      else if (l === undefined || r === undefined) left = undefined;
+      else left = l || r;
+    }
+    return left;
+  }
+
+  private parseAnd(): Value {
+    let left = this.parseComparison();
+    while (this.eatOp('&&')) {
+      const right = this.parseComparison();
+      const l = asBoolean(left);
+      const r = asBoolean(right);
+      if (l === false || r === false) left = false;
+      else if (l === undefined || r === undefined) left = undefined;
+      else left = l && r;
+    }
+    return left;
+  }
+
+  private parseComparison(): Value {
+    const left = this.parseUnary();
+    for (const op of ['==', '!=', '<=', '>=', '<', '>']) {
+      if (this.eatOp(op)) {
+        const right = this.parseUnary();
+        return compare(left, right, op, this.schema);
+      }
+    }
+    return left;
+  }
+
+  private parseUnary(): Value {
+    if (this.eatOp('!')) {
+      const v = asBoolean(this.parseUnary());
+      return v === undefined ? undefined : !v;
+    }
+    return this.parsePrimary();
+  }
+
+  private parsePrimary(): Value {
+    const t = this.peek();
+    if (!t) return undefined;
+
+    if (t.kind === 'num') {
+      this.pos += 1;
+      return t.value;
+    }
+    if (t.kind === 'str') {
+      this.pos += 1;
+      return t.value;
+    }
+    if (t.kind === 'op' && t.value === '(') {
+      this.pos += 1;
+      const v = this.parseOr();
+      if (!this.eatOp(')')) return undefined;
+      return v;
+    }
+    if (t.kind !== 'id') return undefined;
+    this.pos += 1;
+
+    if (t.value === 'true') return true;
+    if (t.value === 'false') return false;
+
+    // `config->opt_bool("key")`, `config->option<ConfigOptionFloat>("key")->value`
+    if (t.value === 'config') return this.parseConfigAccess();
+
+    // A bare identifier is either a named local or a C++ enum symbol.
+    const local = this.locals[t.value];
+    if (local !== undefined) return this.resolveLocal(t.value, local);
+    // Not a local, so it is a C++ enumerator; `compare` decides whether it can
+    // be matched against the other side's declared enum values.
+    return `${ENUM_SYMBOL}${t.value}`;
+  }
+
+  /** Consumes the `->accessor<T>("key")` tail after a `config` identifier. */
+  private parseConfigAccess(): Value {
+    if (!this.eatOp('->')) return undefined;
+    const name = this.peek();
+    if (!name || name.kind !== 'id' || !ACCESSORS.has(name.value)) return undefined;
+    this.pos += 1;
+
+    // Optional `<ConfigOptionFloat>` / `<InfillPattern>` template argument.
+    if (this.eatOp('<')) {
+      let depth = 1;
+      while (depth > 0) {
+        const tok = this.peek();
+        if (!tok) return undefined;
+        this.pos += 1;
+        if (tok.kind === 'op' && tok.value === '<') depth += 1;
+        if (tok.kind === 'op' && tok.value === '>') depth -= 1;
+      }
+    }
+
+    if (!this.eatOp('(')) return undefined;
+    const arg = this.peek();
+    if (!arg || arg.kind !== 'str') return undefined;
+    this.pos += 1;
+    const key = arg.value;
+    // Skip any further arguments (`, variant_index`, `, 0`).
+    while (this.eatOp(',')) {
+      let depth = 0;
+      for (;;) {
+        const tok = this.peek();
+        if (!tok) return undefined;
+        if (tok.kind === 'op' && tok.value === '(') depth += 1;
+        if (tok.kind === 'op' && tok.value === ')') {
+          if (depth === 0) break;
+          depth -= 1;
+        }
+        if (tok.kind === 'op' && tok.value === ',' && depth === 0) break;
+        this.pos += 1;
+      }
+    }
+    if (!this.eatOp(')')) return undefined;
+
+    // `config->option<T>("key")->value` — consume the trailing member access.
+    if (this.eatOp('->')) {
+      const member = this.peek();
+      if (!member || member.kind !== 'id') return undefined;
+      this.pos += 1;
+    }
+
+    if (name.value === 'has') return this.cfg.has(key);
+
+    const raw = this.cfg.get(key);
+    // Tag reads of enum options so a comparison against a C++ enumerator can
+    // validate its transliteration against this option's declared values.
+    if (this.schema[key]?.enum_values) {
+      return { enumKey: key, value: typeof raw === 'string' ? raw : undefined };
+    }
+    return raw;
+  }
+
+  private resolveLocal(name: string, source: string): Value {
+    const cached = this.memo.get(name);
+    if (cached !== undefined || this.memo.has(name)) return cached;
+    if (this.resolving.has(name)) return undefined;
+
+    this.resolving.add(name);
+    const nested = new Evaluator(this.cfg, this.locals, this.schema, this.resolving, this.memo);
+    const value = nested.evaluate(source);
+    this.resolving.delete(name);
+
+    this.memo.set(name, value);
+    return value;
+  }
+}
+
+/**
+ * Compares two resolved values.
+ *
+ * The interesting case is an enum option tested against a C++ enumerator —
+ * `config->opt_enum<IroningType>("ironing_type") != IroningType::NoIroning`.
+ * OrcaSlicer's enumerator spellings and its serialised config values are
+ * related but not identical (`btNoBrim` -> `no_brim`, `NoIroning` ->
+ * `no ironing`), so we generate the plausible spellings and only trust the
+ * result when exactly one of them is a value the option actually declares.
+ * A transliteration that matches nothing yields `undefined`, not a confident
+ * `false` that would grey out a field for the wrong reason.
+ */
+function compare(left: Value, right: Value, op: string, schema: ProcessSchema): Value {
+  const symbolSide = typeof left === 'string' && left.startsWith(ENUM_SYMBOL) ? left : typeof right === 'string' && right.startsWith(ENUM_SYMBOL) ? right : undefined;
+
+  if (symbolSide !== undefined) {
+    if (op !== '==' && op !== '!=') return undefined;
+    const other = symbolSide === left ? right : left;
+    if (!isEnumRead(other)) return undefined;
+
+    const declared = schema[other.enumKey]?.enum_values;
+    if (!declared || other.value === undefined) return undefined;
+
+    const matches = enumCandidates(symbolSide.slice(ENUM_SYMBOL.length)).filter((c) => declared.includes(c));
+    if (matches.length !== 1) return undefined;
+
+    const equal = matches[0] === other.value;
+    return op === '==' ? equal : !equal;
+  }
+
+  // An enum read compared against anything else is only meaningful by value.
+  const l0 = isEnumRead(left) ? left.value : left;
+  const r0 = isEnumRead(right) ? right.value : right;
+
+  if (op === '==' || op === '!=') {
+    if (l0 === undefined || r0 === undefined) return undefined;
+    const equal = typeof l0 === 'string' || typeof r0 === 'string' ? String(l0) === String(r0) : asNumber(l0) === asNumber(r0);
+    return op === '==' ? equal : !equal;
+  }
+
+  const l = asNumber(l0);
+  const r = asNumber(r0);
+  if (l === undefined || r === undefined) return undefined;
+  if (op === '<') return l < r;
+  if (op === '<=') return l <= r;
+  if (op === '>') return l > r;
+  if (op === '>=') return l >= r;
+  return undefined;
+}
+
+/**
+ * Plausible config spellings for a C++ enumerator.
+ *
+ * `IroningType::NoIroning` -> ["no_ironing", "no ironing", "noironing"]
+ * `btNoBrim`               -> ["no_brim", "no brim", "nobrim"]
+ */
+function enumCandidates(symbol: string): string[] {
+  const bare = symbol.includes('::') ? symbol.slice(symbol.lastIndexOf('::') + 2) : symbol;
+  // Enumerators are either bare PascalCase or PascalCase behind a lowercase
+  // type tag (ip*, bt*, sms*); try both readings.
+  const cores = [bare, /^[a-z]+([A-Z].*)$/.exec(bare)?.[1]].filter((c): c is string => Boolean(c));
+
+  const out = new Set<string>();
+  for (const core of cores) {
+    const snake = core.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
+    out.add(snake);
+    out.add(snake.replace(/_/g, ' '));
+    out.add(snake.replace(/_/g, ''));
+  }
+  return [...out];
+}
+
+// --- Public API ------------------------------------------------------------
+
+export interface ToggleRules {
+  locals: Record<string, string>;
+  rules: Array<{ fields: string[]; enable_if: string }>;
+}
+
+/**
+ * Returns the set of option keys the current settings disable.
+ *
+ * Only rules that evaluate to a definite `false` contribute; unknown and true
+ * both leave the field enabled.
+ */
+export function disabledKeys(settings: Record<string, SettingValue>, schema: ProcessSchema, toggles: ToggleRules): Set<string> {
+  const cfg = makeConfigReader(settings, schema);
+  const memo = new Map<string, Value>();
+  const disabled = new Set<string>();
+
+  for (const rule of toggles.rules) {
+    // The C++ helper takes `(expr, variant_index)`; only the first part is the
+    // condition, the rest selects which extruder variant to read.
+    const condition = splitCondition(rule.enable_if);
+    if (!condition) continue;
+    const evaluator = new Evaluator(cfg, toggles.locals, schema, new Set(), memo);
+    if (asBoolean(evaluator.evaluate(condition)) === false) {
+      for (const field of rule.fields) disabled.add(field);
+    }
+  }
+  return disabled;
+}
+
+/**
+ * Takes the condition off an `enable_if` payload, dropping a trailing
+ * `variant_index` argument. Only parentheses count towards nesting: every
+ * argument-bearing call in the rule set is parenthesised, while `<` and `>`
+ * appear far more often as comparisons than as template brackets.
+ */
+function splitCondition(expr: string): string | undefined {
+  let depth = 0;
+  for (let i = 0; i < expr.length; i += 1) {
+    const c = expr[i];
+    if (c === '(') depth += 1;
+    else if (c === ')') depth -= 1;
+    else if (c === ',' && depth === 0) return expr.slice(0, i).trim() || undefined;
+  }
+  return expr.trim() || undefined;
+}

+ 35 - 0
frontend/src/pages/SettingsPage.tsx

@@ -54,6 +54,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
 import { Gauge, Palette } from 'lucide-react';
 import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch';
 import type { UsersSubTab } from '../lib/settingsSearch';
+import { availableEngines, hasEngineChoice, resolveEngine, type SliceEngineId } from '../lib/sliceEngines';
 
 const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
 type TabType = typeof validTabs[number];
@@ -1067,6 +1068,7 @@ export function SettingsPage() {
       Number(baseline.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
       (baseline.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
       (baseline.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
+      resolveEngine(baseline.slice_engine) !== resolveEngine(localSettings.slice_engine) ||
       (baseline.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
       (baseline.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) ||
       (baseline.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') ||
@@ -1175,6 +1177,7 @@ export function SettingsPage() {
         library_disk_warning_gb: localSettings.library_disk_warning_gb,
         camera_view_mode: localSettings.camera_view_mode,
         preferred_slicer: localSettings.preferred_slicer,
+        slice_engine: localSettings.slice_engine,
         open_in_slicer: localSettings.open_in_slicer,
         use_slicer_api: localSettings.use_slicer_api,
         orcaslicer_api_url: localSettings.orcaslicer_api_url,
@@ -5118,6 +5121,38 @@ export function SettingsPage() {
               </h3>
             </CardHeader>
             <CardContent className="space-y-3">
+              {/* Where slicing runs. Rendered only once more than one engine
+                  is actually usable — while the sidecar is the only one, a
+                  picker with a single entry is noise, and an entry the user
+                  can see but never select reads as a broken feature. Adding a
+                  browser engine to lib/sliceEngines.ts makes this appear. */}
+              {hasEngineChoice() && (
+                <div>
+                  <label className="block text-sm text-bambu-gray mb-1">
+                    {t('settings.sliceEngine')}
+                  </label>
+                  <div className="relative">
+                    <select
+                      value={resolveEngine(localSettings.slice_engine)}
+                      onChange={(e) => updateSetting('slice_engine', e.target.value as SliceEngineId)}
+                      className="w-full px-3 py-2 pr-10 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                    >
+                      {availableEngines().map((engine) => (
+                        <option key={engine.id} value={engine.id}>
+                          {t(engine.labelKey)}
+                        </option>
+                      ))}
+                    </select>
+                    <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                  </div>
+                  <p className="text-xs text-bambu-gray mt-1">
+                    {t(
+                      availableEngines().find((e) => e.id === resolveEngine(localSettings.slice_engine))?.descriptionKey
+                        ?? 'settings.sliceEngineSidecarHint',
+                    )}
+                  </p>
+                </div>
+              )}
               <div>
                 <label className="block text-sm text-bambu-gray mb-1">
                   {t('settings.preferredSlicer')}

+ 64 - 0
frontend/src/types/slicerSettings.ts

@@ -0,0 +1,64 @@
+/**
+ * Types for the vendored OrcaSlicer process-settings metadata.
+ *
+ * The JSON under `src/data/slicer/` is generated by
+ * `scripts/generate-slicer-schema.mjs` from the `three-slicer` package, which
+ * extracts it from OrcaSlicer's own `PrintConfig.cpp` and `Tab.cpp`. These
+ * types describe that generated shape.
+ */
+
+/** A value the user has set for one process option, in its slicer-side form. */
+export type SettingValue = string | number | boolean | Array<string | number | boolean>;
+
+/**
+ * OrcaSlicer's `ConfigOptionType` names, as they appear in the extracted schema.
+ * The plural forms are per-extruder vectors.
+ */
+export type OptionType =
+  | 'coBool'
+  | 'coBools'
+  | 'coInt'
+  | 'coFloat'
+  | 'coFloats'
+  | 'coPercent'
+  | 'coFloatOrPercent'
+  | 'coFloatsOrPercents'
+  | 'coEnum'
+  | 'coString';
+
+/** OrcaSlicer's setting visibility tiers, mirrored by the panel's mode switch. */
+export type OptionMode = 'simple' | 'advanced' | 'expert' | 'develop';
+
+export interface ProcessOption {
+  type: OptionType;
+  mode: OptionMode;
+  label: string;
+  tooltip?: string;
+  /** Unit shown after the input ("mm", "mm/s²", "%"). */
+  sidetext?: string;
+  /**
+   * Bounds as the extractor found them. Usually numeric, but a few carry
+   * unparsed C++ float literals ("0.3f") — callers must coerce and ignore what
+   * doesn't convert.
+   */
+  min?: number | string;
+  max?: number | string;
+  enum_values?: string[];
+  enum_labels?: string[];
+  default?: SettingValue;
+}
+
+export type ProcessSchema = Record<string, ProcessOption>;
+
+export interface ProcessGroup {
+  group: string;
+  options: string[];
+}
+
+export interface ProcessPage {
+  page: string;
+  icon?: string;
+  groups: ProcessGroup[];
+}
+
+export type ProcessUiTree = ProcessPage[];

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Bfjo96N3.js


File diff suppressed because it is too large
+ 1 - 0
static/assets/index-DQ9iPYXW.css


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-DZYWm6I1.css


File diff suppressed because it is too large
+ 116 - 0
static/assets/process-schema-zTidBW1a.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/process-toggle-rules-DDBax3G5.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/process-ui-tree-BWrKRLV6.js


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-B1ugBOm2.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DZYWm6I1.css">
+    <script type="module" crossorigin src="/assets/index-Bfjo96N3.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DQ9iPYXW.css">
   </head>
   <body>
     <div id="root"></div>

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