diff --git a/CHANGELOG.md b/CHANGELOG.md index e92038007..b60881636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to Bambuddy will be documented in this file. - **Temperatures on the streaming overlay, and a builder for its URL (#1422, reporter @SMAW)** — The overlay at `/overlay/{printer}` draws live print data over a full-screen camera view for OBS, a wall display or any browser source. It could already be tuned — which fields, what size, what frame rate — but only through query parameters documented in the wiki, and temperatures were not among the fields on offer. Both are now addressed. Nozzle, bed and chamber readings join the list, shown with the target while the heater is still climbing and with the target dropped once it is reached, so a settled hotend reads "220°C" rather than "220 / 220°C" for the rest of the print. Both nozzles appear on a dual-nozzle printer. They are drawn whether or not a print is running, since a preheating machine is exactly when they are worth watching, and each reading appears only when the printer genuinely reports it — chamber temperature stays absent on P1 and A1 models, which publish a value with no sensor behind it. And **Settings → API Keys → Streaming Overlay** now builds the URL for you: pick the printer, tick the fields, set size and frame rate, paste in a token if login is enabled, and copy the result, with an optional preview alongside it. The preview stays off until you ask for it so that leaving the settings page open does not hold a viewer on the printer's single camera connection. Making that preview possible needed one narrow change to the security headers: the overlay path now sends `frame-ancestors 'self'` instead of `'none'`, so Bambuddy's own UI can embed it. Every other page still refuses to be framed at all, `'self'` permits a framer only on this same origin, and embedding the overlay from another host — Home Assistant on a different port, say — is unchanged and still requires `TRUSTED_FRAME_ORIGINS`. Temperatures are not in the default field set, so an overlay URL already pasted into a scene looks exactly the same after upgrading. Translated in all locales, wiki updated, covered by backend and frontend tests. - **The external spool can be hidden from the printer card (#1782, reporter @Arn0uDz)** — An external spool holder that never gets used still occupies a full card's width in the **Filaments** row, next to the AMS units that are actually being used. An eye icon at the right-hand end of that row's header now hides it, and clicking it again brings it back, so nothing is lost behind a settings page you would have to remember. The choice is remembered per printer and stored in the browser, like the card size and the offline-printer filter — one machine in a fleet can be tidied up without touching the others, and nothing changes for anyone else using the same Bambuddy. The icon is deliberately absent on a printer with no AMS: there the external spool is the entire filament section, and hiding it would leave an empty row. That guard also covers the case of an AMS being unplugged from a printer whose external spool was hidden earlier — the spool reappears rather than leaving a blank row behind. On the H2D and H2S both external positions share one card and so hide together. Translated in all locales, wiki updated, covered by frontend tests. +- **The slice dialog can edit the full print-parameter set, not just pick a preset** — Slicing from Bambuddy meant taking a process preset exactly as it came. Anything beyond that — one more wall for a bracket, supports for a single overhang, slower outer walls on a part that keeps scarring — meant going back to Bambu Studio, editing there, and re-exporting. The slice dialog now has a **Process settings** section carrying the whole tree: the same pages, groups and ordering the desktop slicer shows under Print Settings, with the same labels, tooltips, ranges and defaults, because they are extracted from the slicer's own sources rather than hand-picked. It behaves the way the desktop one does. **Simple / Advanced / Expert** matches the slicer's own visibility tiers, search reaches across every page at once, changed settings are marked and individually revertable, and settings the slicer itself disables in your current configuration are greyed out — infill options with infill at zero, ironing options with ironing off — because Bambuddy evaluates the slicer's own enable rules rather than approximating them. Where a rule cannot be decided with certainty the setting stays editable, on the grounds that a missing control looks like a bug while a redundant one is merely ignored. Edits apply to one slice, are not saved into a preset, and are written after the source file's support configuration and any carried designer settings, so an explicit choice is never silently overridden; an untouched panel produces exactly the request it did before. Parameter names and descriptions are in English even where the rest of Bambuddy is not — several hundred strings lifted verbatim from the slicer, which is a separate job from translating Bambuddy's own interface. The dialog's own wording is translated in all locales. Wiki updated, covered by backend and frontend tests. + ### Changed - **The MQTT debug log now records the commands sent to a printer, not only what it reports back** — **Printer → Debug → MQTT** captured one side of the conversation. Bambuddy listens on both of a printer's topics, but the one carrying commands returned before anything was written to the log, so a capture could show every status push the printer made and nothing it was ever told — including the commands Bambu Studio sends over the local network, which is the only place they can be observed at all. Those now appear alongside Bambuddy's own, grouped under the outgoing filter. It is what lets a question like "which value does Studio put in this field?" be answered from a user's capture instead of guessed at, and it is why #2774 could not be taken further. Commands Bambuddy sends appear twice, once as it publishes and once as the broker echoes it back, and the pair is itself evidence the command reached the broker. Logging is off until switched on, as before. Covered by backend tests. - **The L and XL printer cards now scale their text and icons, not just their width (#1848, reporter @misterff1)** — Switching a card from M to XL made it wider, enlarged the printer name and the thumbnail, and left everything else exactly as it was: the AMS slot labels, temperatures, filament names, status text and every small button stayed pinned between 8 and 11 pixels, well under the smallest size used anywhere else in Bambuddy. The result was a full-width card carrying the same tiny text as the compact one, which is precisely the opposite of what someone reaching for a bigger card is asking for. Browser zoom is not an answer to this, since it enlarges the entire page and so preserves the very disparity being complained about. The card body now scales along with the card: L draws it 20% larger and XL 40% larger, icons included, so the controls grow with the text rather than staying fiddly to hit. The AMS-HT card needed two adjustments of its own, since its temperature and humidity readings sit beside the slot rather than under it. Its single slot was the only thing on that row able to grow, so it swallowed every spare pixel and pushed the readings hard against the card's edge — it is now capped at roughly two ordinary slots, which keeps them clear at any card width. The card itself also gained a ceiling of one full AMS card's width, so a unit that wraps onto a line of its own no longer stretches that single slot across the whole card. S and M are deliberately untouched — S is the dense fleet view where density is the point, and M is the default, so an existing install looks identical until you reach for a size that is already asking for more room. Wiki updated. Covered by frontend tests. diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index c338d61c2..56ae607fc 100644 --- a/backend/app/api/routes/library.py +++ b/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 diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 5432c57ef..3dfb858ae 100644 --- a/backend/app/schemas/settings.py +++ b/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 diff --git a/backend/app/schemas/slicer.py b/backend/app/schemas/slicer.py index 4ba502d49..78486228a 100644 --- a/backend/app/schemas/slicer.py +++ b/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=( diff --git a/backend/app/services/process_overrides.py b/backend/app/services/process_overrides.py new file mode 100644 index 000000000..1f732e4c1 --- /dev/null +++ b/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) diff --git a/backend/tests/unit/test_process_overrides.py b/backend/tests/unit/test_process_overrides.py new file mode 100644 index 000000000..dc7aabb12 --- /dev/null +++ b/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]" diff --git a/frontend/scripts/check-i18n-parity.mjs b/frontend/scripts/check-i18n-parity.mjs index 70c43ee2c..624a5bdfd 100644 --- a/frontend/scripts/check-i18n-parity.mjs +++ b/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 diff --git a/frontend/scripts/generate-slicer-schema.mjs b/frontend/scripts/generate-slicer-schema.mjs new file mode 100644 index 000000000..7ac3d6f56 --- /dev/null +++ b/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 +// +// 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 '); + 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(', ')}`); diff --git a/frontend/src/__tests__/components/SlicerSettingsPanel.test.tsx b/frontend/src/__tests__/components/SlicerSettingsPanel.test.tsx new file mode 100644 index 000000000..626ea43cb --- /dev/null +++ b/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; + onChange: (v: Record, s: Record) => void; +}) { + const [values, setValues] = useState(initial); + return ( + { + setValues(v); + onChange(v, s); + }} + /> + ); +} + +/** Renders the panel and waits for its dynamically imported metadata. */ +async function renderPanel(initial: Record = {}) { + const onChange = vi.fn(); + render(); + 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, 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); + }); +}); diff --git a/frontend/src/__tests__/utils/slicerToggle.test.ts b/frontend/src/__tests__/utils/slicerToggle.test.ts new file mode 100644 index 000000000..4ab6df0b4 --- /dev/null +++ b/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; rules: Array<{ fields: string[]; enable_if: string }> }; + +const disabled = (settings: Record) => 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("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("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; + 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)); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 59b644839..5c3f0abbd 100644 --- a/frontend/src/api/client.ts +++ b/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; + // 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 diff --git a/frontend/src/components/SliceModal.tsx b/frontend/src/components/SliceModal.tsx index 312d23808..42cc6cb78 100644 --- a/frontend/src/components/SliceModal.tsx +++ b/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>(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>({}); + const [serializedProcessOverrides, setSerializedProcessOverrides] = useState>({}); + 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) { )} )} + + {/* 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 && ( +
+ + {settingsExpanded && ( +
+ { + setProcessOverrides(values); + setSerializedProcessOverrides(serialized); + }} + disabled={isEnqueuing} + /> +
+ )} +
+ )} {/* Bed-type override (#1337). Always visible, always enabled. The backend patches curr_bed_type on the resolved process JSON before forwarding to the sidecar. */} diff --git a/frontend/src/components/SlicerSettingsPanel.tsx b/frontend/src/components/SlicerSettingsPanel.tsx new file mode 100644 index 000000000..ec7d49c90 --- /dev/null +++ b/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; + /** + * 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, serialized: Record) => void; + disabled?: boolean; +} + +/** Visibility tiers, in increasing order of how much they reveal. */ +const MODES: OptionMode[] = ['simple', 'advanced', 'expert']; +const MODE_RANK: Record = { simple: 0, advanced: 1, expert: 2, develop: 3 }; + +export default function SlicerSettingsPanel({ values, onChange, disabled = false }: Props) { + const { t } = useTranslation(); + const [data, setData] = useState(null); + const [mode, setMode] = useState('simple'); + const [page, setPage] = useState(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()), + [data, values], + ); + + const emit = (next: Record) => { + 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 = {}; + 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 ( +
+ + {t('slicerSettings.loading', 'Loading slicer settings...')} +
+ ); + } + + const shownPages = activePage ? [activePage] : visiblePages; + + return ( +
+
+
+ {MODES.map((m) => ( + + ))} +
+ +
+ + 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" + /> +
+ + {modifiedCount > 0 && ( + + )} +
+ + {!query.trim() && ( +
+ {visiblePages.map((p) => ( + + ))} +
+ )} + + {shownPages.length === 0 ? ( +

+ {t('slicerSettings.noMatches', 'No settings match this search.')} +

+ ) : ( +
+ {shownPages.map((p) => ( +
+ {query.trim() &&

{p.page}

} + {p.groups.map((g) => ( +
+ {g.group} + {g.options.map((key) => ( + setValue(key, v)} + disabled={disabled || off.has(key)} + disabledBySlicer={off.has(key)} + /> + ))} +
+ ))} +
+ ))} +
+ )} +
+ ); +} + +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 ( +
+ + +
+ + {unit && {unit}} + +
+
+ ); +} + +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 ( + 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 ( + + ); + } + + if (option.type === 'coInt' || option.type === 'coFloat' || option.type === 'coPercent') { + return ( + 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 ( + onChange(e.target.value === '' ? undefined : e.target.value)} + disabled={disabled} + className={inputClass} + /> + ); +} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 697db3edb..4ff6369e8 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/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', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index c174cdc3f..379b3043d 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/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', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index b151d8995..c2cfe8ef8 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 564df6465..ffc11141f 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 465b1ffbf..7270178d7 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/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', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 654235cc1..f114ae19f 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/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}} プレートすべてをスライス', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 91e0d67c2..f5c25cd30 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/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: '슬라이싱 중…', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index d6314dec8..fc6f37253 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/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', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index 6128fdb72..38f554073 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/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}})", diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index b426ba47a..f759b0659 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/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', diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index 342806690..e031c75c8 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/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}})", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 0c99accff..dd3e02fa8 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/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}} 个盘面', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 8660009db..ccd6991a3 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/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}} 個盤面', diff --git a/frontend/src/lib/sliceEngines.ts b/frontend/src/lib/sliceEngines.ts new file mode 100644 index 000000000..f9e1e959c --- /dev/null +++ b/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; +} diff --git a/frontend/src/lib/slicerSettings.ts b/frontend/src/lib/slicerSettings.ts new file mode 100644 index 000000000..146411eb4 --- /dev/null +++ b/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, schema: ProcessSchema): Record { + const out: Record = {}; + 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; +} diff --git a/frontend/src/lib/slicerToggle.ts b/frontend/src/lib/slicerToggle.ts new file mode 100644 index 000000000..338b435b7 --- /dev/null +++ b/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, 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; + private readonly schema: ProcessSchema; + /** Locals currently being resolved — guards the (unlikely) cyclic definition. */ + private readonly resolving: Set; + private readonly memo: Map; + + constructor(cfg: ConfigReader, locals: Record, schema: ProcessSchema, resolving: Set, memo: Map) { + 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("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("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 `` / `` 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("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("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(); + 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; + 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, schema: ProcessSchema, toggles: ToggleRules): Set { + const cfg = makeConfigReader(settings, schema); + const memo = new Map(); + const disabled = new Set(); + + 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; +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 692e0fcee..72cc8987d 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/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() { + {/* 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() && ( +
+ +
+ + +
+

+ {t( + availableEngines().find((e) => e.id === resolveEngine(localSettings.slice_engine))?.descriptionKey + ?? 'settings.sliceEngineSidecarHint', + )} +

+
+ )}