mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
fix(slicer): filter process/filament presets by uploaded bundles, not preset names (#1325)
The process dropdown still mixed @BBL P2S presets into an X1C list: slicerPrinterMatch matched cloud/standard presets by parsing the @BBL <model> name suffix against a hardcoded model-code allow-list that was missing P2S, H2C and X2D — so those presets resolved to "unknown" and stayed in the main list instead of "Other printers". Drop both hardcoded model tables. Compatibility now comes from the user's uploaded Slicer Bundles: a bundle is scoped to one printer and lists the presets it ships, so a preset matches a printer exactly when some bundle for that printer contains it. New models are covered the moment their bundle is uploaded.
This commit is contained in:
@@ -18,6 +18,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean).
|
||||
|
||||
### Fixed
|
||||
- **Slicer: the Process / Filament dropdowns now filter by printer using the uploaded Slicer Bundles instead of guessing from preset names (#1325, reported by @IndividualGhost1905)** — After the printer-preset pre-selection landed, the reporter found the Process Profile dropdown still showed a flat mix of `@BBL X1C` and `@BBL P2S` presets with the printer set to X1C — P2S presets that should have dropped into the trailing "Other printers" group sat in the main list. **Root cause**: `frontend/src/utils/slicerPrinterMatch.ts` resolved each cloud / standard preset's printer by parsing the `@BBL <model>` suffix of its name against a hard-coded `KNOWN_MODEL_CODES` allow-list. That list (`X1C, X1E, X1, P1S, P1P, A1M, A1, H2D, H2S`) was missing `P2S` (and `H2C`, `X2D`), so every `@BBL P2S` preset parsed to an empty model-code set, `presetCompatibility` returned `unknown`, and the dropdown keeps `unknown` presets in the main list (only `mismatch` moves to "Other printers"). It was a maintenance trap by construction: every new Bambu model silently broke filtering until someone edited the list. **Fix**: the name-suffix heuristic and both hard-coded model tables (`KNOWN_MODEL_CODES`, `PRINTER_NAME_PATTERNS`) are removed. Compatibility is now read from ground truth — the user's uploaded Slicer Bundles (`.bbscfg`). Each bundle is scoped to one printer and lists the process / filament presets it ships, so "process P works with printer X" holds exactly when some uploaded bundle for printer X contains P. `buildCompatibilityIndex` builds a `presetName → {printer names}` index per slot from `GET /slicer/bundles` (already fetched by the modal), and `presetCompatibility` consults it — still preferring an imported preset's own `compatible_printers` list when present. A newly released Bambu model is covered the moment its bundle is uploaded, with no code change. Presets no bundle covers stay in the main list (`unknown` is never hidden), so a user with no bundle imported sees the un-filtered list rather than a wrong one. `printerPresetCode` / `presetModelCodes` are gone; `SliceModal` passes the bundle-derived index to `PresetDropdown`, `pickProcessDefault`, and `pickFilamentForSlot` in place of the old model code. **Tests**: `slicerPrinterMatch.test.ts` rewritten — 12 tests covering `buildCompatibilityIndex` (per-printer mapping, multi-bundle union, `# ` user-clone-prefix stripping, empty-printer skip) and `presetCompatibility` (imported-tier `compatible_printers` exact match, bundle-driven match / mismatch / unknown, the #1325 P2S-into-X1C repro, no-bundles and no-printer-selected cases). 32 SliceModal tests green; frontend build clean; backend ruff clean.
|
||||
- **PWA: Bambuddy can now be installed as an app on Android, and the font is self-hosted (#1460, reported by @Soopahfly)** — Reporter could install Bambuddy as a PWA on desktop but not on an Android phone (Pixel 9 Pro XL, failing in both Chrome and Edge). A thorough remote-DevTools trace confirmed the manifest was valid, all icons/screenshots present, and the service worker activated, running, and controlling the page — DevTools reported no installability blockers — yet no install prompt ever appeared. **Two root causes, both Bambuddy-side.** **(1) No in-app install trigger.** Chrome for Android removed the automatic install mini-infobar in Chrome 108 (2022); since then a site must either listen for `beforeinstallprompt` and surface its own button, or the user must dig into the browser's ⋮ menu. Desktop Chrome still auto-shows the omnibox install icon, which is exactly why desktop "worked" and Android didn't. Bambuddy had no `beforeinstallprompt` handler at all, so on Android there was no discoverable install path. New `frontend/src/components/InstallAppButton.tsx` captures the `beforeinstallprompt` event (calling `preventDefault()` so the button is the single predictable entry point), re-fires it on click, shows a success toast on accept, and clears the captured prompt afterwards (a prompt can only be used once). It renders nothing when there is no pending prompt — already installed, unsupported browser, or iOS Safari (no programmatic install) — so it never shows a dead button. Added to both the expanded and compact sidebar-footer rows in `Layout.tsx`, next to the GitHub link. New `nav.installApp` / `nav.installAppSuccess` i18n keys with real translations in all 9 locales (en/de/es/fr/it/ja/pt-BR/zh-CN/zh-TW). **(2) Inter font loaded from the Google Fonts CDN.** `frontend/src/index.css` pulled Inter via `@import url('https://fonts.googleapis.com/css2?...')`. For a local-first, offline-capable PWA this is wrong: it leaks a request to Google on every load, breaks the UI font when offline, and — as the reporter's trace showed — triggered CSP console errors (`connect-src` doesn't allow `fonts.googleapis.com`). The service worker made it worse: a request to `fonts.googleapis.com/css2` has path `/css2` (no `.css` extension), so it missed the CSS branch, fell through to the catch-all HTML branch, and on failure was answered with the cached `index.html` — which is why the font request came back as `text/html`. **Fix**: the two Inter variable woff2 files (latin + latin-ext, one file covers every weight via the variable axis) are now bundled in `frontend/public/fonts/` and declared with `@font-face` in `index.css`, served same-origin. The service worker now (a) skips all cross-origin requests entirely — letting the browser handle them so a failed cross-origin fetch can never be answered with `index.html` again — (b) caches the font files in `STATIC_ASSETS` and via a `.woff2` / `/fonts/` match in the cache-first branch so the UI renders offline, and (c) bumps its cache version. With Inter self-hosted, `fonts.googleapis.com` and `fonts.gstatic.com` were dropped from the SPA and gcode-viewer CSP directives in `backend/app/main.py` (the `/docs` ReDoc/Swagger CSP keeps them — that third-party UI genuinely loads Google Fonts). Frontend build clean, i18n parity green across 9 locales, backend ruff clean, 17 security-header tests green.
|
||||
- **Flow Calibration now actually runs when the print option is enabled (#1478, reported by @andreirusu99)** — Reporter on an H2S saw poor extrusion around corners (classic too-high K factor); the printer's flow-dynamics calibration step never appeared in the pre-print checklist even with **Flow Calibration** toggled on in the Re-print dialog, while the same 3MF printed from Bambu Studio did calibrate. **Root causes, both in the `project_file` command built by `start_print` (`backend/app/services/bambu_mqtt.py`)**: (1) `extrude_cali_flag` was hardcoded to `0`. A BambuStudio request-topic capture from a real H2D (plus X1C and P2S captures) shows BambuStudio always sends `1` (run flow-dynamics calibration) or `2` (skip, reuse the stored PA value), paired with `flow_cali`, and **never** `0` — so the printer skipped calibration regardless of the toggle. (2) An earlier revision integer-encoded the calibration/leveling fields (`timelapse`, `bed_leveling`, `flow_cali`, `vibration_cali`, `layer_inspect`) for the H2 family (H2D/H2S/H2C/X2D) on the belief that H2 firmware required `0/1`; the same H2D capture disproves this — BambuStudio sends plain JSON booleans for every model. The "integer required" claim conflated these fields with `use_ams`, which genuinely must stay boolean (H2D Pro reads an integer `use_ams` as a nozzle index — the actual #1386 cause). **Fix**: `extrude_cali_flag` is now `1 if flow_cali else 2`, and the five calibration/leveling fields are sent as JSON booleans for all models, matching BambuStudio's wire format exactly. The H-family integer-conversion branch (`is_h_family`) is removed. `use_ams` is unchanged. This affects only the outbound print command; the virtual-printer inbound coercion of slicer integer `0/1` flags (#1403) is a separate path and untouched. **Tests**: in `test_bambu_mqtt.py`, the two tests that asserted the integer format (`test_x2d_uses_integer_format_for_calibration_fields`, `test_h2s_keeps_integer_format_for_calibration_fields`) are corrected to assert booleans and renamed; all three model tests (X2D/H2S/P2S) now also assert `extrude_cali_flag` is `1` when flow cali is on and `2` when off. 381 mqtt + virtual-printer tests green; backend ruff clean.
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset
|
||||
# Precise compatibility link — the slicer's own compatible_printers
|
||||
# list, captured at import time. Lets the SliceModal filter the
|
||||
# process / filament dropdowns by the selected printer without
|
||||
# falling back to its name-suffix heuristic.
|
||||
# falling back to the uploaded-bundle index.
|
||||
preset.compatible_printers = _parse_compatible_printers(p.compatible_printers)
|
||||
slots[slot].append(preset)
|
||||
return slots
|
||||
@@ -188,7 +188,7 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset
|
||||
def _parse_compatible_printers(raw: str | None) -> list[str] | None:
|
||||
"""``LocalPreset.compatible_printers`` stores a JSON array of printer-preset
|
||||
names. Return the parsed list, or ``None`` on missing / malformed data so
|
||||
the SliceModal falls back to its name-suffix heuristic for that preset."""
|
||||
the SliceModal falls back to the uploaded-bundle index for that preset."""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
|
||||
@@ -37,8 +37,9 @@ class UnifiedPreset(BaseModel):
|
||||
local tier (stored at import time); left ``None`` for cloud (no per-preset
|
||||
detail is fetched — rate limits) and standard (the sidecar's bundled
|
||||
listing doesn't expose it). The SliceModal uses it to filter the
|
||||
process / filament dropdowns by the selected printer (#1325), falling back
|
||||
to a name-suffix heuristic when it is ``None``.
|
||||
process / filament dropdowns by the selected printer (#1325); when it is
|
||||
``None`` the modal falls back to the user's uploaded Slicer Bundles, which
|
||||
map each printer to the presets it ships.
|
||||
"""
|
||||
|
||||
id: str
|
||||
|
||||
@@ -1,96 +1,120 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
printerPresetCode,
|
||||
presetModelCodes,
|
||||
buildCompatibilityIndex,
|
||||
presetCompatibility,
|
||||
EMPTY_COMPATIBILITY_INDEX,
|
||||
type CompatibilityBundle,
|
||||
} from '../../utils/slicerPrinterMatch';
|
||||
|
||||
describe('printerPresetCode', () => {
|
||||
it('maps Bambu stock printer names to model codes', () => {
|
||||
expect(printerPresetCode('Bambu Lab X1 Carbon 0.4 nozzle')).toBe('X1C');
|
||||
expect(printerPresetCode('Bambu Lab X1E 0.4 nozzle')).toBe('X1E');
|
||||
expect(printerPresetCode('Bambu Lab X1 0.4 nozzle')).toBe('X1');
|
||||
expect(printerPresetCode('Bambu Lab P1S 0.4 nozzle')).toBe('P1S');
|
||||
expect(printerPresetCode('Bambu Lab P1P 0.4 nozzle')).toBe('P1P');
|
||||
expect(printerPresetCode('Bambu Lab A1 mini 0.4 nozzle')).toBe('A1M');
|
||||
expect(printerPresetCode('Bambu Lab A1 0.4 nozzle')).toBe('A1');
|
||||
expect(printerPresetCode('Bambu Lab H2D 0.4 nozzle')).toBe('H2D');
|
||||
const X1C = 'Bambu Lab X1 Carbon 0.4 nozzle';
|
||||
const P2S = 'Bambu Lab P2S 0.4 nozzle';
|
||||
|
||||
// Two uploaded bundles, one per printer — the ground truth all matching
|
||||
// is derived from. Note P2S: a model the old hard-coded list never knew
|
||||
// about, now covered purely because its bundle was uploaded (#1325).
|
||||
const BUNDLES: CompatibilityBundle[] = [
|
||||
{
|
||||
printer_preset_name: X1C,
|
||||
process: ['0.20mm Standard @BBL X1C', '0.20mm Strength @BBL X1C'],
|
||||
filament: ['Bambu PLA Basic @BBL X1C'],
|
||||
},
|
||||
{
|
||||
printer_preset_name: P2S,
|
||||
process: ['0.20mm Standard @BBL P2S', '0.16mm Standard @BBL P2S'],
|
||||
filament: ['Bambu PLA Basic @BBL P2S'],
|
||||
},
|
||||
];
|
||||
|
||||
describe('buildCompatibilityIndex', () => {
|
||||
it('maps each preset name to the printers whose bundles ship it', () => {
|
||||
const index = buildCompatibilityIndex(BUNDLES);
|
||||
expect([...(index.process.get('0.20mm Standard @BBL X1C') ?? [])]).toEqual([X1C]);
|
||||
expect([...(index.process.get('0.16mm Standard @BBL P2S') ?? [])]).toEqual([P2S]);
|
||||
expect([...(index.filament.get('Bambu PLA Basic @BBL P2S') ?? [])]).toEqual([P2S]);
|
||||
});
|
||||
|
||||
it('prefers the more specific name when prefixes overlap', () => {
|
||||
// "X1 Carbon" must not be read as bare "X1"; "A1 mini" not as "A1".
|
||||
expect(printerPresetCode('X1 Carbon')).toBe('X1C');
|
||||
expect(printerPresetCode('A1 mini')).toBe('A1M');
|
||||
it('unions printers when several bundles ship the same preset name', () => {
|
||||
const shared = '0.20mm Standard';
|
||||
const index = buildCompatibilityIndex([
|
||||
{ printer_preset_name: X1C, process: [shared], filament: [] },
|
||||
{ printer_preset_name: P2S, process: [shared], filament: [] },
|
||||
]);
|
||||
expect(index.process.get(shared)).toEqual(new Set([X1C, P2S]));
|
||||
});
|
||||
|
||||
it('returns null for an unrecognised / custom printer', () => {
|
||||
expect(printerPresetCode('My Custom Voron 0.4')).toBeNull();
|
||||
expect(printerPresetCode('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('presetModelCodes', () => {
|
||||
it('parses a single model from the @BBL suffix', () => {
|
||||
expect([...presetModelCodes('0.20mm Standard @BBL X1C')]).toEqual(['X1C']);
|
||||
expect([...presetModelCodes('Bambu PLA Basic @BBL A1M')]).toEqual(['A1M']);
|
||||
it("strips BambuStudio's '# ' user-clone prefix so names compare equal", () => {
|
||||
const index = buildCompatibilityIndex([
|
||||
{ printer_preset_name: X1C, process: ['# 0.20mm Custom'], filament: [] },
|
||||
]);
|
||||
expect(index.process.has('0.20mm Custom')).toBe(true);
|
||||
});
|
||||
|
||||
it('parses multiple models from one suffix', () => {
|
||||
const codes = presetModelCodes('0.20mm Standard @BBL X1C X1');
|
||||
expect(codes.has('X1C')).toBe(true);
|
||||
expect(codes.has('X1')).toBe(true);
|
||||
expect(codes.size).toBe(2);
|
||||
});
|
||||
|
||||
it('returns an empty set for generic / untagged presets', () => {
|
||||
expect(presetModelCodes('Generic PLA @base').size).toBe(0);
|
||||
expect(presetModelCodes('My Custom Process').size).toBe(0);
|
||||
it('skips bundles with no printer name', () => {
|
||||
const index = buildCompatibilityIndex([
|
||||
{ printer_preset_name: '', process: ['Orphan Process'], filament: [] },
|
||||
]);
|
||||
expect(index.process.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('presetCompatibility', () => {
|
||||
it('uses compatible_printers exactly when present (local-tier override)', () => {
|
||||
const preset = {
|
||||
name: 'My Process',
|
||||
compatible_printers: ['Bambu Lab X1 Carbon 0.4 nozzle'],
|
||||
};
|
||||
expect(
|
||||
presetCompatibility(preset, 'Bambu Lab X1 Carbon 0.4 nozzle', 'X1C'),
|
||||
).toBe('match');
|
||||
expect(presetCompatibility(preset, 'Bambu Lab A1 0.4 nozzle', 'A1')).toBe(
|
||||
'mismatch',
|
||||
);
|
||||
});
|
||||
const index = buildCompatibilityIndex(BUNDLES);
|
||||
|
||||
it('falls back to the name heuristic when compatible_printers is absent', () => {
|
||||
const preset = { name: '0.20mm Standard @BBL X1C' };
|
||||
expect(presetCompatibility(preset, 'Bambu Lab X1 Carbon 0.4 nozzle', 'X1C')).toBe(
|
||||
'match',
|
||||
);
|
||||
expect(presetCompatibility(preset, 'Bambu Lab A1 0.4 nozzle', 'A1')).toBe(
|
||||
'mismatch',
|
||||
);
|
||||
});
|
||||
|
||||
it('is unknown when the preset carries no resolvable model', () => {
|
||||
expect(presetCompatibility({ name: 'Generic PLA @base' }, 'Bambu Lab X1 Carbon', 'X1C')).toBe(
|
||||
'unknown',
|
||||
);
|
||||
});
|
||||
|
||||
it('is unknown when the selected printer is unrecognised', () => {
|
||||
expect(
|
||||
presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'My Custom Printer', null),
|
||||
).toBe('unknown');
|
||||
it('uses compatible_printers exactly when present (imported / local tier)', () => {
|
||||
const preset = { name: 'My Process', compatible_printers: [X1C] };
|
||||
expect(presetCompatibility(preset, 'process', X1C, EMPTY_COMPATIBILITY_INDEX)).toBe('match');
|
||||
expect(presetCompatibility(preset, 'process', P2S, EMPTY_COMPATIBILITY_INDEX)).toBe('mismatch');
|
||||
});
|
||||
|
||||
it('is unknown when compatible_printers is set but no printer is selected', () => {
|
||||
expect(
|
||||
presetCompatibility({ name: 'P', compatible_printers: [X1C] }, 'process', null, index),
|
||||
).toBe('unknown');
|
||||
});
|
||||
|
||||
it('matches a preset shipped by the selected printer\'s bundle', () => {
|
||||
expect(presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'process', X1C, index)).toBe(
|
||||
'match',
|
||||
);
|
||||
expect(
|
||||
presetCompatibility({ name: 'Bambu PLA Basic @BBL P2S' }, 'filament', P2S, index),
|
||||
).toBe('match');
|
||||
});
|
||||
|
||||
it('flags a preset whose bundle is for a different printer (the #1325 bug)', () => {
|
||||
// X1C selected, but this process only ships in the P2S bundle.
|
||||
expect(presetCompatibility({ name: '0.16mm Standard @BBL P2S' }, 'process', X1C, index)).toBe(
|
||||
'mismatch',
|
||||
);
|
||||
});
|
||||
|
||||
it('is unknown when no uploaded bundle covers the preset', () => {
|
||||
expect(
|
||||
presetCompatibility({ name: '0.20mm Standard @BBL A1' }, 'process', X1C, index),
|
||||
).toBe('unknown');
|
||||
});
|
||||
|
||||
it('is unknown when no bundles are imported at all', () => {
|
||||
expect(
|
||||
presetCompatibility(
|
||||
{ name: 'P', compatible_printers: ['Bambu Lab X1 Carbon 0.4 nozzle'] },
|
||||
null,
|
||||
null,
|
||||
{ name: '0.20mm Standard @BBL X1C' },
|
||||
'process',
|
||||
X1C,
|
||||
EMPTY_COMPATIBILITY_INDEX,
|
||||
),
|
||||
).toBe('unknown');
|
||||
});
|
||||
|
||||
it('is unknown when no printer is selected', () => {
|
||||
expect(
|
||||
presetCompatibility({ name: '0.20mm Standard @BBL X1C' }, 'process', null, index),
|
||||
).toBe('unknown');
|
||||
});
|
||||
|
||||
it("matches across the '# ' user-clone prefix", () => {
|
||||
const index2 = buildCompatibilityIndex([
|
||||
{ printer_preset_name: X1C, process: ['# 0.20mm Custom'], filament: [] },
|
||||
]);
|
||||
expect(presetCompatibility({ name: '0.20mm Custom' }, 'process', X1C, index2)).toBe('match');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1325,7 +1325,7 @@ export interface UnifiedPreset {
|
||||
// compatible with. Populated for the local tier (the slicer's own
|
||||
// `compatible_printers`); null for cloud / standard. The SliceModal filters
|
||||
// the process / filament dropdowns by the selected printer using this when
|
||||
// present, falling back to a name-suffix heuristic otherwise (#1325).
|
||||
// present, and otherwise by the user's uploaded Slicer Bundles (#1325).
|
||||
compatible_printers?: string[] | null;
|
||||
}
|
||||
export interface UnifiedPresetsBySlot {
|
||||
|
||||
@@ -20,7 +20,12 @@ import { useToast } from '../contexts/ToastContext';
|
||||
import { PlatePickerModal } from './PlatePickerModal';
|
||||
import type { PlateFilament } from '../types/plates';
|
||||
import { normalizeColorForCompare, colorsAreSimilar } from '../utils/amsHelpers';
|
||||
import { presetCompatibility, printerPresetCode } from '../utils/slicerPrinterMatch';
|
||||
import {
|
||||
presetCompatibility,
|
||||
buildCompatibilityIndex,
|
||||
EMPTY_COMPATIBILITY_INDEX,
|
||||
type PrinterCompatibilityIndex,
|
||||
} from '../utils/slicerPrinterMatch';
|
||||
|
||||
export type SliceSource =
|
||||
| { kind: 'libraryFile'; id: number; filename: string }
|
||||
@@ -87,20 +92,20 @@ function findPresetByName(
|
||||
function pickProcessDefault(
|
||||
by: UnifiedPresetsResponse,
|
||||
printerName: string | null,
|
||||
printerCode: string | null,
|
||||
compatIndex: PrinterCompatibilityIndex,
|
||||
preferredName?: string | null,
|
||||
): PresetRef | null {
|
||||
const preferred = findPresetByName(by, 'process', preferredName);
|
||||
if (preferred) {
|
||||
const p = findPreset(by, preferred, 'process');
|
||||
if (p && presetCompatibility(p, printerName, printerCode) !== 'mismatch') {
|
||||
if (p && presetCompatibility(p, 'process', printerName, compatIndex) !== 'mismatch') {
|
||||
return preferred;
|
||||
}
|
||||
}
|
||||
for (const wanted of ['match', 'unknown'] as const) {
|
||||
for (const tier of SLICE_MODAL_TIER_ORDER) {
|
||||
for (const p of by[tier].process) {
|
||||
if (presetCompatibility(p, printerName, printerCode) === wanted) {
|
||||
if (presetCompatibility(p, 'process', printerName, compatIndex) === wanted) {
|
||||
return { source: p.source, id: p.id };
|
||||
}
|
||||
}
|
||||
@@ -119,7 +124,7 @@ function pickFilamentForSlot(
|
||||
by: UnifiedPresetsResponse,
|
||||
required: { type: string; color: string },
|
||||
printerName: string | null,
|
||||
printerCode: string | null,
|
||||
compatIndex: PrinterCompatibilityIndex,
|
||||
): PresetRef | null {
|
||||
// Score every filament preset against the plate slot's required (type,
|
||||
// colour) and pick the highest. Mirrors the AMS slot-mapping match in the
|
||||
@@ -144,7 +149,7 @@ function pickFilamentForSlot(
|
||||
// Demote printer-incompatible filaments (#1325): a penalty rather than a
|
||||
// hard skip so the pick still degrades gracefully if every filament
|
||||
// mismatches the selected printer.
|
||||
if (presetCompatibility(p, printerName, printerCode) === 'mismatch') {
|
||||
if (presetCompatibility(p, 'filament', printerName, compatIndex) === 'mismatch') {
|
||||
score -= 100;
|
||||
}
|
||||
if (best == null || score > best.score) {
|
||||
@@ -429,9 +434,12 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
if (!presetsQuery.data || !printerPreset) return null;
|
||||
return findPreset(presetsQuery.data, printerPreset, 'printer')?.name ?? null;
|
||||
}, [presetsQuery.data, printerPreset]);
|
||||
const selectedPrinterCode = useMemo<string | null>(
|
||||
() => (selectedPrinterName ? printerPresetCode(selectedPrinterName) : null),
|
||||
[selectedPrinterName],
|
||||
// Compatibility ground truth, derived from the user's uploaded Slicer
|
||||
// Bundles (#1325) — empty until at least one bundle is imported, in which
|
||||
// case no process / filament gets filtered (nothing to filter against).
|
||||
const compatIndex = useMemo<PrinterCompatibilityIndex>(
|
||||
() => buildCompatibilityIndex(bundlesQuery.data ?? []),
|
||||
[bundlesQuery.data],
|
||||
);
|
||||
|
||||
// Printer / process preset names the source 3MF was prepared with. The
|
||||
@@ -463,13 +471,13 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
setProcessPreset((current) => {
|
||||
if (current) {
|
||||
const p = findPreset(data, current, 'process');
|
||||
if (p && presetCompatibility(p, selectedPrinterName, selectedPrinterCode) !== 'mismatch') {
|
||||
if (p && presetCompatibility(p, 'process', selectedPrinterName, compatIndex) !== 'mismatch') {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
return pickProcessDefault(data, selectedPrinterName, selectedPrinterCode, embeddedProcess);
|
||||
return pickProcessDefault(data, selectedPrinterName, compatIndex, embeddedProcess);
|
||||
});
|
||||
}, [presetsQuery.data, selectedPrinterName, selectedPrinterCode, embeddedProcess]);
|
||||
}, [presetsQuery.data, selectedPrinterName, compatIndex, embeddedProcess]);
|
||||
|
||||
// Filament pre-pick: re-runs when the active filament-slot count changes
|
||||
// (plate selection, single-plate metadata arriving) or the selected printer
|
||||
@@ -485,7 +493,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
const cur = current[i] ?? null;
|
||||
if (cur) {
|
||||
const p = findPreset(data, cur, 'filament');
|
||||
if (p && presetCompatibility(p, selectedPrinterName, selectedPrinterCode) !== 'mismatch') {
|
||||
if (p && presetCompatibility(p, 'filament', selectedPrinterName, compatIndex) !== 'mismatch') {
|
||||
return cur;
|
||||
}
|
||||
}
|
||||
@@ -493,11 +501,11 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
data,
|
||||
{ type: slot.type, color: slot.color },
|
||||
selectedPrinterName,
|
||||
selectedPrinterCode,
|
||||
compatIndex,
|
||||
);
|
||||
});
|
||||
});
|
||||
}, [presetsQuery.data, filamentSlots, selectedPrinterName, selectedPrinterCode]);
|
||||
}, [presetsQuery.data, filamentSlots, selectedPrinterName, compatIndex]);
|
||||
|
||||
// Bundle-mode auto-pick: when the user picks a bundle (or the slot count
|
||||
// changes after the picker is open), default the process to the bundle's
|
||||
@@ -719,7 +727,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
onChange={setProcessPreset}
|
||||
disabled={isEnqueuing}
|
||||
selectedPrinterName={selectedPrinterName}
|
||||
selectedPrinterCode={selectedPrinterCode}
|
||||
compatIndex={compatIndex}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -837,7 +845,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
disabled={isEnqueuing || !isUsed}
|
||||
swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
|
||||
selectedPrinterName={selectedPrinterName}
|
||||
selectedPrinterCode={selectedPrinterCode}
|
||||
compatIndex={compatIndex}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -984,10 +992,11 @@ 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 move into a trailing
|
||||
// "Other printers" group instead of the main tier list.
|
||||
// slot, presets that resolve to a different printer (per the uploaded
|
||||
// Slicer Bundles in compatIndex) move into a trailing "Other printers"
|
||||
// group instead of the main tier list.
|
||||
selectedPrinterName?: string | null;
|
||||
selectedPrinterCode?: string | null;
|
||||
compatIndex?: PrinterCompatibilityIndex;
|
||||
}
|
||||
|
||||
function PresetDropdown({
|
||||
@@ -999,7 +1008,7 @@ function PresetDropdown({
|
||||
disabled,
|
||||
swatchColor,
|
||||
selectedPrinterName,
|
||||
selectedPrinterCode,
|
||||
compatIndex,
|
||||
}: PresetDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1026,8 +1035,13 @@ function PresetDropdown({
|
||||
const compatible: UnifiedPreset[] = [];
|
||||
for (const p of entries) {
|
||||
if (
|
||||
presetCompatibility(p, selectedPrinterName ?? null, selectedPrinterCode ?? null) ===
|
||||
'mismatch'
|
||||
presetCompatibility(
|
||||
p,
|
||||
// filterByPrinter is true here, so slot is never 'printer'.
|
||||
slot as 'process' | 'filament',
|
||||
selectedPrinterName ?? null,
|
||||
compatIndex ?? EMPTY_COMPATIBILITY_INDEX,
|
||||
) === 'mismatch'
|
||||
) {
|
||||
other.push(p);
|
||||
} else {
|
||||
@@ -1039,7 +1053,7 @@ function PresetDropdown({
|
||||
}
|
||||
}
|
||||
return { sections: compatSections, otherEntries: other };
|
||||
}, [data, slot, t, selectedPrinterName, selectedPrinterCode]);
|
||||
}, [data, slot, t, selectedPrinterName, compatIndex]);
|
||||
|
||||
const totalEntries =
|
||||
sections.reduce((sum, s) => sum + s.entries.length, 0) + otherEntries.length;
|
||||
|
||||
@@ -1,101 +1,100 @@
|
||||
// Printer-compatibility matching for the SliceModal's process / filament
|
||||
// dropdowns (#1325). Bambuddy can't filter every preset tier the same way:
|
||||
// dropdowns (#1325).
|
||||
//
|
||||
// - local presets carry the slicer's own `compatible_printers` list (a
|
||||
// precise list of printer-preset names) — used directly when present.
|
||||
// - cloud / standard presets carry no compatibility data, so we fall back
|
||||
// to the "@BBL <model>" suffix Bambu Studio / OrcaSlicer embed in preset
|
||||
// names (e.g. "0.20mm Standard @BBL X1C").
|
||||
// Compatibility is read from ground truth, never guessed from preset names:
|
||||
//
|
||||
// Either way the result drives grouping, not hard hiding: a preset whose
|
||||
// compatibility can't be determined stays in the main list, and only a
|
||||
// preset that resolves to a *different* printer is pushed into an
|
||||
// "Other printers" group.
|
||||
|
||||
// Canonical Bambu Lab model codes exactly as they appear in preset-name
|
||||
// suffixes. Order is irrelevant here — this is a membership set.
|
||||
const KNOWN_MODEL_CODES = new Set([
|
||||
'X1C',
|
||||
'X1E',
|
||||
'X1',
|
||||
'P1S',
|
||||
'P1P',
|
||||
'A1M',
|
||||
'A1',
|
||||
'H2D',
|
||||
'H2S',
|
||||
]);
|
||||
|
||||
// Ordered printer-preset-name patterns → model code. First match wins, so
|
||||
// more specific names ("X1 Carbon", "A1 mini") must precede their prefixes
|
||||
// ("X1", "A1").
|
||||
const PRINTER_NAME_PATTERNS: { re: RegExp; code: string }[] = [
|
||||
{ re: /x1\s*-?\s*carbon/i, code: 'X1C' },
|
||||
{ re: /\bx1c\b/i, code: 'X1C' },
|
||||
{ re: /\bx1e\b/i, code: 'X1E' },
|
||||
{ re: /\bx1\b/i, code: 'X1' },
|
||||
{ re: /\bp1s\b/i, code: 'P1S' },
|
||||
{ re: /\bp1p\b/i, code: 'P1P' },
|
||||
{ re: /a1\s*mini/i, code: 'A1M' },
|
||||
{ re: /\ba1m\b/i, code: 'A1M' },
|
||||
{ re: /\ba1\b/i, code: 'A1' },
|
||||
{ re: /\bh2d\b/i, code: 'H2D' },
|
||||
{ re: /\bh2s\b/i, code: 'H2S' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Derive a Bambu model code from a printer-preset name
|
||||
* (e.g. "Bambu Lab X1 Carbon 0.4 nozzle" → "X1C"). Returns null when the
|
||||
* name matches no known model — a custom / third-party printer, for which
|
||||
* we can't filter and so show every preset.
|
||||
*/
|
||||
export function printerPresetCode(name: string): string | null {
|
||||
for (const { re, code } of PRINTER_NAME_PATTERNS) {
|
||||
if (re.test(name)) return code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the "@BBL <model>..." suffix of a process / filament preset name into
|
||||
* the set of model codes it targets. A preset can list several
|
||||
* ("... @BBL X1C X1"); a generic preset ("Generic PLA @base") yields an
|
||||
* empty set, meaning "applies everywhere / can't tell".
|
||||
*/
|
||||
export function presetModelCodes(name: string): Set<string> {
|
||||
const at = name.lastIndexOf('@');
|
||||
if (at < 0) return new Set();
|
||||
const tokens = name
|
||||
.slice(at + 1)
|
||||
.toUpperCase()
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean);
|
||||
return new Set(tokens.filter((tok) => KNOWN_MODEL_CODES.has(tok)));
|
||||
}
|
||||
// - imported (local-tier) presets carry the slicer's own
|
||||
// `compatible_printers` list — an exact list of printer-preset names.
|
||||
// - every other preset is matched through the user's uploaded Slicer
|
||||
// Bundles (.bbscfg). A bundle is scoped to one printer and lists the
|
||||
// process / filament presets shipped with it, so "process P works with
|
||||
// printer X" holds exactly when some uploaded bundle for printer X
|
||||
// contains P. No model codes, no name parsing — a newly released Bambu
|
||||
// model is covered the moment its bundle is uploaded.
|
||||
//
|
||||
// The result drives grouping, not hard hiding: a preset no bundle covers
|
||||
// stays in the main list, and only a preset that resolves to a *different*
|
||||
// printer is pushed into an "Other printers" group.
|
||||
|
||||
export type PrinterCompatibility = 'match' | 'mismatch' | 'unknown';
|
||||
|
||||
// Minimal shape of a Slicer Bundle needed for matching (see SlicerBundle in
|
||||
// api/client.ts). `printer_preset_name` scopes the bundle to one printer;
|
||||
// `process` / `filament` are the preset names that bundle ships.
|
||||
export interface CompatibilityBundle {
|
||||
printer_preset_name: string;
|
||||
process: string[];
|
||||
filament: string[];
|
||||
}
|
||||
|
||||
// A preset-name → set-of-compatible-printer-names index, one map per slot,
|
||||
// built from every uploaded bundle. Empty when no bundles are imported.
|
||||
export interface PrinterCompatibilityIndex {
|
||||
process: Map<string, Set<string>>;
|
||||
filament: Map<string, Set<string>>;
|
||||
}
|
||||
|
||||
/** An empty index — used when no bundles are imported / available yet. */
|
||||
export const EMPTY_COMPATIBILITY_INDEX: PrinterCompatibilityIndex = {
|
||||
process: new Map(),
|
||||
filament: new Map(),
|
||||
};
|
||||
|
||||
// Bundle preset names occasionally carry BambuStudio's "# " user-clone
|
||||
// prefix; strip it so a bundle entry and a tier-listed preset compare equal.
|
||||
function normalizePresetName(name: string): string {
|
||||
return name.replace(/^#\s*/, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the compatibility index from the user's uploaded Slicer Bundles.
|
||||
* Each bundle contributes its printer to every process / filament name it
|
||||
* ships; a name shipped by several bundles accumulates every printer.
|
||||
*/
|
||||
export function buildCompatibilityIndex(
|
||||
bundles: readonly CompatibilityBundle[],
|
||||
): PrinterCompatibilityIndex {
|
||||
const process = new Map<string, Set<string>>();
|
||||
const filament = new Map<string, Set<string>>();
|
||||
const add = (map: Map<string, Set<string>>, name: string, printer: string) => {
|
||||
const key = normalizePresetName(name);
|
||||
if (!key) return;
|
||||
const set = map.get(key) ?? new Set<string>();
|
||||
set.add(printer);
|
||||
map.set(key, set);
|
||||
};
|
||||
for (const bundle of bundles) {
|
||||
const printer = bundle.printer_preset_name?.trim();
|
||||
if (!printer) continue;
|
||||
for (const name of bundle.process) add(process, name, printer);
|
||||
for (const name of bundle.filament) add(filament, name, printer);
|
||||
}
|
||||
return { process, filament };
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a process / filament preset against the selected printer.
|
||||
*
|
||||
* - 'match' — the preset is compatible with the selected printer.
|
||||
* - 'mismatch' — the preset resolves to a *different* printer.
|
||||
* - 'unknown' — compatibility can't be determined (no data, generic preset,
|
||||
* or an unrecognised printer); the caller should not hide it.
|
||||
* - 'unknown' — compatibility can't be determined (no `compatible_printers`
|
||||
* and no uploaded bundle covers the preset, or no printer is
|
||||
* selected); the caller must not hide it.
|
||||
*/
|
||||
export function presetCompatibility(
|
||||
preset: { name: string; compatible_printers?: string[] | null },
|
||||
slot: 'process' | 'filament',
|
||||
selectedPrinterName: string | null,
|
||||
selectedPrinterCode: string | null,
|
||||
index: PrinterCompatibilityIndex,
|
||||
): PrinterCompatibility {
|
||||
// Precise link first: the slicer's own compatible_printers list (local tier).
|
||||
// Imported presets carry the slicer's own compatible_printers list.
|
||||
const compat = preset.compatible_printers;
|
||||
if (compat && compat.length > 0) {
|
||||
if (!selectedPrinterName) return 'unknown';
|
||||
return compat.includes(selectedPrinterName) ? 'match' : 'mismatch';
|
||||
}
|
||||
// Heuristic fallback: the "@BBL <model>" suffix in the preset name.
|
||||
const codes = presetModelCodes(preset.name);
|
||||
if (codes.size === 0 || !selectedPrinterCode) return 'unknown';
|
||||
return codes.has(selectedPrinterCode) ? 'match' : 'mismatch';
|
||||
// Otherwise consult the uploaded bundles.
|
||||
const printers = index[slot].get(normalizePresetName(preset.name));
|
||||
if (!printers || printers.size === 0 || !selectedPrinterName) return 'unknown';
|
||||
return printers.has(selectedPrinterName) ? 'match' : 'mismatch';
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-DQCawz3_.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-B2ysIPjO.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CBuiHfeb.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user