mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
1601 lines
74 KiB
TypeScript
1601 lines
74 KiB
TypeScript
import { Cloud, CloudOff, Cog, Loader2, RefreshCw, X } from 'lucide-react';
|
|
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
api,
|
|
type PresetRef,
|
|
type PresetSource,
|
|
type SliceJobProgress,
|
|
type SliceRequest,
|
|
type SlicerCloudStatus,
|
|
type UnifiedPreset,
|
|
type UnifiedPresetsBySlot,
|
|
type UnifiedPresetsResponse,
|
|
} from '../api/client';
|
|
import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { useIsWideLayout } from '../hooks/useIsWideLayout';
|
|
import { PlatePickerModal } from './PlatePickerModal';
|
|
import SlicerSettingsPanel, { type FilamentChoice } from './SlicerSettingsPanel';
|
|
import type { DesignOverride, PlateFilament } from '../types/plates';
|
|
import type { SettingValue } from '../types/slicerSettings';
|
|
import {
|
|
presetCompatibility,
|
|
buildCompatibilityIndex,
|
|
EMPTY_COMPATIBILITY_INDEX,
|
|
type PrinterCompatibilityIndex,
|
|
} from '../utils/slicerPrinterMatch';
|
|
import {
|
|
findPreset,
|
|
findPresetByName,
|
|
pickDefault,
|
|
pickFilamentForSlot,
|
|
pickProcessDefault,
|
|
statesDifferentMaterial,
|
|
type Slot,
|
|
} from '../utils/slicePresetPicker';
|
|
|
|
export type SliceSource =
|
|
| { kind: 'libraryFile'; id: number; filename: string }
|
|
| { kind: 'archive'; id: number; filename: string };
|
|
|
|
interface SliceModalProps {
|
|
source: SliceSource;
|
|
onClose: () => void;
|
|
// Start with auto-arrange ticked. Set when the source was just built by
|
|
// "Combine to 3MF": its objects only become a sensible plate once arranged.
|
|
defaultAutoArrange?: boolean;
|
|
}
|
|
|
|
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 !== 'orca_cloud' && 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', {
|
|
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');
|
|
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`;
|
|
}
|
|
|
|
// The slicer's own default when nothing supplies a colour — Bambu green. Shown
|
|
// in the picker for a source that carries no colour of its own (an STL, or a
|
|
// mesh-only 3MF) so the swatch tells the truth about what the slice will
|
|
// produce rather than showing an invented placeholder.
|
|
const SLICER_DEFAULT_COLOUR = '#00AE42';
|
|
|
|
// `<input type="color">` accepts only `#RRGGBB`. Source colours reach us in
|
|
// both that form and the 8-digit `#RRGGBBAA` the AMS reports, so the alpha byte
|
|
// is trimmed for display only — an untouched slot still submits the original
|
|
// string, alpha included.
|
|
function colourInputValue(raw: string | null | undefined): string {
|
|
const value = (raw || '').trim();
|
|
return /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(value)
|
|
? value.slice(0, 7).toUpperCase()
|
|
: SLICER_DEFAULT_COLOUR;
|
|
}
|
|
|
|
export function SliceModal({ source, onClose, defaultAutoArrange = false }: SliceModalProps) {
|
|
const { t } = useTranslation();
|
|
const { trackJob } = useSliceJobTracker();
|
|
const queryClient = useQueryClient();
|
|
|
|
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)[]>([]);
|
|
// Slots the user chose a filament for by hand, or by applying a pipeline.
|
|
// The pre-pick below re-picks a slot whose preset states the wrong material,
|
|
// and without this it would do that to a deliberate choice too — printing
|
|
// PETG on a plate a designer labelled PLA is a thing people do on purpose. A
|
|
// ref rather than state: this must not itself re-trigger the pre-pick.
|
|
const explicitFilamentSlots = useRef<Set<number>>(new Set());
|
|
// Per-slot colour override, plate-slot-ordered alongside `filamentPresets`.
|
|
// `null` means "not overridden" rather than "no colour": the slot then falls
|
|
// through to the source plate's own colour, and — when the source has none
|
|
// either — to the backend's remaining fallbacks. Storing an explicit colour
|
|
// for every slot up front would defeat that chain, because a sent colour
|
|
// outranks the preset's own `default_filament_colour` (#2977).
|
|
const [filamentColours, setFilamentColours] = 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);
|
|
// "Slice all plates" mode: sends ``plate=0`` to the backend which forwards
|
|
// ``--slice 0`` to the BS CLI, producing a single output 3MF whose
|
|
// ``Metadata/plate_N.gcode`` entries are *all* plates sliced together —
|
|
// one archive, one file, all plates. Distinct from the per-plate
|
|
// ``selectedPlate`` mode (which slices just that one plate). Filament
|
|
// selection in this mode covers every slot the project defines, not
|
|
// just the slots the currently-visible plate happens to use — see
|
|
// ``allProjectFilamentSlots`` below.
|
|
const [sliceAllPlates, setSliceAllPlates] = useState(false);
|
|
// 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);
|
|
|
|
// "Slice as designed" (#2611). When on, the backend honours the source
|
|
// 3MF's embedded project_settings.config (the designer's own wall count,
|
|
// infill, etc.) instead of the picked process/filament profiles. Only
|
|
// offered when the picked printer matches the design's target model —
|
|
// see canUseEmbedded below.
|
|
const [useEmbedded, setUseEmbedded] = useState(false);
|
|
|
|
// Auto-orient / auto-arrange (#2548) — the GUI's two layout buttons,
|
|
// forwarded as the slicer's --orient / --arrange CLI actions. Per-slice
|
|
// and off by default: both rewrite the object placement the file came
|
|
// with, so they are something the user asks for, never a default. Kept
|
|
// enabled in embedded mode, unlike the process-level options around
|
|
// them — these act on the geometry, whichever config drives the slice.
|
|
const [autoOrient, setAutoOrient] = useState(false);
|
|
const [autoArrange, setAutoArrange] = useState(defaultAutoArrange);
|
|
|
|
// #2622: process settings the designer changed away from the stock preset,
|
|
// carried onto the picked process profile so a cross-printer re-slice keeps
|
|
// the model's intended wall count / infill / first layer instead of losing
|
|
// them to --load-settings. Keys the file flags as machine-coupled (speeds,
|
|
// accelerations, prime-tower geometry) are listed but start unticked — those
|
|
// were tuned for the designer's printer and can be plain wrong on another.
|
|
const [designKeys, setDesignKeys] = useState<Set<string>>(new Set());
|
|
|
|
// Process settings the user edited by hand in the settings panel. Two shapes
|
|
// are kept: the panel's editing values, and the same set serialised into the
|
|
// string forms a process preset stores. The panel owns the option schema, so
|
|
// it hands back both rather than making this component re-derive the second.
|
|
const [processOverrides, setProcessOverrides] = useState<Record<string, SettingValue>>({});
|
|
const [serializedProcessOverrides, setSerializedProcessOverrides] = useState<Record<string, string | string[]>>({});
|
|
const [settingsExpanded, setSettingsExpanded] = useState(false);
|
|
// Wide enough for the two-column layout, where the panel has a column to
|
|
// itself and so is always open. The disclosure only exists for the narrow
|
|
// single-stack layout, in which 348 unfolded options would bury the preset
|
|
// pickers above them.
|
|
const isWideLayout = useIsWideLayout();
|
|
const panelOpen = isWideLayout || settingsExpanded;
|
|
|
|
// Slicer Pipelines (#1425) — apply a saved preset bundle to all four slots
|
|
// with one pick, or save the current selection as a new pipeline.
|
|
const pipelinesQuery = useQuery({
|
|
queryKey: ['slicer-pipelines'],
|
|
queryFn: () => api.listSlicerPipelines(),
|
|
staleTime: 60_000,
|
|
});
|
|
const [savePipelineOpen, setSavePipelineOpen] = useState(false);
|
|
const [pipelineDraftName, setPipelineDraftName] = useState('');
|
|
const { showToast } = useToast();
|
|
const createPipelineMutation = useMutation({
|
|
mutationFn: (body: {
|
|
name: string;
|
|
printer_preset: PresetRef;
|
|
process_preset: PresetRef;
|
|
filament_presets: PresetRef[];
|
|
bed_type: string | null;
|
|
}) => api.createSlicerPipeline(body),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] });
|
|
showToast(t('slice.pipelines.toast.saved', 'Pipeline saved'), 'success');
|
|
setSavePipelineOpen(false);
|
|
setPipelineDraftName('');
|
|
},
|
|
onError: (err: Error) => {
|
|
showToast(err.message || t('slice.pipelines.toast.saveFailed', 'Save failed'), 'error');
|
|
},
|
|
});
|
|
|
|
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 () => {
|
|
// `fullSlots`: one row per project slot, not only the ones this plate
|
|
// prints with. The list below is positional all the way to the CLI's
|
|
// filament_N.json parts, so a source whose only used slot is 4 has to
|
|
// present four rows — otherwise the single pick binds to slot 1 and
|
|
// slot 4 slices with whatever the source had baked in (#2712). The
|
|
// unused rows stay disabled exactly as before.
|
|
if (source.kind === 'libraryFile') {
|
|
return api.getLibraryFileFilamentRequirements(source.id, effectivePlateId, previewRequestId, true);
|
|
}
|
|
return api.getArchiveFilamentRequirements(source.id, effectivePlateId, previewRequestId, true);
|
|
},
|
|
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). In ``sliceAllPlates`` mode
|
|
// we keep the same slot list (the backend already returns every project
|
|
// slot via ``extract_project_filaments_from_3mf``'s fallback path when
|
|
// slice_info doesn't carry per-plate filaments) but override every
|
|
// slot's ``used_in_plate`` flag to ``true`` so the dropdown labels
|
|
// drop the "— not used by this plate" suffix and the dropdowns become
|
|
// selectable. Across the whole project, every defined slot IS used by
|
|
// at least one plate, so this is correct in slice-all mode.
|
|
const filamentSlots = useMemo<PlateFilament[]>(() => {
|
|
const reqs = filamentReqsQuery.data?.filaments ?? [];
|
|
const base: PlateFilament[] =
|
|
reqs.length > 0
|
|
? (reqs as PlateFilament[])
|
|
: [{ slot_id: 1, type: '', color: '', used_grams: 0, used_meters: 0 }];
|
|
if (sliceAllPlates) {
|
|
return base.map((slot) => ({ ...slot, used_in_plate: true }));
|
|
}
|
|
return base;
|
|
}, [sliceAllPlates, 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,
|
|
});
|
|
|
|
// Manual refresh — bypasses the backend's 5-minute cloud cache and 1-hour
|
|
// bundled cache for one call so users who deleted a preset in Bambu
|
|
// Studio / Bambu Handy see the change immediately (#1581). The cache write
|
|
// inside _fetch_cloud_presets / _fetch_bundled_presets refills with the
|
|
// fresh result so subsequent normal callers still get cached responses.
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const handleRefreshPresets = async () => {
|
|
if (isRefreshing) return;
|
|
setIsRefreshing(true);
|
|
try {
|
|
const fresh = await api.getSlicerPresets({ refresh: true });
|
|
queryClient.setQueryData(['slicerPresets'], fresh);
|
|
} catch {
|
|
// Fall through to invalidate so React Query retries via its normal
|
|
// path on the next render — surfacing the failure through the existing
|
|
// presetsQuery.isError banner instead of duplicating error UI here.
|
|
queryClient.invalidateQueries({ queryKey: ['slicerPresets'] });
|
|
} finally {
|
|
setIsRefreshing(false);
|
|
}
|
|
};
|
|
|
|
// Canonical Bambu printer-model registry — drives the @BBL <code> name
|
|
// fallback in slicerPrinterMatch for cloud / standard presets (#1325).
|
|
// Long staleTime: the registry only changes across backend releases.
|
|
const printerModelsQuery = useQuery({
|
|
queryKey: ['slicerPrinterModels'],
|
|
queryFn: api.getSlicerPrinterModels,
|
|
staleTime: Infinity,
|
|
});
|
|
|
|
// Selected-printer context for the process / filament filter (#1325).
|
|
const selectedPrinterName = useMemo<string | null>(() => {
|
|
if (!presetsQuery.data || !printerPreset) return null;
|
|
return findPreset(presetsQuery.data, printerPreset, 'printer')?.name ?? null;
|
|
}, [presetsQuery.data, printerPreset]);
|
|
// Compatibility ground truth: the slicer's own `compatible_printers` list
|
|
// on local-imported presets, plus the @BBL <code> name fallback for cloud
|
|
// / standard presets via the backend Bambu printer-model registry.
|
|
const compatIndex = useMemo<PrinterCompatibilityIndex>(
|
|
() => buildCompatibilityIndex(printerModelsQuery.data ?? {}),
|
|
[printerModelsQuery.data],
|
|
);
|
|
|
|
// The picked process preset's effective values, flattened by the sidecar.
|
|
// Without this the settings panel shows OrcaSlicer's compiled-in defaults —
|
|
// a preset with a 0.42mm line width would read 0, which is the C++ default
|
|
// meaning "derive from the nozzle". Keyed on the preset so switching presets
|
|
// re-baselines the panel.
|
|
const presetValuesQuery = useQuery({
|
|
queryKey: ['slicer-preset-values', processPreset?.source, processPreset?.id],
|
|
queryFn: () => api.getSlicerPresetValues(processPreset as PresetRef),
|
|
enabled: processPreset != null,
|
|
// Preset contents only change when the user edits them in the slicer, and
|
|
// the modal is short-lived; no need to re-fetch while it is open.
|
|
staleTime: 5 * 60_000,
|
|
});
|
|
|
|
// A failed fetch is not an error the user must act on — the panel falls back
|
|
// to schema defaults and says so — so treat "no data yet" as unresolved
|
|
// rather than blocking the panel on it.
|
|
const presetValues = presetValuesQuery.data?.values as Record<string, SettingValue> | undefined;
|
|
const presetValuesResolved = presetValuesQuery.data?.resolved ?? presetValuesQuery.isLoading;
|
|
// A failed request (rather than a 'resolved: false' answer) means we
|
|
// never reached the backend, which is the same situation as an
|
|
// unreachable sidecar as far as the user is concerned.
|
|
const presetValuesReason = presetValuesQuery.data?.reason ?? (presetValuesQuery.isError ? 'sidecar_unavailable' : undefined);
|
|
|
|
// Slot list for the settings panel's filament pickers (support base and
|
|
// interface, and the Multimaterial page's per-region options). Those store a
|
|
// plain integer, so without this the user has to map slot numbers onto their
|
|
// own AMS by hand. Falls back to the slot's material when a slot has no pick
|
|
// yet, so the list is never a column of blanks.
|
|
const filamentChoices = useMemo<FilamentChoice[]>(() => {
|
|
const data = presetsQuery.data;
|
|
return filamentSlots.map((slot, idx) => {
|
|
const ref = filamentPresets[idx] ?? null;
|
|
const preset = data && ref ? findPreset(data, ref, 'filament') : null;
|
|
return {
|
|
index: idx + 1,
|
|
label: preset?.name || slot.type || t('slice.filamentSlotUnset', 'not set'),
|
|
color: slot.color || undefined,
|
|
};
|
|
});
|
|
}, [filamentSlots, filamentPresets, presetsQuery.data, t]);
|
|
|
|
// Printer / process preset names the source 3MF was prepared with. The
|
|
// plates query resolves before the presets query (the latter is gated on
|
|
// it), so these are known by the time the pre-pick effects run.
|
|
const embeddedPrinter = platesQuery.data?.embedded_printer ?? null;
|
|
const designOverrides = useMemo<DesignOverride[]>(
|
|
() => platesQuery.data?.design_overrides ?? [],
|
|
[platesQuery.data],
|
|
);
|
|
const embeddedProcess = platesQuery.data?.embedded_process ?? null;
|
|
|
|
// "Slice as designed" is offered only when the source carries embedded
|
|
// settings (a real project 3MF, not an STL) AND the picked printer matches
|
|
// the design's target model. The match gate is load-bearing: honouring
|
|
// embedded settings for a different model would place the model on the
|
|
// wrong bed. Names come from the same preset namespace, so a normalised
|
|
// (strip "# " prefix, case-fold) equality is enough.
|
|
const canUseEmbedded = useMemo<boolean>(() => {
|
|
if (!embeddedPrinter || !embeddedProcess || !selectedPrinterName) return false;
|
|
const norm = (s: string) => s.replace(/^#\s*/, '').trim().toLowerCase();
|
|
return norm(selectedPrinterName) === norm(embeddedPrinter);
|
|
}, [embeddedPrinter, embeddedProcess, selectedPrinterName]);
|
|
|
|
// Drop back to profile slicing whenever the toggle stops being offered
|
|
// (e.g. the user switches to a printer that doesn't match the design).
|
|
useEffect(() => {
|
|
if (!canUseEmbedded) setUseEmbedded(false);
|
|
}, [canUseEmbedded]);
|
|
|
|
// The per-key ticks follow "Use the file's built-in settings" (#2942).
|
|
// Off, nothing comes out of the file unless the user asks for it by name;
|
|
// on, the file drives the whole slice, so every setting it changed shows
|
|
// ticked — a readout of what the toggle means rather than a control, since
|
|
// the embedded path sends no design_overrides at all.
|
|
//
|
|
// They used to arrive pre-ticked whatever that toggle said, which is what
|
|
// #2942 reported: a slice with it deliberately unticked still took sixteen
|
|
// values from the file, several of them on rows the slicer had greyed out,
|
|
// where the tick could not be cleared. One checkbox now answers the
|
|
// question the whole dialog asks — where do this slice's settings come
|
|
// from — and the per-key ticks refine that answer instead of contradicting
|
|
// it. Reaching them in bulk is the panel's own "use the designer's
|
|
// settings", which still leaves the machine-coupled and preset-defining
|
|
// keys for an explicit per-key choice.
|
|
useEffect(() => {
|
|
setDesignKeys(useEmbedded ? new Set(designOverrides.map((o) => o.key)) : new Set());
|
|
}, [designOverrides, useEmbedded]);
|
|
|
|
// Printer pre-pick: defaults to the printer the 3MF was prepared for when
|
|
// that preset is available, else the first listed printer. Runs once when
|
|
// presets first arrive; later re-renders preserve any manual choice.
|
|
useEffect(() => {
|
|
const data = presetsQuery.data;
|
|
if (!data) return;
|
|
if (printerPreset == null) {
|
|
setPrinterPreset(
|
|
findPresetByName(data, 'printer', embeddedPrinter) ?? pickDefault(data, 'printer'),
|
|
);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [presetsQuery.data, embeddedPrinter]);
|
|
|
|
// Process pre-pick / re-pick (#1325): defaults to a process compatible with
|
|
// the selected printer, and re-defaults when a printer change leaves the
|
|
// current process incompatible. A compatible or unknown manual pick is kept.
|
|
useEffect(() => {
|
|
const data = presetsQuery.data;
|
|
if (!data) return;
|
|
setProcessPreset((current) => {
|
|
if (current) {
|
|
const p = findPreset(data, current, 'process');
|
|
if (p && presetCompatibility(p, 'process', selectedPrinterName, compatIndex) !== 'mismatch') {
|
|
return current;
|
|
}
|
|
}
|
|
return pickProcessDefault(data, selectedPrinterName, compatIndex, 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
|
|
// changes. Each slot scores every available filament preset against the
|
|
// slot's required (type, colour); an existing pick (incl. a user override)
|
|
// is kept as long as it's still compatible with the selected printer, while
|
|
// null slots and printer-incompatible picks are re-picked (#1325).
|
|
useEffect(() => {
|
|
const data = presetsQuery.data;
|
|
if (!data) return;
|
|
setFilamentPresets((current) => {
|
|
return filamentSlots.map((slot, i) => {
|
|
const cur = current[i] ?? null;
|
|
if (cur) {
|
|
const p = findPreset(data, cur, 'filament');
|
|
// Compatible with the printer is not on its own a reason to keep it:
|
|
// a preset that states a different material than the plate asks for
|
|
// is the wrong preset however well it fits the machine, and holding
|
|
// onto one is how a PETG profile survived on a PLA plate through
|
|
// every re-pick (#2982). An explicit choice is exempt — the material
|
|
// rule exists to correct the auto-pick, not to overrule the user.
|
|
const wrongMaterial = p
|
|
&& !explicitFilamentSlots.current.has(i)
|
|
&& statesDifferentMaterial(p, slot.type);
|
|
if (
|
|
p
|
|
&& !wrongMaterial
|
|
&& presetCompatibility(p, 'filament', selectedPrinterName, compatIndex) !== 'mismatch'
|
|
) {
|
|
return cur;
|
|
}
|
|
}
|
|
return pickFilamentForSlot(
|
|
data,
|
|
{ type: slot.type, color: slot.color },
|
|
selectedPrinterName,
|
|
compatIndex,
|
|
);
|
|
});
|
|
});
|
|
}, [presetsQuery.data, filamentSlots, selectedPrinterName, compatIndex]);
|
|
|
|
// Drop colour overrides when the slot count changes. A plate switch renumbers
|
|
// the slots, so keeping index-keyed overrides would paint slot 2's colour
|
|
// onto whatever the new plate happens to call slot 2. Same slot count keeps
|
|
// them: that is a re-pick of presets, not a different plate layout.
|
|
useEffect(() => {
|
|
setFilamentColours((current) => {
|
|
if (current.length === filamentSlots.length) return current;
|
|
// A renumbered slot list invalidates the record of which slots the user
|
|
// chose for the same reason it invalidates the colours: slot 2 of the new
|
|
// plate is not slot 2 of the old one.
|
|
explicitFilamentSlots.current = new Set();
|
|
return filamentSlots.map(() => null);
|
|
});
|
|
}, [filamentSlots]);
|
|
|
|
const enqueueMutation = useMutation({
|
|
mutationFn: async (plate: number | null) => {
|
|
const body = buildSliceBody(plate);
|
|
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);
|
|
},
|
|
});
|
|
|
|
// Body builder shared by the single-plate and slice-all paths. ``plate``
|
|
// is the 1-indexed plate number to slice, or ``null`` for STL / single-
|
|
// plate 3MF sources where the field is omitted entirely.
|
|
function buildSliceBody(plate: number | null): SliceRequest {
|
|
if (
|
|
!printerPreset ||
|
|
!processPreset ||
|
|
filamentPresets.length === 0 ||
|
|
filamentPresets.some((r) => r == null)
|
|
) {
|
|
throw new Error(t('slice.allPresetsRequired'));
|
|
}
|
|
return {
|
|
printer_preset: printerPreset,
|
|
process_preset: processPreset,
|
|
filament_preset: filamentPresets[0] as PresetRef,
|
|
filament_presets: filamentPresets as PresetRef[],
|
|
// An untouched slot submits the source plate's own colour, and an empty
|
|
// string when the source has none — which is what lets the backend fall
|
|
// back to the preset's `default_filament_colour` for the Orca-imported
|
|
// profiles that carry one. Sending a placeholder here instead would win
|
|
// that comparison and silently discard the preset's real colour.
|
|
filament_colours: filamentSlots.map(
|
|
(slot, i) => filamentColours[i] ?? slot.color ?? '',
|
|
),
|
|
...(plate != null ? { plate } : {}),
|
|
...(bedType != null ? { bed_type: bedType } : {}),
|
|
// The preset refs above are still sent (the backend validator requires
|
|
// them) but go unused when this flag is set — the slicer falls back on
|
|
// the file's embedded project_settings.config instead.
|
|
...(useEmbedded && canUseEmbedded ? { use_embedded_settings: true } : {}),
|
|
// Carried design settings are patched onto the resolved process JSON,
|
|
// which the embedded-settings path never sends — so they are mutually
|
|
// exclusive by construction (#2622).
|
|
//
|
|
// Sent whenever the file offered any, an empty list included: the
|
|
// backend reads a missing list as a client that predates the per-key
|
|
// ticks and keeps carrying the file's support settings unconditionally
|
|
// (#1881), where an empty one says the user was shown them and took
|
|
// none (#2942).
|
|
...(!useEmbedded && designOverrides.length > 0 ? { design_overrides: [...designKeys] } : {}),
|
|
// The user's own edits from the settings panel. Like design_overrides
|
|
// these patch the resolved process JSON, so the embedded-settings path
|
|
// (which sends no process JSON at all) cannot carry them.
|
|
...(!useEmbedded && Object.keys(processOverrides).length > 0
|
|
? { process_overrides: serializedProcessOverrides }
|
|
: {}),
|
|
// Sent only when on. The backend defaults both to false, so omitting
|
|
// them keeps the request identical to what older clients send.
|
|
...(autoOrient ? { auto_orient: true } : {}),
|
|
...(autoArrange ? { auto_arrange: true } : {}),
|
|
};
|
|
}
|
|
|
|
|
|
// Slice button stays disabled until the preview slice / embedded-metadata
|
|
// read has succeeded (filamentReqsQuery.isSuccess) and every filament slot
|
|
// has a picked profile.
|
|
const isReady =
|
|
printerPreset != null &&
|
|
processPreset != null &&
|
|
filamentReqsQuery.isSuccess &&
|
|
filamentPresets.length > 0 &&
|
|
filamentPresets.every((r) => r != null);
|
|
const isEnqueuing = enqueueMutation.isPending;
|
|
const totalPlateCount = platesQuery.data?.plates?.length ?? 0;
|
|
const canSliceAll = isMultiPlate && totalPlateCount > 1 && !needsPlatePicker;
|
|
|
|
// 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 lg:max-w-5xl xl:max-w-7xl 2xl:max-w-[96rem] 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')}
|
|
</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')}
|
|
>
|
|
<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')}
|
|
</div>
|
|
)}
|
|
|
|
{presetsQuery.isError && (
|
|
<div className="text-sm text-red-700 dark: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 && (
|
|
<>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 space-y-2">
|
|
<CloudStatusBanner status={presetsQuery.data.cloud_status} cloudName="bambu" />
|
|
<CloudStatusBanner status={presetsQuery.data.orca_cloud_status} cloudName="orca" />
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleRefreshPresets}
|
|
disabled={isRefreshing || isEnqueuing}
|
|
className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary/40 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
title={t('slice.refreshPresetsTitle')}
|
|
aria-label={t('slice.refreshPresets')}
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
|
|
{t('slice.refreshPresets')}
|
|
</button>
|
|
</div>
|
|
{/* CloudStatusBanner above is hidden via flex-1 wrapper when
|
|
status === 'ok' (returns null in that case), but the Refresh
|
|
button stays visible regardless so users can pick up cloud /
|
|
bundled changes even when sign-in is healthy. */}
|
|
{/* Two columns once there is room for them. The left keeps the
|
|
"what am I slicing with" decisions together; the right gives
|
|
the process-settings panel a column of its own, which is the
|
|
only way 348 options are comfortable to work through. Below
|
|
lg both collapse back into the original single stack. The
|
|
left column grows with the modal: fixed at 20rem it cut
|
|
preset names off and wrapped every filament label while the
|
|
settings panel sat half empty. */}
|
|
<div className="lg:grid lg:grid-cols-[minmax(20rem,2fr)_minmax(0,3fr)] lg:gap-5 lg:items-start">
|
|
<div className="space-y-4 min-w-0">
|
|
{/* Slicer Pipelines (#1425): apply a saved preset bundle to all
|
|
four slots, or save the current selection as a pipeline.
|
|
Pipelines are managed in Settings → Workflow → Pipelines. */}
|
|
<div className="flex flex-wrap items-center gap-2 px-2 py-1.5 rounded-md bg-bambu-dark/40 border border-bambu-dark-tertiary">
|
|
<span className="text-xs font-medium text-bambu-gray flex items-center gap-1">
|
|
<Cog className="w-3.5 h-3.5" /> {t('slice.pipelines.label', 'Pipeline')}
|
|
</span>
|
|
<select
|
|
value=""
|
|
disabled={isEnqueuing || (pipelinesQuery.data?.pipelines.length ?? 0) === 0}
|
|
onChange={(e) => {
|
|
const id = parseInt(e.target.value, 10);
|
|
if (Number.isNaN(id)) return;
|
|
const picked = pipelinesQuery.data?.pipelines.find((p) => p.id === id);
|
|
if (!picked) return;
|
|
// Apply slot state. The filament list is right-padded from
|
|
// current state so a pipeline with fewer entries than the
|
|
// current source's slot count keeps the existing tail.
|
|
setPrinterPreset(picked.printer_preset);
|
|
setProcessPreset(picked.process_preset);
|
|
setBedType(picked.bed_type);
|
|
setFilamentPresets((current) => {
|
|
const next = current.length > 0 ? [...current] : picked.filament_presets.map(() => null);
|
|
for (let i = 0; i < next.length; i++) {
|
|
if (i < picked.filament_presets.length) {
|
|
next[i] = picked.filament_presets[i];
|
|
// Applying a pipeline is as deliberate as picking
|
|
// from the dropdown, so the slots it fills are
|
|
// exempt from the material re-pick too.
|
|
explicitFilamentSlots.current.add(i);
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
showToast(t('slice.pipelines.toast.applied', 'Applied "{{name}}"', { name: picked.name }), 'success');
|
|
// Reset the dropdown so the user can re-apply the same
|
|
// pipeline if needed (selects don't fire onChange when
|
|
// value reselects the same option).
|
|
e.target.value = '';
|
|
}}
|
|
className="text-xs px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white disabled:opacity-50 disabled:cursor-not-allowed flex-1 min-w-[10ch]"
|
|
aria-label={t('slice.pipelines.applyAria', 'Apply pipeline')}
|
|
>
|
|
<option value="">
|
|
{(pipelinesQuery.data?.pipelines.length ?? 0) === 0
|
|
? t('slice.pipelines.empty', 'No saved pipelines')
|
|
: t('slice.pipelines.applyPrompt', 'Apply pipeline…')}
|
|
</option>
|
|
{pipelinesQuery.data?.pipelines.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{!savePipelineOpen ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setPipelineDraftName('');
|
|
setSavePipelineOpen(true);
|
|
}}
|
|
disabled={
|
|
isEnqueuing ||
|
|
!printerPreset ||
|
|
!processPreset ||
|
|
filamentPresets.length === 0 ||
|
|
filamentPresets.some((f) => f === null)
|
|
}
|
|
className="text-xs px-2 py-1 bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green border border-bambu-green/40 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title={t('slice.pipelines.saveTitle', 'Save the current four-slot selection as a reusable pipeline')}
|
|
>
|
|
{t('slice.pipelines.saveButton', 'Save as pipeline')}
|
|
</button>
|
|
) : (
|
|
<div className="flex items-center gap-1 flex-1 min-w-[16ch]">
|
|
<input
|
|
autoFocus
|
|
value={pipelineDraftName}
|
|
onChange={(e) => setPipelineDraftName(e.target.value)}
|
|
placeholder={t('slice.pipelines.namePlaceholder', 'Pipeline name')}
|
|
aria-label={t('slice.pipelines.nameAria', 'New pipeline name')}
|
|
className="flex-1 text-xs px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const trimmed = pipelineDraftName.trim();
|
|
if (!trimmed || !printerPreset || !processPreset) return;
|
|
const nonNull = filamentPresets.filter((f): f is PresetRef => f !== null);
|
|
if (nonNull.length === 0) return;
|
|
createPipelineMutation.mutate({
|
|
name: trimmed,
|
|
printer_preset: printerPreset,
|
|
process_preset: processPreset,
|
|
filament_presets: nonNull,
|
|
bed_type: bedType,
|
|
});
|
|
}}
|
|
disabled={createPipelineMutation.isPending || !pipelineDraftName.trim()}
|
|
className="text-xs px-2 py-1 bg-bambu-green hover:bg-bambu-green/80 text-white rounded disabled:opacity-50"
|
|
>
|
|
{createPipelineMutation.isPending ? (
|
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|
) : (
|
|
t('common.save', 'Save')
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSavePipelineOpen(false);
|
|
setPipelineDraftName('');
|
|
}}
|
|
className="text-xs px-2 py-1 text-bambu-gray hover:text-white"
|
|
>
|
|
{t('common.cancel', 'Cancel')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<PresetDropdown
|
|
label={t('slice.printer')}
|
|
slot="printer"
|
|
data={presetsQuery.data}
|
|
value={printerPreset}
|
|
onChange={setPrinterPreset}
|
|
// Locked in embedded mode too: the picked printer is unused on
|
|
// the embedded-settings path, and changing it away from the
|
|
// design's target would drop canUseEmbedded and yank the toggle
|
|
// out from under the user (#2611).
|
|
disabled={isEnqueuing || useEmbedded}
|
|
/>
|
|
{/* "Slice as designed" (#2611): honour the file's embedded
|
|
settings instead of the picked process/filament. Offered
|
|
only when the picked printer matches the design's target. */}
|
|
{canUseEmbedded && (
|
|
<label className="flex items-start gap-2 text-sm text-bambu-gray cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
checked={useEmbedded}
|
|
onChange={(e) => setUseEmbedded(e.target.checked)}
|
|
disabled={isEnqueuing}
|
|
className="mt-0.5 cursor-pointer"
|
|
/>
|
|
<span>
|
|
{t('slice.useEmbedded')}
|
|
<span className="block text-xs text-bambu-gray/70">
|
|
{t('slice.useEmbeddedHint')}
|
|
</span>
|
|
</span>
|
|
</label>
|
|
)}
|
|
<PresetDropdown
|
|
label={t('slice.process')}
|
|
slot="process"
|
|
data={presetsQuery.data}
|
|
value={processPreset}
|
|
onChange={setProcessPreset}
|
|
disabled={isEnqueuing || useEmbedded}
|
|
selectedPrinterName={selectedPrinterName}
|
|
compatIndex={compatIndex}
|
|
/>
|
|
|
|
{/* Bed-type override (#1337). Always visible, always enabled.
|
|
The backend patches curr_bed_type on the resolved process
|
|
JSON before forwarding to the sidecar. */}
|
|
{/* Bed-type patches curr_bed_type onto the resolved process
|
|
JSON, which the embedded-settings path never sends — so it
|
|
has no effect there and is disabled to avoid implying it
|
|
does. */}
|
|
<BedTypeDropdown
|
|
value={bedType}
|
|
onChange={setBedType}
|
|
disabled={isEnqueuing || useEmbedded}
|
|
/>
|
|
{/* Layout passes (#2548) — the GUI's "Auto orient" / "Auto
|
|
arrange". Not disabled in embedded mode: these are CLI
|
|
actions on the geometry, so they work regardless of where
|
|
the print config comes from. */}
|
|
<div className="flex flex-col gap-2">
|
|
<label className="flex items-start gap-2 text-sm text-bambu-gray cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
checked={autoOrient}
|
|
onChange={(e) => setAutoOrient(e.target.checked)}
|
|
disabled={isEnqueuing}
|
|
className="mt-0.5 cursor-pointer"
|
|
/>
|
|
<span>
|
|
{t('slice.autoOrient')}
|
|
<span className="block text-xs text-bambu-gray/70">
|
|
{t('slice.autoOrientHint')}
|
|
</span>
|
|
</span>
|
|
</label>
|
|
<label className="flex items-start gap-2 text-sm text-bambu-gray cursor-pointer select-none">
|
|
<input
|
|
type="checkbox"
|
|
checked={autoArrange}
|
|
onChange={(e) => setAutoArrange(e.target.checked)}
|
|
disabled={isEnqueuing}
|
|
className="mt-0.5 cursor-pointer"
|
|
/>
|
|
<span>
|
|
{t('slice.autoArrange')}
|
|
<span className="block text-xs text-bambu-gray/70">
|
|
{t('slice.autoArrangeHint')}
|
|
</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
{/* 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}
|
|
/>
|
|
) : (
|
|
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,
|
|
})
|
|
: t('slice.filament');
|
|
const label = isUsed
|
|
? baseLabel
|
|
: `${baseLabel} ${t('slice.notUsedByPlate')}`;
|
|
return (
|
|
<PresetDropdown
|
|
key={`filament-${idx}`}
|
|
label={label}
|
|
slot="filament"
|
|
data={presetsQuery.data}
|
|
value={filamentPresets[idx] ?? null}
|
|
onChange={(ref) => {
|
|
explicitFilamentSlots.current.add(idx);
|
|
setFilamentPresets((current) => {
|
|
const next = current.length === filamentSlots.length
|
|
? [...current]
|
|
: filamentSlots.map((_, i) => current[i] ?? null);
|
|
next[idx] = ref;
|
|
return next;
|
|
});
|
|
}}
|
|
disabled={isEnqueuing || !isUsed || useEmbedded}
|
|
// Shown for every filament slot now that it is editable,
|
|
// not just multi-color ones: a single-slot STL is exactly
|
|
// the case with no colour anywhere else to inherit.
|
|
swatchColor={filamentColours[idx] ?? slot.color}
|
|
swatchColorLabel={t('slice.filamentColour')}
|
|
onSwatchColorChange={(colour) =>
|
|
setFilamentColours((current) => {
|
|
const next = current.length === filamentSlots.length
|
|
? [...current]
|
|
: filamentSlots.map((_, i) => current[i] ?? null);
|
|
next[idx] = colour;
|
|
return next;
|
|
})
|
|
}
|
|
selectedPrinterName={selectedPrinterName}
|
|
compatIndex={compatIndex}
|
|
/>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* Right column: the settings panel. It owns this column, so
|
|
there is nothing to collapse it out of the way of — the
|
|
disclosure below lg exists only because the single-column
|
|
stack cannot afford 348 options unfolded.
|
|
|
|
Kept on screen in embedded mode but disabled rather than
|
|
removed: nothing here is sent on that path (the file's own
|
|
settings drive the slice), and dropping the column outright
|
|
made the dialog look like it had lost a feature whenever
|
|
the toggle was flipped. */}
|
|
<div className="mt-4 lg:mt-0 min-w-0">
|
|
<div
|
|
className={`rounded border border-bambu-dark-tertiary p-3 ${useEmbedded ? 'opacity-60' : ''}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSettingsExpanded((v) => !v)}
|
|
aria-expanded={panelOpen}
|
|
disabled={isWideLayout}
|
|
className="flex w-full items-center justify-between gap-2 text-left lg:cursor-default"
|
|
>
|
|
<span className="text-sm text-white">
|
|
{t('slice.processSettings', 'Process settings')}
|
|
<span className="block text-xs text-bambu-gray/70">
|
|
{useEmbedded
|
|
? t(
|
|
'slice.processSettingsEmbedded',
|
|
"Not used while \"Use the file's built-in settings\" is on -- the file's own settings drive this slice.",
|
|
)
|
|
: t(
|
|
'slice.processSettingsHint',
|
|
"Adjust the picked preset for this slice. Anything you don't touch stays as the preset defines it.",
|
|
)}
|
|
</span>
|
|
</span>
|
|
<span className="shrink-0 text-xs text-bambu-gray">
|
|
{useEmbedded
|
|
? t('slice.processSettingsInactive', 'Inactive')
|
|
: Object.keys(serializedProcessOverrides).length > 0
|
|
? t('slice.processSettingsChanged', '{{count}} changed', {
|
|
count: Object.keys(serializedProcessOverrides).length,
|
|
})
|
|
: t('slice.processSettingsUnchanged', 'Preset defaults')}
|
|
</span>
|
|
</button>
|
|
{panelOpen && (
|
|
<div className="mt-3 border-t border-bambu-dark-tertiary pt-3">
|
|
<SlicerSettingsPanel
|
|
values={processOverrides}
|
|
onChange={(values, serialized) => {
|
|
setProcessOverrides(values);
|
|
setSerializedProcessOverrides(serialized);
|
|
}}
|
|
disabled={isEnqueuing || useEmbedded}
|
|
// The designer's own deviations (#2622) are shown
|
|
// against the options they belong to rather than in
|
|
// a list of their own. Only the tick state lives
|
|
// here; the values still travel as design_overrides,
|
|
// so the backend keeps reading them from the file
|
|
// and keys outside the vendored schema stay faithful.
|
|
filamentChoices={filamentChoices}
|
|
presetValues={presetValues}
|
|
presetValuesResolved={presetValuesResolved}
|
|
presetValuesReason={presetValuesReason}
|
|
sourceOverrides={designOverrides}
|
|
sourceSelected={designKeys}
|
|
onToggleSource={(key, on) =>
|
|
setDesignKeys((prev) => {
|
|
const next = new Set(prev);
|
|
if (on) next.add(key);
|
|
else next.delete(key);
|
|
return next;
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{errorMessage && (
|
|
<div className="text-sm text-red-700 dark: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')}
|
|
</button>
|
|
{canSliceAll && (
|
|
<label
|
|
className="flex items-center gap-2 mr-auto text-sm text-bambu-gray cursor-pointer select-none"
|
|
title={t('slice.actionAllTitle', { count: totalPlateCount })}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={sliceAllPlates}
|
|
onChange={(e) => setSliceAllPlates(e.target.checked)}
|
|
disabled={isEnqueuing}
|
|
className="cursor-pointer"
|
|
/>
|
|
{t('slice.allPlatesToggle', { count: totalPlateCount })}
|
|
</label>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setErrorMessage(null);
|
|
// ``plate=0`` is the sidecar's "all plates" sentinel — passes
|
|
// ``--slice 0`` to the BS CLI which produces a single 3MF
|
|
// with one ``Metadata/plate_N.gcode`` entry per plate.
|
|
const platePayload = sliceAllPlates ? 0 : selectedPlate;
|
|
enqueueMutation.mutate(platePayload);
|
|
}}
|
|
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')}
|
|
</>
|
|
) : sliceAllPlates ? (
|
|
t('slice.actionAll', { count: totalPlateCount })
|
|
) : (
|
|
t('slice.action')
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CloudStatusBanner({
|
|
status,
|
|
cloudName = 'bambu',
|
|
}: {
|
|
status: SlicerCloudStatus;
|
|
cloudName?: 'bambu' | 'orca';
|
|
}) {
|
|
const { t } = useTranslation();
|
|
// `ok` is the happy path. `not_authenticated` is silenced too: a user who
|
|
// hasn't signed in (or has explicitly logged out — #1712) doesn't need a
|
|
// permanent nag at the top of the modal; sign-in lives on the Profiles
|
|
// page if they want it. Only `expired` and `unreachable` surface — those
|
|
// are real breakage states a previously-signed-in user needs to see.
|
|
if (status === 'ok' || status === 'not_authenticated') return null;
|
|
|
|
// Same status vocabulary for both Bambu and Orca Cloud — only the
|
|
// user-facing text varies. The fallbacks below name each cloud explicitly
|
|
// so the banner makes sense without translation when i18n hasn't been
|
|
// updated for a new locale.
|
|
const messages =
|
|
cloudName === 'orca'
|
|
? {
|
|
expired: {
|
|
key: 'slice.orcaCloud.expired',
|
|
fallback: 'Orca Cloud session expired — sign in again to refresh your Orca presets.',
|
|
},
|
|
unreachable: {
|
|
key: 'slice.orcaCloud.unreachable',
|
|
fallback: 'Orca Cloud is unreachable right now. Other presets still work.',
|
|
},
|
|
}
|
|
: {
|
|
expired: {
|
|
key: 'slice.cloud.expired',
|
|
fallback: 'Bambu Cloud session expired — sign in again to refresh your cloud presets.',
|
|
},
|
|
unreachable: {
|
|
key: 'slice.cloud.unreachable',
|
|
fallback: 'Bambu Cloud is unreachable right now. Local and standard presets still work.',
|
|
},
|
|
};
|
|
|
|
const tones: Record<'expired' | 'unreachable', { tone: string; icon: typeof Cloud }> = {
|
|
expired: {
|
|
tone: 'border-amber-300 dark:border-amber-700/40 bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200',
|
|
icon: CloudOff,
|
|
},
|
|
unreachable: {
|
|
tone: 'border-bambu-dark-tertiary/40 bg-bambu-dark text-bambu-gray',
|
|
icon: CloudOff,
|
|
},
|
|
};
|
|
const { tone, icon: Icon } = tones[status];
|
|
const { key, fallback } = messages[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')}
|
|
</span>
|
|
<select
|
|
value={value ?? ''}
|
|
onChange={(e) => onChange(e.target.value === '' ? null : e.target.value)}
|
|
disabled={disabled}
|
|
className="flex-1 min-w-0 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')}</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;
|
|
// When set, the swatch becomes an editable colour input for this slot
|
|
// (#2977). Filament presets carry no colour of their own, so for an STL —
|
|
// or any source whose plate was never given one — this is the only place the
|
|
// colour recorded in the sliced file can come from.
|
|
onSwatchColorChange?: (colour: string) => void;
|
|
// Accessible name for that input. Required alongside the handler because the
|
|
// visible label belongs to the preset <select>, not to the swatch.
|
|
swatchColorLabel?: string;
|
|
// Selected printer context (#1325). When provided for a process / filament
|
|
// slot, presets that resolve to a different printer (per compatIndex) are
|
|
// held back behind a "Show all" link instead of padding out the main list.
|
|
selectedPrinterName?: string | null;
|
|
compatIndex?: PrinterCompatibilityIndex;
|
|
}
|
|
|
|
function PresetDropdown({
|
|
label,
|
|
slot,
|
|
data,
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
swatchColor,
|
|
onSwatchColorChange,
|
|
swatchColorLabel,
|
|
selectedPrinterName,
|
|
compatIndex,
|
|
}: PresetDropdownProps) {
|
|
const { t } = useTranslation();
|
|
// Reveals the other-printer group for this slot only. Per-dropdown rather
|
|
// than modal-wide: wanting a filament from another printer's library says
|
|
// nothing about wanting its process profiles too.
|
|
const [showAll, setShowAll] = useState(false);
|
|
// Binds the label to the select now that they are siblings rather than
|
|
// nested. Filament slots render several of these, so the id must be unique
|
|
// per instance rather than derived from the slot name.
|
|
const selectId = useId();
|
|
// The colour input needs its own id so the hex beside it can be a <label>
|
|
// for it rather than for the preset <select> it sits next to.
|
|
const colourId = `${selectId}-colour`;
|
|
|
|
// Tier sections (imported → cloud → standard), plus — for a process /
|
|
// filament slot with a selected printer — a trailing group of presets that
|
|
// resolve to a different printer (#1325). Compatibility-unknown presets
|
|
// stay in their tier, so a custom / untagged preset is never hidden, and
|
|
// empty sections collapse out.
|
|
const { sections, otherEntries } = useMemo(() => {
|
|
const tiers: { key: keyof UnifiedPresetsResponse; label: string; fallback: string }[] = [
|
|
{ key: 'local', label: 'slice.tier.local', fallback: 'Imported' },
|
|
{ key: 'orca_cloud', label: 'slice.tier.orcaCloud', fallback: 'Orca Cloud' },
|
|
{ key: 'cloud', label: 'slice.tier.cloud', fallback: 'Bambu Cloud' },
|
|
{ key: 'standard', label: 'slice.tier.standard', fallback: 'Standard' },
|
|
];
|
|
const filterByPrinter = slot !== 'printer';
|
|
const compatSections: { tierLabel: string; entries: UnifiedPreset[] }[] = [];
|
|
// The same sections with the printer filter never applied, kept for the
|
|
// all-filtered-out case below.
|
|
const unfiltered: { tierLabel: string; entries: UnifiedPreset[] }[] = [];
|
|
const other: UnifiedPreset[] = [];
|
|
for (const { key, label: lk, fallback } of tiers) {
|
|
const entries = (data[key] as UnifiedPresetsBySlot)[slot];
|
|
if (entries.length > 0) unfiltered.push({ tierLabel: t(lk, fallback), entries });
|
|
if (!filterByPrinter) {
|
|
if (entries.length > 0) compatSections.push({ tierLabel: t(lk, fallback), entries });
|
|
continue;
|
|
}
|
|
const compatible: UnifiedPreset[] = [];
|
|
for (const p of entries) {
|
|
if (
|
|
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 {
|
|
compatible.push(p);
|
|
}
|
|
}
|
|
if (compatible.length > 0) {
|
|
compatSections.push({ tierLabel: t(lk, fallback), entries: compatible });
|
|
}
|
|
}
|
|
// Filtering that leaves nothing at all is a statement about our matching,
|
|
// not about the presets: a P1S has ten usable process presets and read as
|
|
// having none, because every one of them is named for an X1C and only says
|
|
// "P1S" in a `compatible_printers` list an older sidecar doesn't report
|
|
// (#2982). Hiding all of them left a dropdown holding one auto-picked entry
|
|
// and a "Show all" link, with no hint that the list was the problem. So
|
|
// when the filter empties the list, show it unfiltered — a visible preset
|
|
// for the wrong printer is recoverable, an empty dropdown is not.
|
|
if (compatSections.length === 0 && other.length > 0) {
|
|
return { sections: unfiltered, otherEntries: [] };
|
|
}
|
|
return { sections: compatSections, otherEntries: other };
|
|
}, [data, slot, t, selectedPrinterName, compatIndex]);
|
|
|
|
// Other-printer presets are held back by default so the list shows what is
|
|
// usable on the selected printer. Two things are never hidden: a preset whose
|
|
// compatibility is merely *unknown* (it never reaches otherEntries), and the
|
|
// one currently selected — a pipeline or an auto-pick can land on a
|
|
// cross-printer preset, and dropping it from the options would blank the
|
|
// select and silently discard the choice.
|
|
const selectedRefValue = toRefValue(value);
|
|
const visibleOther = useMemo(() => {
|
|
if (showAll) return otherEntries;
|
|
return otherEntries.filter((p) => `${p.source}:${p.id}` === selectedRefValue);
|
|
}, [showAll, otherEntries, selectedRefValue]);
|
|
|
|
const hiddenCount = otherEntries.length - visibleOther.length;
|
|
const totalEntries =
|
|
sections.reduce((sum, s) => sum + s.entries.length, 0) + visibleOther.length;
|
|
|
|
return (
|
|
// A plain wrapper rather than a <label> around everything: the "Show all"
|
|
// control is a button, and a button inside a label that also wraps the
|
|
// select inherits the whole label as its accessible name (screen readers
|
|
// announced it as "Process profile 2 hidden 0.20mm Standard @BBL X1C") as
|
|
// well as being invalid HTML. The label is bound to the select by id.
|
|
<div className="block">
|
|
<div className="flex items-center gap-2 text-xs text-bambu-gray mb-1">
|
|
{/* Read-only dot for slots with no editable colour. Filament rows
|
|
carry a real control beside the dropdown instead — see below. */}
|
|
{!onSwatchColorChange && swatchColor && (
|
|
<span
|
|
className="inline-block w-3 h-3 rounded-full border border-bambu-dark-tertiary"
|
|
style={{ backgroundColor: swatchColor }}
|
|
aria-hidden
|
|
/>
|
|
)}
|
|
<label htmlFor={selectId}>{label}</label>
|
|
{(hiddenCount > 0 || showAll) && (
|
|
<span className="ml-auto flex items-center gap-1.5 font-normal">
|
|
{hiddenCount > 0 && (
|
|
<span className="text-bambu-gray/60">
|
|
{t('slice.presetsHidden', '{{count}} hidden', { count: hiddenCount })}
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAll((v) => !v)}
|
|
disabled={disabled}
|
|
className="text-bambu-green hover:underline disabled:opacity-50 disabled:no-underline"
|
|
>
|
|
{showAll
|
|
? t('slice.showFewerPresets', 'Show fewer')
|
|
: t('slice.showAllPresets', 'Show all')}
|
|
</button>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-stretch gap-2">
|
|
<select
|
|
id={selectId}
|
|
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')
|
|
: t('slice.selectPreset')}
|
|
</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>
|
|
))}
|
|
{visibleOther.length > 0 && (
|
|
<optgroup label={t('slice.otherPrinters')}>
|
|
{visibleOther.map((p) => (
|
|
<option key={`${p.source}:${p.id}`} value={`${p.source}:${p.id}`}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</optgroup>
|
|
)}
|
|
</select>
|
|
{/* The colour control sits beside the dropdown, styled like it and the
|
|
same height, because that is what makes it read as a control at all.
|
|
Two earlier shapes did not: a bare swatch in the label row looked
|
|
exactly like the read-only dot multi-colour rows had carried for
|
|
releases, and adding the hex beside it only made it look like a
|
|
caption. On a single-filament STL — the one source with no colour to
|
|
inherit, and so the case this exists for — neither said anything was
|
|
settable. Clicking the label opens the picker, so the hex is a hit
|
|
target and not a caption. */}
|
|
{onSwatchColorChange && (
|
|
<label
|
|
htmlFor={colourId}
|
|
title={swatchColorLabel}
|
|
className={`flex items-center gap-2 px-2.5 rounded-md bg-bambu-dark border border-bambu-dark-tertiary text-sm ${
|
|
disabled
|
|
? 'opacity-50 cursor-not-allowed'
|
|
: 'cursor-pointer hover:border-bambu-gray transition-colors'
|
|
}`}
|
|
>
|
|
<input
|
|
id={colourId}
|
|
type="color"
|
|
value={colourInputValue(swatchColor)}
|
|
onChange={(e) => onSwatchColorChange(e.target.value.toUpperCase())}
|
|
disabled={disabled}
|
|
aria-label={swatchColorLabel}
|
|
// Painted explicitly as well as through the native swatch: every
|
|
// engine fills ::-webkit-color-swatch / ::-moz-color-swatch from
|
|
// the value, but one that did not would leave an empty ring, and
|
|
// an unlit swatch is indistinguishable from no swatch at all.
|
|
style={{ backgroundColor: colourInputValue(swatchColor) }}
|
|
className="slice-colour-swatch w-4 h-4 shrink-0 rounded-full border border-bambu-dark-tertiary p-0 disabled:cursor-not-allowed enabled:cursor-pointer"
|
|
/>
|
|
<span className="font-mono text-xs text-white tracking-tight">
|
|
{colourInputValue(swatchColor)}
|
|
</span>
|
|
</label>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|