From c384911f7c2bf928bab8a398af586edee664897d Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 9 Aug 2026 10:00:53 +0200 Subject: [PATCH] Sync --- .gitignore | 5 +- CHANGELOG.md | 3 +- frontend/scripts/generate-slicer-schema.mjs | 28 +- .../__tests__/components/SliceModal.test.tsx | 241 +++++++++++- .../components/SlicerSettingsPanel.test.tsx | 179 ++++++++- .../utils/slicerPrinterMatch.test.ts | 58 +++ .../src/__tests__/utils/slicerToggle.test.ts | 44 ++- frontend/src/components/SliceModal.tsx | 219 ++++++----- .../src/components/SlicerSettingsPanel.tsx | 288 +++++++++++++-- frontend/src/data/slicer/process-schema.json | 1 + .../src/data/slicer/process-toggle-rules.json | 1 + frontend/src/data/slicer/process-ui-tree.json | 1 + frontend/src/i18n/locales/de.ts | 18 +- frontend/src/i18n/locales/en.ts | 18 +- frontend/src/i18n/locales/es.ts | 18 +- frontend/src/i18n/locales/fr.ts | 18 +- frontend/src/i18n/locales/it.ts | 18 +- frontend/src/i18n/locales/ja.ts | 18 +- frontend/src/i18n/locales/ko.ts | 18 +- frontend/src/i18n/locales/pt-BR.ts | 18 +- frontend/src/i18n/locales/ru.ts | 18 +- frontend/src/i18n/locales/tr.ts | 18 +- frontend/src/i18n/locales/uk.ts | 18 +- frontend/src/i18n/locales/zh-CN.ts | 18 +- frontend/src/i18n/locales/zh-TW.ts | 18 +- frontend/src/lib/slicerSettings.ts | 17 +- frontend/src/types/slicerSettings.ts | 6 +- frontend/src/utils/slicerPrinterMatch.ts | 41 ++- static/assets/index-D4VkH83v.css | 2 + .../{index-D5dXOgnd.js => index-DGpcnZPX.js} | 344 +++++++++--------- static/assets/index-DcBH50JZ.css | 2 - ...zTidBW1a.js => process-schema-CzfeynAH.js} | 30 +- static/index.html | 4 +- 33 files changed, 1322 insertions(+), 426 deletions(-) create mode 100644 frontend/src/data/slicer/process-schema.json create mode 100644 frontend/src/data/slicer/process-toggle-rules.json create mode 100644 frontend/src/data/slicer/process-ui-tree.json create mode 100644 static/assets/index-D4VkH83v.css rename static/assets/{index-D5dXOgnd.js => index-DGpcnZPX.js} (68%) delete mode 100644 static/assets/index-DcBH50JZ.css rename static/assets/{process-schema-zTidBW1a.js => process-schema-CzfeynAH.js} (81%) diff --git a/.gitignore b/.gitignore index 72b114b1f..bd8ef4707 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,10 @@ firmware/ # Node modules node_modules/ -data/ +# Runtime data dir (db, archives, backups). Anchored to the repo root on +# purpose: a bare `data/` also matches frontend/src/data and +# backend/app/data, which are source, not runtime state. +/data/ # Local-dev runtime caches (matplotlib MPLCONFIGDIR lands here when DATA_DIR # is unset, so base_dir resolves to the repo root). In Docker this sits diff --git a/CHANGELOG.md b/CHANGELOG.md index 803cda1b4..3badd86de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,10 @@ All notable changes to Bambuddy will be documented in this file. - **Temperatures on the streaming overlay, and a builder for its URL (#1422, reporter @SMAW)** — The overlay at `/overlay/{printer}` draws live print data over a full-screen camera view for OBS, a wall display or any browser source. It could already be tuned — which fields, what size, what frame rate — but only through query parameters documented in the wiki, and temperatures were not among the fields on offer. Both are now addressed. Nozzle, bed and chamber readings join the list, shown with the target while the heater is still climbing and with the target dropped once it is reached, so a settled hotend reads "220°C" rather than "220 / 220°C" for the rest of the print. Both nozzles appear on a dual-nozzle printer. They are drawn whether or not a print is running, since a preheating machine is exactly when they are worth watching, and each reading appears only when the printer genuinely reports it — chamber temperature stays absent on P1 and A1 models, which publish a value with no sensor behind it. And **Settings → API Keys → Streaming Overlay** now builds the URL for you: pick the printer, tick the fields, set size and frame rate, paste in a token if login is enabled, and copy the result, with an optional preview alongside it. The preview stays off until you ask for it so that leaving the settings page open does not hold a viewer on the printer's single camera connection. Making that preview possible needed one narrow change to the security headers: the overlay path now sends `frame-ancestors 'self'` instead of `'none'`, so Bambuddy's own UI can embed it. Every other page still refuses to be framed at all, `'self'` permits a framer only on this same origin, and embedding the overlay from another host — Home Assistant on a different port, say — is unchanged and still requires `TRUSTED_FRAME_ORIGINS`. Temperatures are not in the default field set, so an overlay URL already pasted into a scene looks exactly the same after upgrading. Translated in all locales, wiki updated, covered by backend and frontend tests. - **The external spool can be hidden from the printer card (#1782, reporter @Arn0uDz)** — An external spool holder that never gets used still occupies a full card's width in the **Filaments** row, next to the AMS units that are actually being used. An eye icon at the right-hand end of that row's header now hides it, and clicking it again brings it back, so nothing is lost behind a settings page you would have to remember. The choice is remembered per printer and stored in the browser, like the card size and the offline-printer filter — one machine in a fleet can be tidied up without touching the others, and nothing changes for anyone else using the same Bambuddy. The icon is deliberately absent on a printer with no AMS: there the external spool is the entire filament section, and hiding it would leave an empty row. That guard also covers the case of an AMS being unplugged from a printer whose external spool was hidden earlier — the spool reappears rather than leaving a blank row behind. On the H2D and H2S both external positions share one card and so hide together. Translated in all locales, wiki updated, covered by frontend tests. -- **The slice dialog can edit the full print-parameter set, not just pick a preset** — Slicing from Bambuddy meant taking a process preset exactly as it came. Anything beyond that — one more wall for a bracket, supports for a single overhang, slower outer walls on a part that keeps scarring — meant going back to Bambu Studio, editing there, and re-exporting. The slice dialog now has a **Process settings** section carrying the whole tree: the same pages, groups and ordering the desktop slicer shows under Print Settings, with the same labels, tooltips, ranges and defaults, because they are extracted from the slicer's own sources rather than hand-picked. The dialog itself widens to make room: on a reasonably sized screen it now uses two columns, with every "what am I slicing with" decision — pipeline, printer, process, filaments, bed type, layout passes — kept together on the left and the settings panel given a column of its own on the right, open and ready rather than folded away. Narrower screens keep the single column and the collapsed panel. It behaves the way the desktop one does. **Simple / Advanced / Expert** matches the slicer's own visibility tiers, search reaches across every page at once, changed settings are marked and individually revertable, and settings the slicer itself disables in your current configuration are greyed out — infill options with infill at zero, ironing options with ironing off — because Bambuddy evaluates the slicer's own enable rules rather than approximating them. Where a rule cannot be decided with certainty the setting stays editable, on the grounds that a missing control looks like a bug while a redundant one is merely ignored. Edits apply to one slice, are not saved into a preset, and are written after the source file's support configuration and any carried designer settings, so an explicit choice is never silently overridden; an untouched panel produces exactly the request it did before. Parameter names and descriptions are in English even where the rest of Bambuddy is not — several hundred strings lifted verbatim from the slicer, which is a separate job from translating Bambuddy's own interface. The dialog's own wording is translated in all locales. Wiki updated, covered by backend and frontend tests. +- **The slice dialog can edit the full print-parameter set, not just pick a preset** — Slicing from Bambuddy meant taking a process preset exactly as it came. Anything beyond that — one more wall for a bracket, supports for a single overhang, slower outer walls on a part that keeps scarring — meant going back to Bambu Studio, editing there, and re-exporting. The slice dialog now has a **Process settings** section carrying the whole tree: the same pages, groups and ordering the desktop slicer shows under Print Settings, with the same labels, tooltips, ranges and defaults, because they are extracted from the slicer's own sources rather than hand-picked. The dialog itself widens to make room: on a reasonably sized screen it now uses two columns, with every "what am I slicing with" decision — pipeline, printer, process, filaments, bed type, layout passes — kept together on the left and the settings panel given a column of its own on the right, open and ready rather than folded away. Narrower screens keep the single column and the collapsed panel. The settings a source file's designer changed (#2622) now live in this panel too, marked *from file* against the options they belong to instead of in a separate list further up the dialog -- so there is one place that shows what a slice will actually use. Machine-coupled ones stay flagged and unticked as before, anything the panel has no entry for is listed by name rather than quietly dropped, and typing your own value still wins. Switching on "Use the file's built-in settings" greys the panel out rather than removing it, so the dialog does not appear to lose a feature when that toggle is flipped -- it stays visible, says why it is inactive, and applies nothing. Options that select *which* filament prints a feature -- support base and interface, and the per-region pickers for walls, infill and surfaces -- list the filaments you actually picked on the left rather than asking for a slot number, so "support interface" can be set to the PVA in slot 2 by name. Defaults and ranges are read out of the slicer's C++ initialisers, so a few arrived in source form -- the whole Line width group showed "0." rather than "0" -- and those are now cleaned as the data is generated instead of being papered over at display time. It behaves the way the desktop one does. **Simple / Advanced / Expert** matches the slicer's own visibility tiers, search reaches across every page at once, changed settings are marked and individually revertable, and settings the slicer itself disables in your current configuration are greyed out — infill options with infill at zero, ironing options with ironing off — because Bambuddy evaluates the slicer's own enable rules rather than approximating them. Where a rule cannot be decided with certainty the setting stays editable, on the grounds that a missing control looks like a bug while a redundant one is merely ignored. Edits apply to one slice, are not saved into a preset, and are written after the source file's support configuration and any carried designer settings, so an explicit choice is never silently overridden; an untouched panel produces exactly the request it did before. Parameter names and descriptions are in English even where the rest of Bambuddy is not — several hundred strings lifted verbatim from the slicer, which is a separate job from translating Bambuddy's own interface. The dialog's own wording is translated in all locales. Wiki updated, covered by backend and frontend tests. ### Changed +- **The slice dialog's process and filament lists now leave out presets that belong to another printer** — They were already sorted by compatibility, but a preset for a different Bambu model still appeared, demoted to an "Other printers" group at the bottom of the dropdown. With a large cloud filament library that group is most of the list, so the filtering was doing little for the thing it was meant to help: finding the profile you actually want. Those presets are now held back, with the label reporting how many ("3 hidden") next to a **Show all** link that brings them back for that one dropdown. Two things are never hidden. A preset with no detectable printer — a custom or renamed profile — stays in the list, because absence of evidence is not evidence of incompatibility and hiding those would make people's own imported profiles vanish. And whatever is currently selected stays visible even when the list is collapsed, so a deliberate cross-printer pick, or one restored from a pipeline, is never silently discarded by being dropped from the options. Re-slicing for another printer remains fully supported, so this is a default view rather than a restriction. Fixing this also corrected a screen-reader bug in those dropdowns: the controls sat inside the label wrapping the select, which handed them the entire label as their spoken name. A separate defect surfaced alongside it — the filter did nothing at all when the selected printer was a preset you had edited, because BambuStudio names those copies with a leading "# " and the matcher did not know to look past it. On such a printer every preset read as "compatibility unknown", and profiles listing their compatible printers by name could be ruled out against the very printer they were cloned from. - **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. - **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests. diff --git a/frontend/scripts/generate-slicer-schema.mjs b/frontend/scripts/generate-slicer-schema.mjs index 7ac3d6f56..cfc9ed500 100644 --- a/frontend/scripts/generate-slicer-schema.mjs +++ b/frontend/scripts/generate-slicer-schema.mjs @@ -80,11 +80,37 @@ for (const k of CONDITION_KEYS) if (schema[k]) referenced.add(k); // 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']; +// The extractor reads defaults and bounds straight out of C++ initialisers, so +// float literals arrive in source form: `0.` stays "0.", `0.3f` stays "0.3f", +// `100.%` stays "100.%", and `0.f` even splits into [0, "f"]. Rendering those +// verbatim put a column of "0." in the Line width group. They are literal +// artefacts, not values, so they are cleaned here — once, in the data — rather +// than worked around in every place that displays a default. +function normaliseLiteral(value) { + if (Array.isArray(value)) { + // `0.f` split across two entries; the stray "f" is not a value. + const cleaned = value.filter((v) => v !== 'f').map(normaliseLiteral); + return cleaned.length > 0 ? cleaned : [0]; + } + if (typeof value !== 'string') return value; + + let s = value.trim(); + s = s.replace(/^(-?[\d.]+)f$/, '$1'); // 0.3f -> 0.3, 0.f -> 0. + s = s.replace(/^(-?[\d.]*)\.%$/, '$1%'); // 100.% -> 100% + s = s.replace(/^(-?[\d.]*)\.$/, '$1'); // 0. -> 0 + // A literal that was nothing but a dot carried no digits to keep. + if (s === '' || s === '-') return value; + return s; +} + 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]; + for (const f of KEEP) { + if (opt[f] === undefined) continue; + out[f] = f === 'default' || f === 'min' || f === 'max' ? normaliseLiteral(opt[f]) : opt[f]; + } trimmedSchema[key] = out; } diff --git a/frontend/src/__tests__/components/SliceModal.test.tsx b/frontend/src/__tests__/components/SliceModal.test.tsx index ef1d1e3a2..7afadd777 100644 --- a/frontend/src/__tests__/components/SliceModal.test.tsx +++ b/frontend/src/__tests__/components/SliceModal.test.tsx @@ -33,6 +33,7 @@ vi.mock('../../api/client', () => ({ // Slicer Pipelines (#1425) listSlicerPipelines: vi.fn(), createSlicerPipeline: vi.fn(), + getSlicerPrinterModels: vi.fn(), }, })); @@ -47,6 +48,7 @@ const mockApi = api as unknown as { getArchiveFilamentRequirements: ReturnType; listSlicerPipelines: ReturnType; createSlicerPipeline: ReturnType; + getSlicerPrinterModels: ReturnType; }; function makeUnified(overrides: Partial = {}): UnifiedPresetsResponse { @@ -350,12 +352,27 @@ describe('SliceModal', () => { ], }; + // The designer's settings are shown inside the process-settings panel now, + // against the options they belong to, rather than in a list of their own. + // The payload contract below is unchanged: their *values* still travel as + // design_overrides keys, read from the file by the backend. async function openDesignSection() { const user = userEvent.setup(); - await user.click(await screen.findByText(/Keep the designer's settings/)); + await user.click(await screen.findByRole('button', { name: /Process settings/ })); + await screen.findByPlaceholderText('Search settings'); + // Every designer key must be reachable, including expert-tier ones. + await user.click(screen.getByRole('button', { name: 'Expert' })); return user; } + /** The panel's per-option "use the file's value" checkbox, by option key. */ + function sourceCheckbox(key: string): HTMLInputElement { + const boxes = screen.getAllByRole('checkbox') as HTMLInputElement[]; + const found = boxes.find((b) => (b.getAttribute('aria-label') ?? '').includes(key)); + if (!found) throw new Error(`no source checkbox for ${key}`); + return found; + } + it("carries the design's printer-independent settings by default (#2622)", async () => { mockApi.sliceLibraryFile.mockResolvedValue({ job_id: 42, @@ -369,8 +386,9 @@ describe('SliceModal', () => { onClose: vi.fn(), }); - // Two of three pre-selected: the speed key is machine-coupled. - expect(await screen.findByText('2 of 3 selected')).toBeInTheDocument(); + // Two of three pre-selected: the speed key is machine-coupled and is + // offered but never pre-ticked. + await waitFor(() => expect(screen.getByRole('button', { name: /^Slice$/ })).toBeEnabled()); const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: /^Slice$/ })); @@ -388,15 +406,18 @@ describe('SliceModal', () => { onClose: vi.fn(), }); - await openDesignSection(); + const user = await openDesignSection(); - expect(screen.getByText('wall_loops')).toBeInTheDocument(); - expect(screen.getByText('5')).toBeInTheDocument(); - expect(screen.getByText('sparse_infill_density')).toBeInTheDocument(); - expect(screen.getByText('100%')).toBeInTheDocument(); - // The risky one is listed too — visible, explained, just not pre-ticked. - expect(screen.getByText('outer_wall_speed')).toBeInTheDocument(); - expect(screen.getByText('printer-specific')).toBeInTheDocument(); + // Carried keys show the designer's value in the option's own control. + await user.type(screen.getByPlaceholderText('Search settings'), 'wall loops'); + await waitFor(() => expect(screen.getByLabelText(/^Wall loops/)).toHaveValue(5)); + expect(sourceCheckbox('Wall loops').checked).toBe(true); + + await user.clear(screen.getByPlaceholderText('Search settings')); + await user.type(screen.getByPlaceholderText('Search settings'), 'outer wall speed'); + // The machine-coupled one is present and flagged, just not pre-ticked. + await waitFor(() => expect(screen.getAllByText("designer's printer").length).toBeGreaterThan(0)); + expect(sourceCheckbox('Outer wall').checked).toBe(false); }); it('lets the user opt a machine-coupled setting in and a safe one out (#2622)', async () => { @@ -413,12 +434,15 @@ describe('SliceModal', () => { }); const user = await openDesignSection(); - const boxes = screen.getAllByRole('checkbox') as HTMLInputElement[]; - const byKey = (key: string) => - boxes.find((b) => b.closest('label')?.textContent?.includes(key)) as HTMLInputElement; - await user.click(byKey('outer_wall_speed')); - await user.click(byKey('wall_loops')); + await user.type(screen.getByPlaceholderText('Search settings'), 'outer wall speed'); + await waitFor(() => expect(sourceCheckbox('Outer wall')).toBeInTheDocument()); + await user.click(sourceCheckbox('Outer wall')); + + await user.clear(screen.getByPlaceholderText('Search settings')); + await user.type(screen.getByPlaceholderText('Search settings'), 'wall loops'); + await waitFor(() => expect(sourceCheckbox('Wall loops')).toBeInTheDocument()); + await user.click(sourceCheckbox('Wall loops')); await user.click(screen.getByRole('button', { name: /^Slice$/ })); @@ -441,9 +465,11 @@ describe('SliceModal', () => { }); const user = await openDesignSection(); - const boxes = screen.getAllByRole('checkbox') as HTMLInputElement[]; - for (const box of boxes) { - if (box.checked) await user.click(box); + for (const key of ['Wall loops', 'Sparse infill density']) { + await user.clear(screen.getByPlaceholderText('Search settings')); + await user.type(screen.getByPlaceholderText('Search settings'), key.toLowerCase()); + await waitFor(() => expect(sourceCheckbox(key)).toBeInTheDocument()); + if (sourceCheckbox(key).checked) await user.click(sourceCheckbox(key)); } await user.click(screen.getByRole('button', { name: /^Slice$/ })); @@ -461,7 +487,10 @@ describe('SliceModal', () => { }); await waitFor(() => expect(screen.getByRole('button', { name: /^Slice$/ })).toBeEnabled()); - expect(screen.queryByText(/Keep the designer's settings/)).toBeNull(); + // The panel still exists — it is the editor — but nothing is marked as + // coming from the file. + expect(screen.queryByText('from file')).toBeNull(); + expect(screen.queryByText("designer's printer")).toBeNull(); }); it('includes bed_type in the request when the user picks a non-auto plate (#1337)', async () => { @@ -1523,6 +1552,73 @@ describe('SliceModal', () => { * test setup pins matchMedia to `matches: false`, so every other test in this * file exercises the narrow single-stack path; these override it. */ +describe('SliceModal — process settings in "slice as designed" mode', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockApi.getSlicerPresets.mockResolvedValue(fullThreeTier); + mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] }); + mockApi.getSlicerPrinterModels.mockResolvedValue({}); + mockApi.getLibraryFilePlates.mockResolvedValue({ + file_id: 100, + filename: 'Designed.3mf', + plates: [], + is_multi_plate: false, + embedded_printer: 'Bambu Lab X1 Carbon 0.4 nozzle', + embedded_process: '0.20mm Standard', + }); + mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({ + file_id: 100, filename: 'Designed.3mf', plate_id: 1, filaments: [], + }); + }); + + it('disables the panel rather than removing it', async () => { + const user = userEvent.setup(); + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' }, + onClose: vi.fn(), + }); + + const toggle = (await screen.findByLabelText(/Use the file's built-in settings/)) as HTMLInputElement; + const header = await screen.findByRole('button', { name: /Process settings/ }); + await user.click(header); + const search = await screen.findByPlaceholderText('Search settings'); + expect(search).toBeEnabled(); + + await user.click(toggle); + + // Still on screen — hiding it made the dialog look like it had lost a + // feature — but nothing in it can be operated, because nothing in it is + // sent on this path. + expect(screen.getByPlaceholderText('Search settings')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Expert' })).toBeDisabled(); + expect(screen.getByText(/Not used while/)).toBeInTheDocument(); + expect(screen.getByText('Inactive')).toBeInTheDocument(); + }); + + it('sends no process overrides once the file drives the slice', async () => { + mockApi.sliceLibraryFile.mockResolvedValue({ job_id: 42, status: 'pending', status_url: '/x' }); + const user = userEvent.setup(); + renderWithTracker({ + source: { kind: 'libraryFile', id: 100, filename: 'Designed.3mf' }, + onClose: vi.fn(), + }); + + // Edit something, then hand the slice over to the file's own settings. + await user.click(await screen.findByRole('button', { name: /Process settings/ })); + const input = await screen.findByLabelText(/^Layer height/); + await user.clear(input); + await user.type(input, '0.16'); + + await user.click(screen.getByLabelText(/Use the file's built-in settings/)); + await user.click(screen.getByRole('button', { name: /^Slice$/ })); + + await waitFor(() => expect(mockApi.sliceLibraryFile).toHaveBeenCalled()); + const payload = mockApi.sliceLibraryFile.mock.calls[0][1] as Record; + expect(payload.use_embedded_settings).toBe(true); + expect(payload).not.toHaveProperty('process_overrides'); + }); +}); + describe('SliceModal — process settings layout', () => { const setViewport = (wide: boolean) => { Object.defineProperty(window, 'matchMedia', { @@ -1583,6 +1679,111 @@ describe('SliceModal — process settings layout', () => { }); }); +/** + * Process and filament lists hold back presets that resolve to a *different* + * printer, behind a per-slot "Show all". Two things must never be hidden: a + * preset whose compatibility is merely unknown, and whatever is currently + * selected. + */ +describe('SliceModal — presets filtered by the selected printer', () => { + const presets: UnifiedPresetsResponse = { + cloud: { printer: [], process: [], filament: [] }, + orca_cloud: { printer: [], process: [], filament: [] }, + local: { printer: [], process: [], filament: [] }, + standard: { + printer: [ + { id: 'Bambu Lab X1 Carbon 0.4 nozzle', name: 'Bambu Lab X1 Carbon 0.4 nozzle', source: 'standard' }, + ], + process: [ + { id: 'p-x1c', name: '0.20mm Standard @BBL X1C', source: 'standard' }, + { id: 'p-h2d', name: '0.20mm Standard @BBL H2D', source: 'standard' }, + { id: 'p-a1m', name: '0.20mm Standard @BBL A1M', source: 'standard' }, + // No printer tag at all — compatibility is unknown, never hidden. + { id: 'p-custom', name: 'My own profile', source: 'standard' }, + ], + filament: [{ id: 'f-x1c', name: 'Bambu PLA Basic @BBL X1C', source: 'standard' }], + }, + cloud_status: 'ok', + orca_cloud_status: 'ok', + } as UnifiedPresetsResponse; + + const processOptionNames = () => + Array.from(presetSelects()[1].options).map((o) => o.textContent); + + beforeEach(() => { + vi.clearAllMocks(); + mockApi.getSlicerPresets.mockResolvedValue(presets); + mockApi.getSlicerPrinterModels.mockResolvedValue({ 'Bambu Lab X1 Carbon': 'X1C' }); + mockApi.listSlicerPipelines.mockResolvedValue({ pipelines: [] }); + mockApi.getLibraryFilePlates.mockResolvedValue({ + file_id: 100, filename: 'Cube.stl', plates: [], is_multi_plate: false, + }); + mockApi.getLibraryFileFilamentRequirements.mockResolvedValue({ + file_id: 100, filename: 'Cube.stl', plate_id: 1, filaments: [], + }); + }); + + const open = async () => { + renderWithTracker({ source: { kind: 'libraryFile', id: 100, filename: 'Cube.stl' }, onClose: vi.fn() }); + await waitFor(() => expect(presetSelects().length).toBeGreaterThan(1)); + }; + + it('leaves out presets belonging to another printer', async () => { + await open(); + await waitFor(() => expect(processOptionNames()).toContain('0.20mm Standard @BBL X1C')); + expect(processOptionNames()).not.toContain('0.20mm Standard @BBL H2D'); + expect(processOptionNames()).not.toContain('0.20mm Standard @BBL A1M'); + }); + + it('keeps a preset whose compatibility cannot be determined', async () => { + await open(); + // An untagged preset carries no evidence either way; hiding it would make + // a user's own imported profiles vanish. + await waitFor(() => expect(processOptionNames()).toContain('My own profile')); + }); + + it('says how many it held back and reveals them on request', async () => { + const user = userEvent.setup(); + await open(); + + const hidden = await screen.findByText('2 hidden'); + expect(hidden).toBeInTheDocument(); + + await user.click(within(hidden.parentElement as HTMLElement).getByRole('button', { name: 'Show all' })); + await waitFor(() => expect(processOptionNames()).toContain('0.20mm Standard @BBL H2D')); + expect(processOptionNames()).toContain('0.20mm Standard @BBL A1M'); + }); + + it('collapses the list again on Show fewer', async () => { + const user = userEvent.setup(); + await open(); + + await user.click((await screen.findAllByRole('button', { name: 'Show all' }))[0]); + await waitFor(() => expect(processOptionNames()).toContain('0.20mm Standard @BBL H2D')); + + await user.click(screen.getAllByRole('button', { name: 'Show fewer' })[0]); + await waitFor(() => expect(processOptionNames()).not.toContain('0.20mm Standard @BBL H2D')); + }); + + it('never hides the preset that is currently selected', async () => { + const user = userEvent.setup(); + await open(); + + // Reach a cross-printer preset, pick it, then collapse the list again. + await user.click((await screen.findAllByRole('button', { name: 'Show all' }))[0]); + await waitFor(() => expect(processOptionNames()).toContain('0.20mm Standard @BBL H2D')); + await user.selectOptions(presetSelects()[1], 'standard:p-h2d'); + await user.click(screen.getAllByRole('button', { name: 'Show fewer' })[0]); + + // Dropping it from the options would blank the select and silently discard + // a deliberate cross-printer choice. + await waitFor(() => expect(processOptionNames()).toContain('0.20mm Standard @BBL H2D')); + expect(presetSelects()[1].value).toBe('standard:p-h2d'); + // The one still-hidden preset is counted; the selected one is not. + expect(screen.getByText('1 hidden')).toBeInTheDocument(); + }); +}); + describe('pickFilamentForSlot — printer-compat contract (#1851)', () => { // Index that recognises @BBL H2C / @BBL A1 tokens via the canonical // PRINTER_MODEL_MAP. Real production data comes through diff --git a/frontend/src/__tests__/components/SlicerSettingsPanel.test.tsx b/frontend/src/__tests__/components/SlicerSettingsPanel.test.tsx index 626ea43cb..b78750c6c 100644 --- a/frontend/src/__tests__/components/SlicerSettingsPanel.test.tsx +++ b/frontend/src/__tests__/components/SlicerSettingsPanel.test.tsx @@ -4,8 +4,9 @@ import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { render } from '../utils'; -import SlicerSettingsPanel from '../../components/SlicerSettingsPanel'; +import SlicerSettingsPanel, { type FilamentChoice } from '../../components/SlicerSettingsPanel'; import type { SettingValue } from '../../types/slicerSettings'; +import type { DesignOverride } from '../../types/plates'; /** * The panel is a controlled component: it renders from the `values` prop and @@ -16,11 +17,18 @@ import type { SettingValue } from '../../types/slicerSettings'; function Harness({ initial, onChange, + sourceOverrides, + initialSelected, + filamentChoices, }: { initial: Record; onChange: (v: Record, s: Record) => void; + sourceOverrides?: DesignOverride[]; + initialSelected?: string[]; + filamentChoices?: FilamentChoice[]; }) { const [values, setValues] = useState(initial); + const [selected, setSelected] = useState(new Set(initialSelected ?? [])); return ( + setSelected((prev) => { + const next = new Set(prev); + if (on) next.add(key); + else next.delete(key); + return next; + }) + } /> ); } /** Renders the panel and waits for its dynamically imported metadata. */ -async function renderPanel(initial: Record = {}) { +async function renderPanel( + initial: Record = {}, + extra: { + sourceOverrides?: DesignOverride[]; + initialSelected?: string[]; + filamentChoices?: FilamentChoice[]; + } = {}, +) { const onChange = vi.fn(); - render(); + render(); await waitFor(() => expect(screen.getByPlaceholderText('Search settings')).toBeInTheDocument()); return { onChange }; } @@ -177,3 +203,150 @@ describe('SlicerSettingsPanel', () => { expect(values.wall_loops).toBe(4); }); }); + +describe('SlicerSettingsPanel — search', () => { + it('treats underscores and spaces alike so a key can be typed naturally', async () => { + // outer_wall_speed's label is only "Outer wall" — the Speed page supplies + // the rest — so the key is the only place the full phrase appears. + const user = userEvent.setup(); + await renderPanel(); + await user.click(screen.getByRole('button', { name: 'Expert' })); + await user.type(screen.getByPlaceholderText('Search settings'), 'outer wall speed'); + await waitFor(() => expect(screen.getByLabelText(/^Outer wall/)).toBeInTheDocument()); + }); + + it('matches a page or group name, not just option labels', async () => { + const user = userEvent.setup(); + await renderPanel(); + await user.click(screen.getByRole('button', { name: 'Expert' })); + await user.type(screen.getByPlaceholderText('Search settings'), 'ironing'); + await waitFor(() => expect(screen.getByLabelText(/^Ironing type/)).toBeInTheDocument()); + }); +}); + +describe("SlicerSettingsPanel — the source file's own settings", () => { + const sourceOverrides: DesignOverride[] = [ + { key: 'wall_loops', value: '5', printer_coupled: false }, + { key: 'outer_wall_speed', value: '200', printer_coupled: true }, + // A key the vendored schema has no entry for. It still applies, so it must + // not silently vanish from a panel that claims to show what will be used. + { key: 'some_unlisted_key', value: '7', printer_coupled: false }, + ]; + + it("shows the designer's value against the option once switched on", async () => { + const user = userEvent.setup(); + await renderPanel({}, { sourceOverrides, initialSelected: ['wall_loops'] }); + const input = await showOption(user, 'Wall loops', 'wall loops'); + expect(input).toHaveValue(5); + expect(screen.getByText('from file')).toBeInTheDocument(); + }); + + it('falls back to the preset value when it is switched off', async () => { + const user = userEvent.setup(); + await renderPanel({}, { sourceOverrides, initialSelected: [] }); + // wall_loops defaults to 2 in the schema. + const input = await showOption(user, 'Wall loops', 'wall loops'); + expect(input).toHaveValue(2); + }); + + it('flags a machine-coupled setting rather than applying it quietly', async () => { + const user = userEvent.setup(); + await renderPanel({}, { sourceOverrides, initialSelected: ['wall_loops'] }); + await user.click(screen.getByRole('button', { name: 'Expert' })); + await user.type(screen.getByPlaceholderText('Search settings'), 'outer wall speed'); + await waitFor(() => expect(screen.getByText("designer's printer")).toBeInTheDocument()); + }); + + it('lists source settings the schema has no entry for', async () => { + await renderPanel({}, { sourceOverrides, initialSelected: ['some_unlisted_key'] }); + await waitFor(() => expect(screen.getByText('Other settings from this file')).toBeInTheDocument()); + expect(screen.getByText('some_unlisted_key')).toBeInTheDocument(); + expect(screen.getByText('7')).toBeInTheDocument(); + }); + + it('keeps a typed value ahead of the file\'s', async () => { + const user = userEvent.setup(); + const { onChange } = await renderPanel({}, { sourceOverrides, initialSelected: ['wall_loops'] }); + + const input = await showOption(user, 'Wall loops', 'wall loops'); + expect(input).toHaveValue(5); + await user.clear(input); + await user.type(input, '3'); + + // The typed value is what gets sent; the file's tick is unaffected and the + // backend applies it first, so last-write-wins leaves 3 in the process JSON. + await waitFor(() => { + const [, serialized] = onChange.mock.calls.at(-1)!; + expect(serialized.wall_loops).toBe('3'); + }); + }); +}); + +describe('SlicerSettingsPanel — filament-slot options', () => { + const filamentChoices: FilamentChoice[] = [ + { index: 1, label: 'Bambu PLA Basic', color: '#FF0000' }, + { index: 2, label: 'Bambu Support for PLA', color: '#FFFFFF' }, + ]; + + it("follows the slicer's own gating rather than being live regardless", async () => { + // The interface picker sits behind have_support_material, so it greys out + // with supports off — becoming a dropdown must not exempt it from the + // rules every other option obeys. + const user = userEvent.setup(); + await renderPanel({}, { filamentChoices }); + const off = await showOption(user, 'Support/raft interface', 'support_interface_filament'); + expect(off).toBeDisabled(); + }); + + it('is operable once supports are switched on', async () => { + const user = userEvent.setup(); + await renderPanel({ enable_support: true }, { filamentChoices }); + const on = await showOption(user, 'Support/raft interface', 'support_interface_filament'); + expect(on).toBeEnabled(); + }); + + it('offers the picked filaments instead of a bare number field', async () => { + const user = userEvent.setup(); + await renderPanel({}, { filamentChoices }); + const control = await showOption(user, 'Support/raft base', 'support_filament'); + + expect(control.tagName).toBe('SELECT'); + const labels = Array.from((control as HTMLSelectElement).options).map((o) => o.textContent); + expect(labels).toEqual(['Default', '1: Bambu PLA Basic', '2: Bambu Support for PLA']); + }); + + it("defaults to the slicer's 0, meaning no specific filament", async () => { + const user = userEvent.setup(); + await renderPanel({}, { filamentChoices }); + const control = await showOption(user, 'Support/raft base', 'support_filament'); + expect(control).toHaveValue('0'); + }); + + it('sends the slot index the slicer expects', async () => { + const user = userEvent.setup(); + const { onChange } = await renderPanel({ enable_support: true }, { filamentChoices }); + const control = await showOption(user, 'Support/raft interface', 'support_interface_filament'); + + await user.selectOptions(control, '2'); + await waitFor(() => { + const [, serialized] = onChange.mock.calls.at(-1)!; + expect(serialized.support_interface_filament).toBe('2'); + }); + }); + + it('stays a plain number field when no filaments have been picked', async () => { + // STL sources and the pre-plate-analysis window have no slot list yet; + // an empty dropdown would be worse than the number input it replaced. + const user = userEvent.setup(); + await renderPanel({}, { filamentChoices: [] }); + const control = await showOption(user, 'Support/raft base', 'support_filament'); + expect(control.tagName).toBe('INPUT'); + }); + + it('leaves unrelated integer options alone', async () => { + const user = userEvent.setup(); + await renderPanel({}, { filamentChoices }); + const control = await showOption(user, 'Wall loops', 'wall loops'); + expect(control.tagName).toBe('INPUT'); + }); +}); diff --git a/frontend/src/__tests__/utils/slicerPrinterMatch.test.ts b/frontend/src/__tests__/utils/slicerPrinterMatch.test.ts index cb0f7e769..66f31391b 100644 --- a/frontend/src/__tests__/utils/slicerPrinterMatch.test.ts +++ b/frontend/src/__tests__/utils/slicerPrinterMatch.test.ts @@ -485,3 +485,61 @@ describe('presetCompatibility — nozzle-only @ tag (#2628 follow-up)', () ).toBe('mismatch'); }); }); + +describe("presetCompatibility — BambuStudio's \"# \" user-clone prefix", () => { + const index = buildCompatibilityIndex(PRINTER_MODELS); + // Editing a system preset saves a copy under this name; .bbscfg bundle + // exports use the same convention. The backend already normalises it in + // _canonical_printer_model. + const CLONED_X1C = '# Bambu Lab X1 Carbon 0.4 nozzle'; + + it('still matches a printer-tagged preset when the printer is a clone', () => { + // Regression: the prefix failed the "Bambu Lab …" test, so every preset + // came back 'unknown' and the dropdown filter silently did nothing. + expect( + presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'process', CLONED_X1C, index), + ).toBe('match'); + }); + + it('still rules out another printer when the selected printer is a clone', () => { + expect( + presetCompatibility({ name: '0.20mm Standard @BBL H2D' }, 'process', CLONED_X1C, index), + ).toBe('mismatch'); + }); + + it('matches a cloned preset against an unprefixed printer', () => { + expect( + presetCompatibility({ name: '# 0.20mm Standard @BBL X1C' }, 'process', X1C, index), + ).toBe('match'); + }); + + it('still compares the nozzle size through the prefix', () => { + expect( + presetCompatibility({ name: '0.20mm Standard @BBL X1C 0.6 nozzle' }, 'process', CLONED_X1C, index), + ).toBe('mismatch'); + }); + + it('matches compatible_printers with the prefix on either side', () => { + // A preset cloned from a system printer lists the *unprefixed* name; a raw + // comparison against the "# " form reads as a mismatch, which now hides + // the preset rather than merely demoting it. + expect( + presetCompatibility({ name: 'My Process', compatible_printers: [X1C] }, 'process', CLONED_X1C, index), + ).toBe('match'); + expect( + presetCompatibility({ name: 'My Process', compatible_printers: [CLONED_X1C] }, 'process', X1C, index), + ).toBe('match'); + }); + + it('does not let the prefix turn a genuine mismatch into a match', () => { + expect( + presetCompatibility({ name: 'My Process', compatible_printers: [P2S] }, 'process', CLONED_X1C, index), + ).toBe('mismatch'); + }); + + it('leaves an untagged clone unknown rather than guessing', () => { + expect( + presetCompatibility({ name: '# My own profile' }, 'process', CLONED_X1C, index), + ).toBe('unknown'); + }); +}); diff --git a/frontend/src/__tests__/utils/slicerToggle.test.ts b/frontend/src/__tests__/utils/slicerToggle.test.ts index 4ab6df0b4..47253eb91 100644 --- a/frontend/src/__tests__/utils/slicerToggle.test.ts +++ b/frontend/src/__tests__/utils/slicerToggle.test.ts @@ -2,10 +2,12 @@ import { describe, it, expect } from 'vitest'; import processSchema from '../../data/slicer/process-schema.json'; import processToggles from '../../data/slicer/process-toggle-rules.json'; +import processTree from '../../data/slicer/process-ui-tree.json'; import { disabledKeys, makeConfigReader } from '../../lib/slicerToggle'; -import type { ProcessSchema, SettingValue } from '../../types/slicerSettings'; +import type { ProcessSchema, ProcessUiTree, SettingValue } from '../../types/slicerSettings'; const schema = processSchema as unknown as ProcessSchema; +const tree = processTree as unknown as ProcessUiTree; const toggles = processToggles as { locals: Record; rules: Array<{ fields: string[]; enable_if: string }> }; const disabled = (settings: Record) => disabledKeys(settings, schema, toggles); @@ -93,3 +95,43 @@ describe('disabledKeys', () => { expect(decided.length).toBeGreaterThanOrEqual(Math.floor(toggles.rules.length * 0.6)); }); }); + +describe('vendored process schema', () => { + // The extractor reads defaults and bounds out of C++ initialisers, so float + // literals arrive in source form — `0.`, `0.3f`, `100.%`, and `0.f` split + // into [0, "f"]. Rendering those verbatim put a column of "0." in the Line + // width group. scripts/generate-slicer-schema.mjs normalises them; this + // guards a regeneration that drops that step. + const LITERAL_ARTEFACT = /^-?[\d]*\.$|\.%$|^-?[\d.]+f$/; + + const offenders = (field: 'default' | 'min' | 'max') => + Object.entries(schema) + .filter(([, opt]) => { + const v = opt[field]; + if (Array.isArray(v)) return v.some((x) => x === 'f'); + return typeof v === 'string' && LITERAL_ARTEFACT.test(v); + }) + .map(([key]) => `${key}.${field}`); + + it.each(['default', 'min', 'max'] as const)('carries no C++ literal artefacts in %s', (field) => { + expect(offenders(field)).toEqual([]); + }); + + it('renders the line-width defaults as plain numbers', () => { + // The reported symptom: every field in this group showed "0." + for (const key of ['line_width', 'outer_wall_line_width', 'inner_wall_line_width', 'support_line_width']) { + expect(schema[key].default).toBe('0'); + } + expect(schema.bridge_line_width.default).toBe('100%'); + }); + + it('keeps every option the UI tree references', () => { + // A trim that drops a referenced key renders a control with no type, + // label or default. + for (const page of tree) { + for (const group of page.groups) { + for (const key of group.options) expect(schema[key]).toBeDefined(); + } + } + }); +}); diff --git a/frontend/src/components/SliceModal.tsx b/frontend/src/components/SliceModal.tsx index c1bace182..43b83cc4a 100644 --- a/frontend/src/components/SliceModal.tsx +++ b/frontend/src/components/SliceModal.tsx @@ -1,5 +1,5 @@ import { Cloud, CloudOff, Cog, Loader2, RefreshCw, X } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useId, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { @@ -17,7 +17,7 @@ import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext'; import { useToast } from '../contexts/ToastContext'; import { useIsWideLayout } from '../hooks/useIsWideLayout'; import { PlatePickerModal } from './PlatePickerModal'; -import SlicerSettingsPanel from './SlicerSettingsPanel'; +import SlicerSettingsPanel, { type FilamentChoice } from './SlicerSettingsPanel'; import type { DesignOverride, PlateFilament } from '../types/plates'; import type { SettingValue } from '../types/slicerSettings'; import { @@ -189,16 +189,6 @@ function formatElapsed(seconds: number): string { return `${h}h ${remM}m`; } -// Render a slicer parameter value for the design-settings list. Bambu's process -// schema stores everything as strings or arrays of strings, so this only has to -// flatten arrays and keep scalars readable — no unit or type interpretation, -// which would rot against every slicer release. -function formatDesignValue(value: unknown): string { - if (Array.isArray(value)) return value.map((v) => String(v)).join(', '); - if (value == null) return ''; - return String(value); -} - export function SliceModal({ source, onClose }: SliceModalProps) { const { t } = useTranslation(); const { trackJob } = useSliceJobTracker(); @@ -256,7 +246,6 @@ export function SliceModal({ source, onClose }: SliceModalProps) { // accelerations, prime-tower geometry) are listed but start unticked — those // were tuned for the designer's printer and can be plain wrong on another. 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 @@ -432,6 +421,24 @@ export function SliceModal({ source, onClose }: SliceModalProps) { [printerModelsQuery.data], ); + // Slot list for the settings panel's filament pickers (support base and + // interface, and the Multimaterial page's per-region options). Those store a + // plain integer, so without this the user has to map slot numbers onto their + // own AMS by hand. Falls back to the slot's material when a slot has no pick + // yet, so the list is never a column of blanks. + const filamentChoices = useMemo(() => { + const data = presetsQuery.data; + return filamentSlots.map((slot, idx) => { + const ref = filamentPresets[idx] ?? null; + const preset = data && ref ? findPreset(data, ref, 'filament') : null; + return { + index: idx + 1, + label: preset?.name || slot.type || t('slice.filamentSlotUnset', 'not set'), + color: slot.color || undefined, + }; + }); + }, [filamentSlots, filamentPresets, presetsQuery.data, t]); + // Printer / process preset names the source 3MF was prepared with. The // plates query resolves before the presets query (the latter is gated on // it), so these are known by the time the pre-pick effects run. @@ -857,68 +864,6 @@ export function SliceModal({ source, onClose }: SliceModalProps) { selectedPrinterName={selectedPrinterName} compatIndex={compatIndex} /> - {/* Designer's process tweaks (#2622). BambuStudio records which - keys deviate from the stock preset in the 3MF itself, so a - re-slice for another printer can carry them instead of - flattening them under --load-settings. Hidden entirely when - the source lists none, and disabled in embedded mode where - the process JSON these patch is never sent. */} - {designOverrides.length > 0 && ( -
- - {designExpanded && ( -
- {designOverrides.map((o) => ( - - ))} -
- )} -
- )} {/* Bed-type override (#1337). Always visible, always enabled. The backend patches curr_bed_type on the resolved process @@ -1026,13 +971,17 @@ export function SliceModal({ source, onClose }: SliceModalProps) { {/* Right column: the settings panel. It owns this column, so there is nothing to collapse it out of the way of — the disclosure below lg exists only because the single-column - stack cannot afford 348 options unfolded. */} + stack cannot afford 348 options unfolded. + + Kept on screen in embedded mode but disabled rather than + removed: nothing here is sent on that path (the file's own + settings drive the slice), and dropping the column outright + made the dialog look like it had lost a feature whenever + the toggle was flipped. */}
- {/* Process settings, mirroring OrcaSlicer's own Print Settings - tabs. Hidden entirely in embedded mode, where no process - JSON is sent for these to patch. */} - {!useEmbedded && ( -
+
{panelOpen && ( @@ -1062,12 +1021,28 @@ export function SliceModal({ source, onClose }: SliceModalProps) { setProcessOverrides(values); setSerializedProcessOverrides(serialized); }} - disabled={isEnqueuing} + disabled={isEnqueuing || useEmbedded} + // The designer's own deviations (#2622) are shown + // against the options they belong to rather than in + // a list of their own. Only the tick state lives + // here; the values still travel as design_overrides, + // so the backend keeps reading them from the file + // and keys outside the vendored schema stay faithful. + filamentChoices={filamentChoices} + sourceOverrides={designOverrides} + sourceSelected={designKeys} + onToggleSource={(key, on) => + setDesignKeys((prev) => { + const next = new Set(prev); + if (on) next.add(key); + else next.delete(key); + return next; + }) + } />
)}
- )}
@@ -1259,8 +1234,8 @@ interface PresetDropdownProps { // configuring against the source 3MF's per-slot colour. swatchColor?: string; // Selected printer context (#1325). When provided for a process / filament - // slot, presets that resolve to a different printer (per compatIndex) move - // into a trailing "Other printers" group instead of the main tier list. + // slot, presets that resolve to a different printer (per compatIndex) are + // held back behind a "Show all" link instead of padding out the main list. selectedPrinterName?: string | null; compatIndex?: PrinterCompatibilityIndex; } @@ -1277,6 +1252,14 @@ function PresetDropdown({ compatIndex, }: PresetDropdownProps) { const { t } = useTranslation(); + // Reveals the other-printer group for this slot only. Per-dropdown rather + // than modal-wide: wanting a filament from another printer's library says + // nothing about wanting its process profiles too. + const [showAll, setShowAll] = useState(false); + // Binds the label to the select now that they are siblings rather than + // nested. Filament slots render several of these, so the id must be unique + // per instance rather than derived from the slot name. + const selectId = useId(); // Tier sections (imported → cloud → standard), plus — for a process / // filament slot with a selected printer — a trailing group of presets that @@ -1322,12 +1305,30 @@ function PresetDropdown({ return { sections: compatSections, otherEntries: other }; }, [data, slot, t, selectedPrinterName, compatIndex]); + // Other-printer presets are held back by default so the list shows what is + // usable on the selected printer. Two things are never hidden: a preset whose + // compatibility is merely *unknown* (it never reaches otherEntries), and the + // one currently selected — a pipeline or an auto-pick can land on a + // cross-printer preset, and dropping it from the options would blank the + // select and silently discard the choice. + const selectedRefValue = toRefValue(value); + const visibleOther = useMemo(() => { + if (showAll) return otherEntries; + return otherEntries.filter((p) => `${p.source}:${p.id}` === selectedRefValue); + }, [showAll, otherEntries, selectedRefValue]); + + const hiddenCount = otherEntries.length - visibleOther.length; const totalEntries = - sections.reduce((sum, s) => sum + s.entries.length, 0) + otherEntries.length; + sections.reduce((sum, s) => sum + s.entries.length, 0) + visibleOther.length; return ( -