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/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 ( -