mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
feat(slicer): multi-color slicing + per-plate filament discovery
The slice modal previously rendered exactly one filament dropdown and
silently truncated multi-color 3MFs to a single profile, producing wrong
colours on every multi-filament print. End-to-end fix across sidecar,
backend, and frontend.
Sidecar (orca-slicer-api / bambuddy/profile-resolver, separate commit):
- /slice accepts up to 16 repeated filamentProfile parts; slicing
service materializes each and joins paths with `;` for
--load-filaments.
- /profiles/bundled emits filament_type and filament_colour per leaf
so the bundled tier carries metadata into the modal.
Bambuddy backend:
- SliceRequest gains filament_presets: list[PresetRef]. Validator
accepts three shapes (multi-color array, source-aware singular,
legacy bare-int id) and lands them all on a populated array before
the route handler runs — fully backwards-compatible.
- SlicerApiService.slice_with_profiles takes filament_profile_jsons:
list[str] and sends one filamentProfile multipart part per profile
(in submission order) so the sidecar receives N profiles cleanly.
- New service slice_preview runs the sidecar's slice_without_profiles
against an unsliced project file's embedded settings, parses the
result's slice_info.config, and returns the canonical per-plate
filament list. Cached by (kind, source_id, plate_id, content_hash)
with LRU eviction at 256 entries, per-key asyncio.Lock prevents
thundering-herd; transient sidecar failures are NOT cached so they
retry naturally; parse failures ARE cached (deterministic property
of the input, no point re-running).
- /filament-requirements endpoint chain: slice_info.config (existing,
sliced files) → preview-slice (new, unsliced project files) →
project_settings.config + painted-face heuristic with 5% noise
threshold (sidecar-down fallback).
- threemf_tools gains extract_project_filaments_from_3mf and
extract_plate_extruder_set_from_3mf — the latter unions object
top-level extruder, per-part overrides, and painted-face quadtree
leaves (1-E nibbles in paint_color attrs of <triangle> elements
inside per-object .model files).
- Cloud preset listing no longer fetches per-preset detail (Bambu's
rate limit at ~10/sec returns 429 on every request for users with
50+ presets). Unified-listing dedup pass instead backfills metadata
cross-tier so a cloud entry that wins dedup over a same-named local
entry inherits the local's filament_type / filament_colour.
Frontend:
- SliceModal multi-step: plate-picker first when the source is a
multi-plate 3MF, then preset dropdowns. One filament dropdown per
AMS slot the plate actually uses, each pre-picked by metadata
match against user's local + standard presets via existing
colorsAreSimilar / normalizeColorForCompare utils.
- SliceModal-only tier priority is now local → cloud → standard
(was cloud → local → standard). Other consumers of /slicer/presets
keep the existing cloud-first order.
- Submits filament_presets array; backfills the legacy singular
filament_preset from the array's first entry for stale-tab
compatibility.
- i18n keys added across all 8 locales: slice.filamentSlot,
slice.tier.{local,cloud,standard}, slice.cloud.{notAuthenticated,
expired,unreachable}, slice.noPresetsForSlot,
slice.allPresetsRequired (en + de fully translated; six others
seeded with English copies pending native translation, matching
the project's existing flow).
Permissions: no new endpoint paths added. Preview-slice runs inside
/filament-requirements (LIBRARY_READ / ARCHIVES_READ) and multi-filament
dispatch runs inside POST /slice (LIBRARY_UPLOAD). No auth surface
widened.
Tests: 6 SliceRequest schema tests for multi-filament + legacy-new
precedence; 9 unit tests for slice_preview cache behaviour (LRU
eviction with lock cleanup, content-hash invalidation, concurrent
thundering-herd guard, no-cache-poison on transient sidecar failure);
15 unit tests for the two new threemf_tools helpers (5 + 10 cases
including the 60/40 painted-threshold regression pin); a multi-filament
wire-format test pinning the multipart part count + order; 22 frontend
SliceModal tests covering plate picker, multi-color render,
metadata-aware pre-pick, manual override, and the new tier order.
This commit is contained in:
@@ -12,6 +12,9 @@ import {
|
||||
type UnifiedPresetsResponse,
|
||||
} from '../api/client';
|
||||
import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
|
||||
import { PlatePickerModal } from './PlatePickerModal';
|
||||
import type { PlateFilament } from '../types/plates';
|
||||
import { normalizeColorForCompare, colorsAreSimilar } from '../utils/amsHelpers';
|
||||
|
||||
export type SliceSource =
|
||||
| { kind: 'libraryFile'; id: number; filename: string }
|
||||
@@ -24,10 +27,16 @@ interface SliceModalProps {
|
||||
|
||||
type Slot = 'printer' | 'process' | 'filament';
|
||||
|
||||
// SliceModal-specific tier priority: local (imported) → cloud → standard.
|
||||
// Imported profiles are surfaced first because they're the user's curated
|
||||
// picks (often colour/type-tagged), cloud is second since names alone can't
|
||||
// drive metadata-aware match, standard is the bundled fallback. This is
|
||||
// distinct from the listing endpoint's dedup order and only affects what
|
||||
// the SliceModal renders / pre-picks.
|
||||
const SLICE_MODAL_TIER_ORDER = ['local', 'cloud', 'standard'] as const;
|
||||
|
||||
function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
|
||||
// Cloud > local > standard. The endpoint already deduplicates by name, so
|
||||
// no name-collision handling needed here — first non-empty tier wins.
|
||||
for (const tier of ['cloud', 'local', 'standard'] as const) {
|
||||
for (const tier of SLICE_MODAL_TIER_ORDER) {
|
||||
const list = by[tier][slot];
|
||||
if (list.length > 0) {
|
||||
return { source: list[0].source, id: list[0].id };
|
||||
@@ -36,6 +45,48 @@ function pickDefault(by: UnifiedPresetsResponse, slot: Slot): PresetRef | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const TIER_BONUS: Record<PresetSource, number> = {
|
||||
local: 1.5,
|
||||
cloud: 1.0,
|
||||
standard: 0.5,
|
||||
};
|
||||
|
||||
function pickFilamentForSlot(
|
||||
by: UnifiedPresetsResponse,
|
||||
required: { type: string; color: string },
|
||||
): 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
|
||||
// print/schedule modal: type match dominates, exact-colour-match bumps over
|
||||
// similar-colour-match, and a small per-tier bonus breaks ties so cloud
|
||||
// user customisations win over standard bundled fallbacks of equal merit.
|
||||
const reqType = required.type.trim().toUpperCase();
|
||||
const reqColor = normalizeColorForCompare(required.color);
|
||||
|
||||
let best: { ref: PresetRef; score: number } | null = null;
|
||||
for (const tier of SLICE_MODAL_TIER_ORDER) {
|
||||
for (const p of by[tier].filament) {
|
||||
let score = 0;
|
||||
const presetType = (p.filament_type ?? '').trim().toUpperCase();
|
||||
const presetColor = normalizeColorForCompare(p.filament_colour ?? '');
|
||||
if (reqType && presetType && reqType === presetType) score += 10;
|
||||
if (reqColor && presetColor) {
|
||||
if (presetColor === reqColor) score += 5;
|
||||
else if (colorsAreSimilar(p.filament_colour ?? '', required.color)) score += 2;
|
||||
}
|
||||
score += TIER_BONUS[tier];
|
||||
if (best == null || score > best.score) {
|
||||
best = { ref: { source: p.source, id: p.id }, score };
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall back to plain priority pick if every preset scored 0+tier (i.e. no
|
||||
// metadata matched). The fallback is exactly the single-color default —
|
||||
// first preset in the highest-priority non-empty tier.
|
||||
if (best == null) return pickDefault(by, 'filament');
|
||||
return best.ref;
|
||||
}
|
||||
|
||||
function toRefValue(ref: PresetRef | null): string {
|
||||
// The HTML `<select>` value space is flat strings; encode source + id so
|
||||
// the same preset name can live in multiple tiers without collision.
|
||||
@@ -58,36 +109,122 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
|
||||
const [printerPreset, setPrinterPreset] = useState<PresetRef | null>(null);
|
||||
const [processPreset, setProcessPreset] = useState<PresetRef | null>(null);
|
||||
const [filamentPreset, setFilamentPreset] = useState<PresetRef | null>(null);
|
||||
// One filament ref per plate slot, in plate order. For STL / single-plate /
|
||||
// single-color sources this is a one-element array; multi-color 3MFs get one
|
||||
// entry per AMS slot the plate uses. Pre-pick (effect below) initialises
|
||||
// each slot from the source plate's required (type, colour).
|
||||
const [filamentPresets, setFilamentPresets] = useState<(PresetRef | null)[]>([]);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
// null = plate not yet picked (or single-plate / non-3MF — picker is skipped
|
||||
// and we'll backfill 1 at submit time). Set to a 1-indexed plate number once
|
||||
// the user picks one (or implicitly for single-plate sources).
|
||||
const [selectedPlate, setSelectedPlate] = useState<number | null>(null);
|
||||
|
||||
const platesQuery = useQuery({
|
||||
queryKey: ['slicePlates', source.kind, source.id],
|
||||
queryFn: async () => {
|
||||
if (source.kind === 'libraryFile') {
|
||||
return api.getLibraryFilePlates(source.id);
|
||||
}
|
||||
return api.getArchivePlates(source.id);
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const isMultiPlate =
|
||||
!!platesQuery.data?.is_multi_plate && (platesQuery.data?.plates?.length ?? 0) > 1;
|
||||
// Single-plate / non-3MF / fetch failure: skip the picker, default to plate 1
|
||||
// at submit time so the backend's existing default behaviour is preserved.
|
||||
const needsPlatePicker = isMultiPlate && selectedPlate == null;
|
||||
|
||||
// Per-plate filament requirements via the same endpoint the print/schedule
|
||||
// modal uses. Reusing it here keeps the SliceModal honest with whatever
|
||||
// logic that endpoint applies (slice_info parsing, future enhancements for
|
||||
// unsliced project files, dual-nozzle fields, etc.) instead of duplicating
|
||||
// extraction. plate_id is always sent: single-plate falls through to plate
|
||||
// 1 server-side; multi-plate uses the user's pick.
|
||||
const effectivePlateId = selectedPlate ?? 1;
|
||||
const filamentReqsQuery = useQuery({
|
||||
queryKey: ['sliceFilamentReqs', source.kind, source.id, effectivePlateId],
|
||||
queryFn: async () => {
|
||||
if (source.kind === 'libraryFile') {
|
||||
return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId);
|
||||
}
|
||||
return api.getArchiveFilamentRequirements(source.id, effectivePlateId);
|
||||
},
|
||||
enabled: !needsPlatePicker,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// Filament slot list for the active plate. Falls back to one synthetic slot
|
||||
// for STL/STEP and any "no metadata available" case so the modal still
|
||||
// works (single dropdown, mono-color slice).
|
||||
const filamentSlots = useMemo<PlateFilament[]>(() => {
|
||||
const reqs = filamentReqsQuery.data?.filaments ?? [];
|
||||
if (reqs.length > 0) return reqs as PlateFilament[];
|
||||
return [
|
||||
{ slot_id: 1, type: '', color: '', used_grams: 0, used_meters: 0 },
|
||||
];
|
||||
}, [filamentReqsQuery.data]);
|
||||
|
||||
const presetsQuery = useQuery({
|
||||
queryKey: ['slicerPresets'],
|
||||
queryFn: () => api.getSlicerPresets(),
|
||||
staleTime: 60_000,
|
||||
// Don't fetch presets while the plate picker is on screen — saves a
|
||||
// round-trip if the user cancels out of the plate step.
|
||||
enabled: !platesQuery.isLoading && !needsPlatePicker,
|
||||
});
|
||||
|
||||
// Default selection: cloud > local > standard. Runs only on the first
|
||||
// successful load; subsequent re-renders preserve the user's manual choice.
|
||||
// Printer / process pre-pick: see SLICE_MODAL_TIER_ORDER. Runs once when
|
||||
// presets first arrive; subsequent re-renders preserve any manual choice.
|
||||
useEffect(() => {
|
||||
if (!presetsQuery.data) return;
|
||||
if (printerPreset == null) setPrinterPreset(pickDefault(presetsQuery.data, 'printer'));
|
||||
if (processPreset == null) setProcessPreset(pickDefault(presetsQuery.data, 'process'));
|
||||
if (filamentPreset == null) setFilamentPreset(pickDefault(presetsQuery.data, 'filament'));
|
||||
// Intentionally exclude state-setters and current selections from deps —
|
||||
// we only want the auto-pick to fire once when data first arrives.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [presetsQuery.data]);
|
||||
|
||||
// Filament pre-pick: re-runs whenever the active filament-slot count
|
||||
// changes (plate selection, single-plate metadata arriving). For each slot
|
||||
// we score every available filament preset against the slot's required
|
||||
// (type, colour) and keep the highest match. Slot count mismatch → reset
|
||||
// and re-pick everything; same length → preserve any user override.
|
||||
useEffect(() => {
|
||||
if (!presetsQuery.data) return;
|
||||
const data = presetsQuery.data;
|
||||
setFilamentPresets((current) => {
|
||||
if (current.length === filamentSlots.length && current.every((r) => r != null)) {
|
||||
return current;
|
||||
}
|
||||
return filamentSlots.map((slot) =>
|
||||
pickFilamentForSlot(data, { type: slot.type, color: slot.color }),
|
||||
);
|
||||
});
|
||||
}, [presetsQuery.data, filamentSlots]);
|
||||
|
||||
const enqueueMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!printerPreset || !processPreset || !filamentPreset) {
|
||||
throw new Error('All three presets must be selected');
|
||||
if (
|
||||
!printerPreset ||
|
||||
!processPreset ||
|
||||
filamentPresets.length === 0 ||
|
||||
filamentPresets.some((r) => r == null)
|
||||
) {
|
||||
throw new Error(t('slice.allPresetsRequired', 'All presets must be selected'));
|
||||
}
|
||||
const body = {
|
||||
printer_preset: printerPreset,
|
||||
process_preset: processPreset,
|
||||
filament_preset: filamentPreset,
|
||||
// The first slot also goes into the legacy singular field so the
|
||||
// backend's older callers / clients keep behaving the same — the
|
||||
// backend validator prefers `filament_presets` when both are set.
|
||||
filament_preset: filamentPresets[0] as PresetRef,
|
||||
filament_presets: filamentPresets as PresetRef[],
|
||||
// Always send a concrete plate number when the source is multi-plate;
|
||||
// omit otherwise so the backend default applies for STL / single-plate
|
||||
// 3MF sources where the concept doesn't apply.
|
||||
...(selectedPlate != null ? { plate: selectedPlate } : {}),
|
||||
};
|
||||
if (source.kind === 'libraryFile') {
|
||||
return api.sliceLibraryFile(source.id, body);
|
||||
@@ -104,9 +241,29 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const isReady = printerPreset != null && processPreset != null && filamentPreset != null;
|
||||
const isReady =
|
||||
printerPreset != null &&
|
||||
processPreset != null &&
|
||||
filamentPresets.length > 0 &&
|
||||
filamentPresets.every((r) => r != null);
|
||||
const isEnqueuing = enqueueMutation.isPending;
|
||||
|
||||
// Step 1: plate picker for multi-plate 3MF sources. Cancelling closes the
|
||||
// entire flow (matches the existing PlatePickerModal contract used by the
|
||||
// archive g-code-viewer entry point).
|
||||
if (needsPlatePicker && platesQuery.data) {
|
||||
return (
|
||||
<PlatePickerModal
|
||||
plates={platesQuery.data.plates}
|
||||
onSelect={(plateIndex) => setSelectedPlate(plateIndex)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2 (or only step for single-plate / non-3MF / load-failure): preset
|
||||
// picker. While the plates query is in-flight we still render the shell
|
||||
// because the presets query is gated on it; the loader covers both.
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
@@ -127,6 +284,9 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
</h3>
|
||||
<p className="text-xs text-bambu-gray mt-1 truncate" title={source.filename}>
|
||||
{source.filename}
|
||||
{selectedPlate != null
|
||||
? ` • ${t('archives.platePicker.plateLabel', { index: selectedPlate })}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -141,7 +301,7 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{presetsQuery.isLoading && (
|
||||
{(platesQuery.isLoading || presetsQuery.isLoading || filamentReqsQuery.isLoading) && (
|
||||
<div className="flex items-center gap-2 text-bambu-gray text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('slice.loadingPresets', 'Loading presets…')}
|
||||
@@ -176,14 +336,34 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
|
||||
onChange={setProcessPreset}
|
||||
disabled={isEnqueuing}
|
||||
/>
|
||||
<PresetDropdown
|
||||
label={t('slice.filament', 'Filament profile')}
|
||||
slot="filament"
|
||||
data={presetsQuery.data}
|
||||
value={filamentPreset}
|
||||
onChange={setFilamentPreset}
|
||||
disabled={isEnqueuing}
|
||||
/>
|
||||
{filamentSlots.map((slot, idx) => (
|
||||
<PresetDropdown
|
||||
key={`filament-${idx}`}
|
||||
label={
|
||||
filamentSlots.length > 1
|
||||
? t('slice.filamentSlot', {
|
||||
index: idx + 1,
|
||||
type: slot.type,
|
||||
defaultValue: `Filament ${idx + 1} (${slot.type || ''})`,
|
||||
})
|
||||
: t('slice.filament', 'Filament profile')
|
||||
}
|
||||
slot="filament"
|
||||
data={presetsQuery.data}
|
||||
value={filamentPresets[idx] ?? null}
|
||||
onChange={(ref) =>
|
||||
setFilamentPresets((current) => {
|
||||
const next = current.length === filamentSlots.length
|
||||
? [...current]
|
||||
: filamentSlots.map((_, i) => current[i] ?? null);
|
||||
next[idx] = ref;
|
||||
return next;
|
||||
})
|
||||
}
|
||||
disabled={isEnqueuing}
|
||||
swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -271,15 +451,22 @@ interface PresetDropdownProps {
|
||||
value: PresetRef | null;
|
||||
onChange: (ref: PresetRef | null) => void;
|
||||
disabled?: boolean;
|
||||
// Optional colour swatch shown next to the label — used for multi-color
|
||||
// filament slots so the user can see at a glance which slot they're
|
||||
// configuring against the source 3MF's per-slot colour.
|
||||
swatchColor?: string;
|
||||
}
|
||||
|
||||
function PresetDropdown({ label, slot, data, value, onChange, disabled }: PresetDropdownProps) {
|
||||
function PresetDropdown({ label, slot, data, value, onChange, disabled, swatchColor }: PresetDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sections: { tierLabel: string; entries: UnifiedPreset[] }[] = useMemo(() => {
|
||||
// Order matches SLICE_MODAL_TIER_ORDER: imported first, then cloud, then
|
||||
// standard fallback. Sections with no entries collapse out so a user
|
||||
// without cloud / local presets only sees the tiers they actually have.
|
||||
const tiers: { key: keyof UnifiedPresetsResponse; tier: 'cloud' | 'local' | 'standard'; label: string; fallback: string }[] = [
|
||||
{ key: 'cloud', tier: 'cloud', label: 'slice.tier.cloud', fallback: 'Cloud' },
|
||||
{ key: 'local', tier: 'local', label: 'slice.tier.local', fallback: 'Imported' },
|
||||
{ key: 'cloud', tier: 'cloud', label: 'slice.tier.cloud', fallback: 'Cloud' },
|
||||
{ key: 'standard', tier: 'standard', label: 'slice.tier.standard', fallback: 'Standard' },
|
||||
];
|
||||
return tiers
|
||||
@@ -294,7 +481,16 @@ function PresetDropdown({ label, slot, data, value, onChange, disabled }: Preset
|
||||
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block text-xs text-bambu-gray mb-1">{label}</span>
|
||||
<span className="flex items-center gap-2 text-xs text-bambu-gray mb-1">
|
||||
{swatchColor && (
|
||||
<span
|
||||
className="inline-block w-3 h-3 rounded-full border border-bambu-dark-tertiary"
|
||||
style={{ backgroundColor: swatchColor || 'transparent' }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
<select
|
||||
value={toRefValue(value)}
|
||||
onChange={(e) => onChange(fromRefValue(e.target.value))}
|
||||
|
||||
Reference in New Issue
Block a user