mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 14:11:35 +02:00
Slicing an STL via the integrated slicer always defaulted to whatever
curr_bed_type lived in the chosen process preset (typically "Cool
Plate"), which the slicer CLI rejected for high-temp filaments with
"Plate 1: Cool Plate does not support filament 1". The user had no
way to switch plates without cloning the preset in BambuStudio.
The Slice modal now exposes a Build plate dropdown with the six
canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate
SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate,
Smooth PEI Plate) plus an "Auto (use process preset)" option that
preserves the previous behavior. Positioned between Process profile
and Filament rows so a long filament list never pushes it off the
modal's scrolled viewport, and always enabled regardless of whether
the user picked a Printer Preset Bundle.
A new bed_type field on SliceRequest flows through both dispatch
paths:
- Resolved-preset path: _patch_process_bed_type overwrites
curr_bed_type on the process JSON before forwarding to the sidecar.
Works end-to-end today, no sidecar change needed.
- Bundle dispatch path: slice_with_bundle adds a bedType form field
to the sidecar multipart. The sidecar (maziggy/orca-slicer-api
fork) needs a matching change to honor it as --curr_bed_type on
the CLI invocation; until then the field is silently ignored and
the slice runs with the bundle's default plate.
1071 lines
45 KiB
TypeScript
1071 lines
45 KiB
TypeScript
import { Cloud, CloudOff, Cog, Loader2, Package, X } from 'lucide-react';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
import {
|
|
api,
|
|
type PresetRef,
|
|
type PresetSource,
|
|
type SliceBundleSpec,
|
|
type SliceJobProgress,
|
|
type SliceRequest,
|
|
type SlicerBundle,
|
|
type SlicerCloudStatus,
|
|
type UnifiedPreset,
|
|
type UnifiedPresetsBySlot,
|
|
type UnifiedPresetsResponse,
|
|
} from '../api/client';
|
|
import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
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 }
|
|
| { kind: 'archive'; id: number; filename: string };
|
|
|
|
interface SliceModalProps {
|
|
source: SliceSource;
|
|
onClose: () => void;
|
|
}
|
|
|
|
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 {
|
|
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 };
|
|
}
|
|
}
|
|
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.
|
|
return ref ? `${ref.source}:${ref.id}` : '';
|
|
}
|
|
|
|
function fromRefValue(raw: string): PresetRef | null {
|
|
if (!raw) return null;
|
|
const idx = raw.indexOf(':');
|
|
if (idx < 0) return null;
|
|
const source = raw.slice(0, idx) as PresetSource;
|
|
const id = raw.slice(idx + 1);
|
|
if (source !== 'cloud' && source !== 'local' && source !== 'standard') return null;
|
|
return { source, id };
|
|
}
|
|
|
|
// Inline spinner for the filament-requirements query. The backend runs a
|
|
// preview slice on first open of an unsliced project file (cached after);
|
|
// on a complex multi-color model that's a real slice — multi-second to
|
|
// multi-minute. The static "Analyzing plate filaments…" string left
|
|
// users wondering whether anything was happening, so the spinner now
|
|
// shows elapsed seconds, polls the sidecar's --pipe progress (via the
|
|
// /slicer/preview-progress proxy) for live stage + percent, and after ~5s
|
|
// surfaces a "this is a one-time slice — repeat opens are instant"
|
|
// note so users don't worry it'll be slow forever.
|
|
//
|
|
// requestId: a UUID generated by the modal when the filament-requirements
|
|
// fetch starts. Forwarded to the sidecar via the API call AND used here
|
|
// to poll the matching progress snapshot. Same id, two consumers.
|
|
function FilamentAnalysisSpinner({
|
|
requestId,
|
|
sourceName,
|
|
}: {
|
|
requestId: string;
|
|
sourceName: string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { showPersistentToast, dismissToast } = useToast();
|
|
const [elapsed, setElapsed] = useState(0);
|
|
const [progress, setProgress] = useState<SliceJobProgress | null>(null);
|
|
// Defensive decode — see prettifyFilename comment in SliceJobTrackerContext.
|
|
let prettyName = sourceName;
|
|
try {
|
|
prettyName = decodeURIComponent(sourceName);
|
|
} catch {
|
|
/* keep raw on malformed encoding */
|
|
}
|
|
|
|
// Elapsed-time tick.
|
|
useEffect(() => {
|
|
const startedAt = Date.now();
|
|
const id = setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
|
|
// Progress polling — once per second while the spinner is mounted.
|
|
// Mirrors the slice-job tracker's cadence. Sidecar 404s during the
|
|
// race window between fetch start and progressStore.start() are
|
|
// swallowed by the API method (returns null) so we keep polling.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const id = setInterval(async () => {
|
|
if (cancelled) return;
|
|
const snap = await api.getPreviewSliceProgress(requestId);
|
|
if (!cancelled && snap) setProgress(snap);
|
|
}, 1000);
|
|
return () => {
|
|
cancelled = true;
|
|
clearInterval(id);
|
|
};
|
|
}, [requestId]);
|
|
|
|
// Mirror the spinner's contents into a persistent toast so the user
|
|
// sees activity even when their cursor is elsewhere on the page.
|
|
// Dismissed in the parent's effect when the requirements arrive.
|
|
const toastId = `slice-preview-${requestId}`;
|
|
useEffect(() => {
|
|
const hasUseful = progress && progress.stage && progress.total_percent > 0;
|
|
const elapsedStr = formatElapsed(elapsed);
|
|
if (hasUseful) {
|
|
showPersistentToast(
|
|
toastId,
|
|
t(
|
|
'slice.previewWithProgress',
|
|
'Analyzing {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
|
|
{
|
|
name: prettyName,
|
|
stage: progress!.stage,
|
|
percent: Math.min(100, Math.max(0, Math.round(progress!.total_percent))),
|
|
elapsed: elapsedStr,
|
|
},
|
|
),
|
|
'loading',
|
|
);
|
|
} else {
|
|
showPersistentToast(
|
|
toastId,
|
|
t('slice.previewToast', 'Analyzing {{name}} — {{elapsed}}', {
|
|
name: prettyName,
|
|
elapsed: elapsedStr,
|
|
}),
|
|
'loading',
|
|
);
|
|
}
|
|
return () => {
|
|
dismissToast(toastId);
|
|
};
|
|
}, [elapsed, progress, prettyName, showPersistentToast, dismissToast, t, toastId]);
|
|
|
|
const stage = progress?.stage;
|
|
const percent = progress?.total_percent;
|
|
const inlineLabel =
|
|
stage && typeof percent === 'number' && percent > 0
|
|
? `${stage} (${Math.min(100, Math.max(0, Math.round(percent)))}%)`
|
|
: t('slice.analyzingPlateFilaments', 'Analyzing plate filaments…');
|
|
return (
|
|
<div className="flex flex-col gap-1 text-bambu-gray text-sm py-2">
|
|
<div className="flex items-center gap-2">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
{inlineLabel}
|
|
<span className="text-xs tabular-nums">{elapsed}s</span>
|
|
</div>
|
|
{elapsed >= 5 && (
|
|
<div className="text-xs text-bambu-gray/70 pl-6">
|
|
{t(
|
|
'slice.analyzingPlateFilamentsHint',
|
|
'Running a preview slice to discover which AMS slots this plate uses. Cached after — re-opening is instant.',
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatElapsed(seconds: number): string {
|
|
const s = Math.max(0, Math.floor(seconds));
|
|
if (s < 60) return `${s}s`;
|
|
const m = Math.floor(s / 60);
|
|
const remS = s % 60;
|
|
if (m < 60) return `${m}m ${remS}s`;
|
|
const h = Math.floor(m / 60);
|
|
const remM = m % 60;
|
|
return `${h}h ${remM}m`;
|
|
}
|
|
|
|
export function SliceModal({ source, onClose }: SliceModalProps) {
|
|
const { t } = useTranslation();
|
|
const { trackJob } = useSliceJobTracker();
|
|
|
|
const [printerPreset, setPrinterPreset] = useState<PresetRef | null>(null);
|
|
const [processPreset, setProcessPreset] = 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)[]>([]);
|
|
// Bundle dispatch (alternative to the preset triplet). When non-null, the
|
|
// SliceModal hides the cloud/local/standard preset dropdowns and shows
|
|
// bundle-scoped pickers (process + per-slot filament from the chosen
|
|
// bundle's contents). Submit routes through the backend's bundle dispatch
|
|
// (`SliceRequest.bundle`) which skips PresetRef resolution.
|
|
const [selectedBundleId, setSelectedBundleId] = useState<string | null>(null);
|
|
const [bundleProcessName, setBundleProcessName] = useState<string | null>(null);
|
|
const [bundleFilamentNames, setBundleFilamentNames] = useState<(string | 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);
|
|
// Build-plate override (#1337). null = inherit from the process preset
|
|
// (the default). Set to a canonical slicer enum value to patch
|
|
// curr_bed_type into the resolved process JSON before slicing — needed
|
|
// because the process preset's default plate (typically "Cool Plate") is
|
|
// incompatible with high-temp filaments like ABS / ASA / PC, and the
|
|
// user had no way to switch plates without cloning the preset.
|
|
const [bedType, setBedType] = useState<string | 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;
|
|
// Generate a request_id per (source, plate) pair so the backend's
|
|
// preview-slice and the FilamentAnalysisSpinner's progress poll share
|
|
// the same id. useMemo keeps it stable across renders within the same
|
|
// pair; switching plates regenerates so a stale poll doesn't bleed
|
|
// progress between plates.
|
|
const previewRequestId = useMemo(() => {
|
|
const random =
|
|
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
? crypto.randomUUID()
|
|
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
// Tag the id with the (source, plate) so logs/Network panel show which
|
|
// pair owns the poll. Also lets the lint rule see the deps in use.
|
|
return `${source.kind}-${source.id}-p${effectivePlateId}-${random}`;
|
|
}, [source.kind, source.id, effectivePlateId]);
|
|
const filamentReqsQuery = useQuery({
|
|
queryKey: ['sliceFilamentReqs', source.kind, source.id, effectivePlateId],
|
|
queryFn: async () => {
|
|
if (source.kind === 'libraryFile') {
|
|
return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId, previewRequestId);
|
|
}
|
|
return api.getArchiveFilamentRequirements(source.id, effectivePlateId, previewRequestId);
|
|
},
|
|
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,
|
|
});
|
|
|
|
// Imported Printer Preset Bundles (.bbscfg). Empty list when no sidecar
|
|
// configured / no bundles imported yet; the bundle picker hides itself
|
|
// in that case so users without bundles see the original modal layout.
|
|
const bundlesQuery = useQuery({
|
|
queryKey: ['slicerBundles'],
|
|
queryFn: api.listSlicerBundles,
|
|
staleTime: 60_000,
|
|
enabled: !platesQuery.isLoading && !needsPlatePicker,
|
|
// Bundle listing is a hard 503 when the sidecar is offline; don't
|
|
// retry tight loops in that case.
|
|
retry: false,
|
|
});
|
|
const selectedBundle: SlicerBundle | null = useMemo(() => {
|
|
if (!selectedBundleId || !bundlesQuery.data) return null;
|
|
return bundlesQuery.data.find((b) => b.id === selectedBundleId) ?? null;
|
|
}, [selectedBundleId, bundlesQuery.data]);
|
|
const isBundleMode = selectedBundle != null;
|
|
|
|
// 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'));
|
|
// 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]);
|
|
|
|
// 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
|
|
// first listed process and every filament slot to the bundle's first
|
|
// listed filament. Plain string match — bundles store delta files keyed
|
|
// by user preset name, no scoring needed since the user picks per-slot
|
|
// afterwards if the default is wrong.
|
|
useEffect(() => {
|
|
if (!selectedBundle) {
|
|
// Reset bundle picks when bundle is cleared so re-selection
|
|
// re-defaults rather than carrying stale values.
|
|
setBundleProcessName(null);
|
|
setBundleFilamentNames([]);
|
|
return;
|
|
}
|
|
setBundleProcessName((current) => {
|
|
// Preserve a manual pick if it still exists in the bundle; otherwise
|
|
// re-default. Same shape as the preset auto-pick effect above.
|
|
if (current && selectedBundle.process.includes(current)) return current;
|
|
return selectedBundle.process[0] ?? null;
|
|
});
|
|
setBundleFilamentNames((current) => {
|
|
if (current.length === filamentSlots.length && current.every((n) => n != null)) {
|
|
return current;
|
|
}
|
|
const fallback = selectedBundle.filament[0] ?? null;
|
|
return filamentSlots.map((_, i) => current[i] ?? fallback);
|
|
});
|
|
}, [selectedBundle, filamentSlots]);
|
|
|
|
const enqueueMutation = useMutation({
|
|
mutationFn: async () => {
|
|
let body: SliceRequest;
|
|
if (isBundleMode) {
|
|
// Bundle dispatch path. The selected bundle's first printer is the
|
|
// implicit printer choice (every .bbscfg carries exactly one).
|
|
if (
|
|
!selectedBundle ||
|
|
!bundleProcessName ||
|
|
bundleFilamentNames.length === 0 ||
|
|
bundleFilamentNames.some((n) => n == null)
|
|
) {
|
|
throw new Error(t('slice.bundleAllRequired', 'Bundle process and every filament slot must be picked'));
|
|
}
|
|
const bundleSpec: SliceBundleSpec = {
|
|
bundle_id: selectedBundle.id,
|
|
printer_name: selectedBundle.printer[0] ?? selectedBundle.printer_preset_name,
|
|
process_name: bundleProcessName,
|
|
filament_names: bundleFilamentNames as string[],
|
|
};
|
|
body = {
|
|
bundle: bundleSpec,
|
|
...(selectedPlate != null ? { plate: selectedPlate } : {}),
|
|
// Bed-type override (#1337) also flows through the bundle path —
|
|
// the sidecar forwards `bedType` as --curr_bed_type to the CLI.
|
|
...(bedType != null ? { bed_type: bedType } : {}),
|
|
};
|
|
} else {
|
|
if (
|
|
!printerPreset ||
|
|
!processPreset ||
|
|
filamentPresets.length === 0 ||
|
|
filamentPresets.some((r) => r == null)
|
|
) {
|
|
throw new Error(t('slice.allPresetsRequired', 'All presets must be selected'));
|
|
}
|
|
body = {
|
|
printer_preset: printerPreset,
|
|
process_preset: processPreset,
|
|
// 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 } : {}),
|
|
// Bed-type override (#1337).
|
|
...(bedType != null ? { bed_type: bedType } : {}),
|
|
};
|
|
}
|
|
if (source.kind === 'libraryFile') {
|
|
return api.sliceLibraryFile(source.id, body);
|
|
}
|
|
return api.sliceArchive(source.id, body);
|
|
},
|
|
onSuccess: (enqueue) => {
|
|
trackJob(enqueue.job_id, source.kind, source.filename);
|
|
onClose();
|
|
},
|
|
onError: (err: unknown) => {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
setErrorMessage(msg);
|
|
},
|
|
});
|
|
|
|
// Pre-slice compatibility check: the slicer CLI (both OrcaSlicer and
|
|
// BambuStudio) cannot re-slice a 3MF for a printer different from the one
|
|
// it was originally bound to — the cross-printer "convert project" flow
|
|
// is desktop-Studio only. If we can match the source's printer model to a
|
|
// SliceModal-known model and the user's chosen printer profile names a
|
|
// different model, surface a warning before they click Slice.
|
|
//
|
|
// For bundle mode, the bundle's printer_preset_name plays the same role
|
|
// as the picked PresetRef's resolved name in preset mode.
|
|
const sourcePrinterModel = platesQuery.data?.source_printer_model ?? null;
|
|
const printerProfileName = isBundleMode
|
|
? selectedBundle?.printer_preset_name.replace(/^# /, '') ?? null
|
|
: printerPreset
|
|
? presetsQuery.data?.[printerPreset.source].printer.find((p) => p.id === printerPreset.id)?.name
|
|
: null;
|
|
// Profile names follow `<model> <nozzle> nozzle` (e.g. "Bambu Lab H2D 0.4
|
|
// nozzle"). The CLI compat check uses the model prefix; substring match
|
|
// catches both standard and locally-imported user-named profiles that
|
|
// include the model in the name. Cloud presets with arbitrary names
|
|
// (e.g. "My Custom X1C") fall through to no-warning, which is a
|
|
// reasonable default — the user picked it knowingly.
|
|
const printerMismatch =
|
|
!!sourcePrinterModel &&
|
|
!!printerProfileName &&
|
|
!printerProfileName.toLowerCase().includes(sourcePrinterModel.toLowerCase());
|
|
|
|
// Slice button stays disabled until *all* of these hold:
|
|
// - the preview slice / embedded-metadata read has succeeded so we know
|
|
// the per-plate filament slot list is final
|
|
// (filamentReqsQuery.isSuccess). Without this gate the synthetic
|
|
// single-slot fallback would auto-enable the button on opaque
|
|
// defaults, before the slicer has even returned the real slot map.
|
|
// - printer + process picked, every filament slot has a profile (the
|
|
// auto-pick fills these once filamentSlots arrives)
|
|
// - no printer-mismatch warning is up (clicking would silently fall
|
|
// back to embedded settings and produce a wrong-printer file)
|
|
const isReady = isBundleMode
|
|
? selectedBundle != null &&
|
|
bundleProcessName != null &&
|
|
filamentReqsQuery.isSuccess &&
|
|
bundleFilamentNames.length > 0 &&
|
|
bundleFilamentNames.every((n) => n != null) &&
|
|
!printerMismatch
|
|
: printerPreset != null &&
|
|
processPreset != null &&
|
|
filamentReqsQuery.isSuccess &&
|
|
filamentPresets.length > 0 &&
|
|
filamentPresets.every((r) => r != null) &&
|
|
!printerMismatch;
|
|
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"
|
|
onClick={() => {
|
|
if (!isEnqueuing) onClose();
|
|
}}
|
|
>
|
|
<div
|
|
className="w-full max-w-xl max-h-[85vh] flex flex-col rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary/60"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex-shrink-0 flex items-start justify-between gap-3 px-4 pt-4 pb-3 border-b border-bambu-dark-tertiary/40">
|
|
<div className="min-w-0">
|
|
<h3 className="text-white font-medium flex items-center gap-2">
|
|
<Cog className="w-4 h-4" />
|
|
{t('slice.title', 'Slice model')}
|
|
</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
|
|
onClick={onClose}
|
|
disabled={isEnqueuing}
|
|
className="flex-shrink-0 text-bambu-gray hover:text-white transition-colors disabled:opacity-50"
|
|
aria-label={t('common.close', 'Close')}
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
{/* Preset listing loader — printer/process dropdowns can't render
|
|
without it. Plate query reuses the same spinner since it's
|
|
also blocking. */}
|
|
{(platesQuery.isLoading || presetsQuery.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…')}
|
|
</div>
|
|
)}
|
|
|
|
{presetsQuery.isError && (
|
|
<div className="text-sm text-red-400" role="alert">
|
|
{t(
|
|
'slice.presetsLoadFailed',
|
|
'Failed to load presets. Open Settings → Profiles to import them, or sign in to Bambu Cloud.',
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{presetsQuery.data && (
|
|
<>
|
|
<CloudStatusBanner status={presetsQuery.data.cloud_status} />
|
|
{/* Bundle picker — only renders when at least one .bbscfg has
|
|
been imported via Settings → Slicer Bundles. Lets the user
|
|
trade the cloud/local/standard tier for a single curated
|
|
triplet from a previously-uploaded BambuStudio bundle. */}
|
|
{bundlesQuery.data && bundlesQuery.data.length > 0 && (
|
|
<BundlePicker
|
|
bundles={bundlesQuery.data}
|
|
selectedId={selectedBundleId}
|
|
onChange={setSelectedBundleId}
|
|
disabled={isEnqueuing}
|
|
/>
|
|
)}
|
|
{/* Preset triplet — hidden when a bundle is selected so the
|
|
user only sees one tier at a time. The bundle's process +
|
|
filament dropdowns render below in their stead. */}
|
|
{!isBundleMode && (
|
|
<>
|
|
<PresetDropdown
|
|
label={t('slice.printer', 'Printer profile')}
|
|
slot="printer"
|
|
data={presetsQuery.data}
|
|
value={printerPreset}
|
|
onChange={setPrinterPreset}
|
|
disabled={isEnqueuing}
|
|
/>
|
|
<PresetDropdown
|
|
label={t('slice.process', 'Process profile')}
|
|
slot="process"
|
|
data={presetsQuery.data}
|
|
value={processPreset}
|
|
onChange={setProcessPreset}
|
|
disabled={isEnqueuing}
|
|
/>
|
|
</>
|
|
)}
|
|
{isBundleMode && selectedBundle && (
|
|
<>
|
|
{/* Bundle's printer is implicit (each .bbscfg has exactly
|
|
one). Show it as a read-only label so the user can
|
|
verify the printer they're slicing for. */}
|
|
<div>
|
|
<label className="block text-sm text-bambu-gray mb-1">
|
|
{t('slice.printer', 'Printer profile')}
|
|
</label>
|
|
<div className="px-3 py-2 rounded-md bg-bambu-dark/40 border border-bambu-dark-tertiary text-white text-sm">
|
|
{selectedBundle.printer_preset_name}
|
|
</div>
|
|
</div>
|
|
<BundleStringDropdown
|
|
label={t('slice.process', 'Process profile')}
|
|
options={selectedBundle.process}
|
|
value={bundleProcessName}
|
|
onChange={setBundleProcessName}
|
|
disabled={isEnqueuing}
|
|
/>
|
|
</>
|
|
)}
|
|
{/* Bed-type override (#1337). Always visible, always enabled.
|
|
In non-bundle mode the backend patches curr_bed_type on the
|
|
resolved process JSON before forwarding to the sidecar; in
|
|
bundle mode the same value rides through as a sidecar form
|
|
field so the bundle's materialised process JSON gets the
|
|
override applied there too. */}
|
|
<BedTypeDropdown
|
|
value={bedType}
|
|
onChange={setBedType}
|
|
disabled={isEnqueuing}
|
|
/>
|
|
{/* Filament reqs may need a server-side preview-slice for
|
|
unsliced project files (single-pass, then cached). Show a
|
|
scoped spinner so the user sees the printer/process
|
|
dropdowns instead of an opaque "Loading presets…" wait. */}
|
|
{filamentReqsQuery.isLoading ? (
|
|
<FilamentAnalysisSpinner
|
|
requestId={previewRequestId}
|
|
sourceName={source.filename}
|
|
/>
|
|
) : isBundleMode && selectedBundle ? (
|
|
filamentSlots.map((slot, idx) => {
|
|
const isUsed = slot.used_in_plate !== false;
|
|
const baseLabel =
|
|
filamentSlots.length > 1
|
|
? t('slice.filamentSlot', {
|
|
index: idx + 1,
|
|
type: slot.type,
|
|
defaultValue: `Filament ${idx + 1} (${slot.type || ''})`,
|
|
})
|
|
: t('slice.filament', 'Filament profile');
|
|
const label = isUsed
|
|
? baseLabel
|
|
: `${baseLabel} ${t('slice.notUsedByPlate', '— not used by this plate')}`;
|
|
return (
|
|
<BundleStringDropdown
|
|
key={`bundle-filament-${idx}`}
|
|
label={label}
|
|
options={selectedBundle.filament}
|
|
value={bundleFilamentNames[idx] ?? null}
|
|
onChange={(name) =>
|
|
setBundleFilamentNames((current) => {
|
|
const next = current.length === filamentSlots.length
|
|
? [...current]
|
|
: filamentSlots.map((_, i) => current[i] ?? null);
|
|
next[idx] = name;
|
|
return next;
|
|
})
|
|
}
|
|
disabled={isEnqueuing || !isUsed}
|
|
swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
|
|
/>
|
|
);
|
|
})
|
|
) : (
|
|
filamentSlots.map((slot, idx) => {
|
|
// Slots flagged by the backend as not used by the
|
|
// picked plate are auto-picked from project metadata
|
|
// and disabled — the slicer CLI still needs a
|
|
// profile per project slot, but the user shouldn't
|
|
// have to think about slots their plate doesn't
|
|
// paint with. used_in_plate defaults to true when
|
|
// missing (sliced 3MFs and the no-flag legacy path).
|
|
const isUsed = slot.used_in_plate !== false;
|
|
const baseLabel =
|
|
filamentSlots.length > 1
|
|
? t('slice.filamentSlot', {
|
|
index: idx + 1,
|
|
type: slot.type,
|
|
defaultValue: `Filament ${idx + 1} (${slot.type || ''})`,
|
|
})
|
|
: t('slice.filament', 'Filament profile');
|
|
const label = isUsed
|
|
? baseLabel
|
|
: `${baseLabel} ${t('slice.notUsedByPlate', '— not used by this plate')}`;
|
|
return (
|
|
<PresetDropdown
|
|
key={`filament-${idx}`}
|
|
label={label}
|
|
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 || !isUsed}
|
|
swatchColor={filamentSlots.length > 1 ? slot.color : undefined}
|
|
/>
|
|
);
|
|
})
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{printerMismatch && (
|
|
<div
|
|
className="text-sm text-amber-200 bg-amber-900/20 border border-amber-700/40 rounded p-2"
|
|
role="alert"
|
|
>
|
|
{t('slice.printerMismatch', {
|
|
source: sourcePrinterModel,
|
|
target: printerProfileName,
|
|
defaultValue:
|
|
'This 3MF was sliced for {{source}}, but you picked {{target}}. The slicer CLI cannot re-slice a 3MF for a different printer — open the source in Bambu Studio, change the printer, and re-export.',
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{errorMessage && (
|
|
<div className="text-sm text-red-400 bg-red-900/20 border border-red-900/40 rounded p-2" role="alert">
|
|
{errorMessage}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex-shrink-0 flex justify-end gap-2 px-4 py-3 border-t border-bambu-dark-tertiary/40">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
disabled={isEnqueuing}
|
|
className="px-3 py-1.5 text-sm rounded-md border border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray transition-colors disabled:opacity-50"
|
|
>
|
|
{t('common.cancel', 'Cancel')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setErrorMessage(null);
|
|
enqueueMutation.mutate();
|
|
}}
|
|
disabled={!isReady || isEnqueuing}
|
|
className="px-3 py-1.5 text-sm rounded-md bg-bambu-green hover:bg-bambu-green/90 text-bambu-dark font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
|
>
|
|
{isEnqueuing ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
{t('slice.enqueuing', 'Submitting slice job…')}
|
|
</>
|
|
) : (
|
|
t('slice.action', 'Slice')
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CloudStatusBanner({ status }: { status: SlicerCloudStatus }) {
|
|
const { t } = useTranslation();
|
|
if (status === 'ok') return null;
|
|
|
|
// Map each non-ok status to the appropriate icon + tone. None of these are
|
|
// hard errors — the user can still slice using local + standard presets,
|
|
// so we use info / warn styling rather than error red.
|
|
const config: Record<Exclude<SlicerCloudStatus, 'ok'>, { tone: string; icon: typeof Cloud; key: string; fallback: string }> = {
|
|
not_authenticated: {
|
|
tone: 'border-bambu-dark-tertiary/40 bg-bambu-dark text-bambu-gray',
|
|
icon: Cloud,
|
|
key: 'slice.cloud.notAuthenticated',
|
|
fallback: 'Sign in to Bambu Cloud (Settings → Profiles → Cloud) to see your cloud presets.',
|
|
},
|
|
expired: {
|
|
tone: 'border-amber-700/40 bg-amber-900/20 text-amber-200',
|
|
icon: CloudOff,
|
|
key: 'slice.cloud.expired',
|
|
fallback: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
|
|
},
|
|
unreachable: {
|
|
tone: 'border-bambu-dark-tertiary/40 bg-bambu-dark text-bambu-gray',
|
|
icon: CloudOff,
|
|
key: 'slice.cloud.unreachable',
|
|
fallback: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
|
|
},
|
|
};
|
|
const { tone, icon: Icon, key, fallback } = config[status];
|
|
return (
|
|
<div className={`flex items-start gap-2 text-xs rounded-md border p-2 ${tone}`} role="status">
|
|
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
|
<span>{t(key, fallback)}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Build-plate options offered in the SliceModal (#1337). Values are the
|
|
// canonical strings the slicer's StaticPrintConfig validator accepts as
|
|
// `curr_bed_type` — BambuStudio is the default sidecar, so this matches its
|
|
// enum; OrcaSlicer accepts the same set with a Supertack alias that users
|
|
// can target via the same dropdown if they re-import their presets.
|
|
const BED_TYPE_OPTIONS: { value: string; labelKey: string; fallback: string }[] = [
|
|
{ value: 'Cool Plate', labelKey: 'slice.bedType.coolPlate', fallback: 'Cool Plate' },
|
|
{
|
|
value: 'Cool Plate (SuperTack)',
|
|
labelKey: 'slice.bedType.coolPlateSuperTack',
|
|
fallback: 'Cool Plate SuperTack',
|
|
},
|
|
{ value: 'Engineering Plate', labelKey: 'slice.bedType.engineering', fallback: 'Engineering Plate' },
|
|
{ value: 'High Temp Plate', labelKey: 'slice.bedType.highTemp', fallback: 'High Temp Plate' },
|
|
{ value: 'Textured PEI Plate', labelKey: 'slice.bedType.texturedPEI', fallback: 'Textured PEI Plate' },
|
|
{ value: 'Smooth PEI Plate', labelKey: 'slice.bedType.smoothPEI', fallback: 'Smooth PEI Plate' },
|
|
];
|
|
|
|
function BedTypeDropdown({
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
}: {
|
|
value: string | null;
|
|
onChange: (value: string | null) => void;
|
|
disabled?: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<label className="block">
|
|
<span className="block text-xs text-bambu-gray mb-1">
|
|
{t('slice.bedType.label', 'Build plate')}
|
|
</span>
|
|
<select
|
|
value={value ?? ''}
|
|
onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
|
|
disabled={disabled}
|
|
className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
|
|
>
|
|
<option value="">{t('slice.bedType.auto', 'Auto (use process preset)')}</option>
|
|
{BED_TYPE_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{t(opt.labelKey, opt.fallback)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
interface PresetDropdownProps {
|
|
label: string;
|
|
slot: Slot;
|
|
data: UnifiedPresetsResponse;
|
|
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, 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: '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
|
|
.map(({ key, label: lk, fallback }) => ({
|
|
tierLabel: t(lk, fallback),
|
|
entries: (data[key] as UnifiedPresetsBySlot)[slot],
|
|
}))
|
|
.filter((s) => s.entries.length > 0);
|
|
}, [data, slot, t]);
|
|
|
|
const totalEntries = sections.reduce((sum, s) => sum + s.entries.length, 0);
|
|
|
|
return (
|
|
<label className="block">
|
|
<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))}
|
|
disabled={disabled || totalEntries === 0}
|
|
className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
|
|
>
|
|
<option value="">
|
|
{totalEntries === 0
|
|
? t('slice.noPresetsForSlot', 'No presets available')
|
|
: t('slice.selectPreset', '— Select a preset —')}
|
|
</option>
|
|
{sections.map((section) => (
|
|
<optgroup key={section.tierLabel} label={section.tierLabel}>
|
|
{section.entries.map((p) => (
|
|
<option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</optgroup>
|
|
))}
|
|
</select>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
// Top-of-modal bundle picker. The "None" option leaves the user on the
|
|
// cloud/local/standard tier path; selecting a bundle id flips the modal
|
|
// into bundle dispatch mode (see SliceModal state above).
|
|
interface BundlePickerProps {
|
|
bundles: SlicerBundle[];
|
|
selectedId: string | null;
|
|
onChange: (id: string | null) => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
function BundlePicker({ bundles, selectedId, onChange, disabled }: BundlePickerProps) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<label className="block">
|
|
<span className="block text-sm text-bambu-gray mb-1 inline-flex items-center gap-1.5">
|
|
<Package className="w-3.5 h-3.5" />
|
|
{t('slice.bundle', 'Slicer bundle')}
|
|
</span>
|
|
<select
|
|
value={selectedId ?? ''}
|
|
onChange={(e) => onChange(e.target.value || null)}
|
|
disabled={disabled}
|
|
className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
|
|
>
|
|
<option value="">
|
|
{t('slice.bundleNone', '— None (pick presets individually) —')}
|
|
</option>
|
|
{bundles.map((b) => (
|
|
<option key={b.id} value={b.id}>
|
|
{b.printer_preset_name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
// Plain-string dropdown used for bundle-mode process / filament selectors.
|
|
// Bundles store presets as a flat list of names within their printer-tied
|
|
// directory, so a `<select>` of strings is enough — no source tier, no
|
|
// optgroups. Same swatch / disabled affordances as the cloud/local/standard
|
|
// PresetDropdown above so the visual rhythm of the form stays consistent.
|
|
interface BundleStringDropdownProps {
|
|
label: string;
|
|
options: string[];
|
|
value: string | null;
|
|
onChange: (next: string | null) => void;
|
|
disabled?: boolean;
|
|
swatchColor?: string;
|
|
}
|
|
|
|
function BundleStringDropdown({
|
|
label,
|
|
options,
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
swatchColor,
|
|
}: BundleStringDropdownProps) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<label className="block">
|
|
<span className="block text-sm text-bambu-gray mb-1 inline-flex items-center gap-1.5">
|
|
{swatchColor && (
|
|
<span
|
|
className="inline-block w-3 h-3 rounded-sm border border-black/20"
|
|
style={{ backgroundColor: swatchColor || 'transparent' }}
|
|
aria-hidden
|
|
/>
|
|
)}
|
|
<span>{label}</span>
|
|
</span>
|
|
<select
|
|
value={value ?? ''}
|
|
onChange={(e) => onChange(e.target.value || null)}
|
|
disabled={disabled || options.length === 0}
|
|
className="w-full px-3 py-2 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-white text-sm focus:outline-none focus:border-bambu-gray disabled:opacity-50"
|
|
>
|
|
<option value="">
|
|
{options.length === 0
|
|
? t('slice.noPresetsForSlot', 'No presets available')
|
|
: t('slice.selectPreset', '— Select a preset —')}
|
|
</option>
|
|
{options.map((name) => (
|
|
<option key={name} value={name}>
|
|
{name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
);
|
|
}
|