mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Every file the internal slicer produced came back with filament_colour = #00AE42 whatever filament was picked: a green plate thumbnail, green metadata, and a "Color mismatch" in the Print dialog against the AMS slot the job had just been correctly mapped to. A colour is not a property of a filament preset in either slicer. It belongs to the project, and Bambu Studio and OrcaSlicer set it from the plate in their GUIs -- so nothing was attached to the preset Bambuddy sends by name, and the CLI fell back to its own compiled-in default, which is Bambu green. None of the shipped BBL filament profiles define filament_colour; the whole tree has zero occurrences. default_filament_colour is not the answer on its own. Measured against a 02.08.02.61 sidecar, a profile carrying only that still slices to filament_colour ["#00AE42"] -- Bambu Studio consumes it in the GUI when a project is created, not in --load-filaments. So it is read and rewritten as filament_colour, which the same sidecar does honour: the reporter's exact triplet returns ["#E8B00C"] when patched this way, and the colour lands in both project_settings.config and slice_info.config, which is what the thumbnail and the AMS mapping actually read. Each filament row in the slice dialog gets a colour control, and the resolved value flows through a chain: the user's pick, then the preset's own default_filament_colour, then the colour that slot was designed with in the source 3MF. A slot with none of the three is left untouched rather than given a guess. The designed colour is read from project_settings.config, not slice_info.config. The latter records what the file was last sliced with, which for a source that never carried a colour is #00AE42 itself -- measured -- so using it would have been circular. The control is offered on single-filament sources too, because an STL, and equally a mesh-only 3MF exported from CAD, has no colour anywhere else to inherit. That is the case this exists for, and it took three shapes to make it visible: a swatch in the label row was identical to the read-only dot multi-colour rows have carried for releases, and adding the hex beside it only made it look like a caption. It now sits beside the dropdown, styled like it and the same height, with the swatch and hex wrapped in one label bound to the input so a click anywhere on it opens the picker. An untouched slot with no designed colour submits an empty string rather than the control's displayed default. A sent colour outranks the preset's own, so pinning the placeholder would silently discard the real colour of an imported OrcaSlicer profile that carries one. Slicer Pipelines pick up the same chain without carrying a colour of their own. Also adds a warning for a defect found while investigating this: a filament preset whose name the sidecar's bundle cannot resolve is not rejected. The CLI inherits nothing, falls back to its defaults for every field, and returns a well-formed success -- measured, an unresolvable name slices as filament_type ["PLA"] at nozzle_temperature ["200"] with filament_ids [""] and filament_vendor ["(Undefined)"], so a PETG preset a sidecar image predates prints at PLA temperatures with no diagnostic anywhere. Both signals are required together, which keeps it off the two legitimate lookalikes: a hand-written profile that never named a vendor still carries a real filament id, and a user's own cloud preset carries a vendor while legitimately having no bundled id. The file is kept rather than refused, unlike the missing start G-code of whoever can see the temperatures.
1550 lines
71 KiB
TypeScript
1550 lines
71 KiB
TypeScript
import { Cloud, CloudOff, Cog, Loader2, RefreshCw, X } from 'lucide-react';
|
|
import { useEffect, useId, useMemo, 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,
|
|
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;
|
|
}
|
|
|
|
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 }: 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)[]>([]);
|
|
// 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(false);
|
|
|
|
// #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');
|
|
if (p && 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) =>
|
|
current.length === filamentSlots.length ? current : 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 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. */}
|
|
<div className="lg:grid lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] 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];
|
|
}
|
|
}
|
|
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) =>
|
|
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[] }[] = [];
|
|
const other: UnifiedPreset[] = [];
|
|
for (const { key, label: lk, fallback } of tiers) {
|
|
const entries = (data[key] as UnifiedPresetsBySlot)[slot];
|
|
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 });
|
|
}
|
|
}
|
|
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>
|
|
);
|
|
}
|