|
@@ -21,10 +21,17 @@ import {
|
|
|
} from 'lucide-react';
|
|
} from 'lucide-react';
|
|
|
import { api } from '../api/client';
|
|
import { api } from '../api/client';
|
|
|
import type { KProfile, KProfileCreate, KProfileDelete, Permission } from '../api/client';
|
|
import type { KProfile, KProfileCreate, KProfileDelete, Permission } from '../api/client';
|
|
|
|
|
+import {
|
|
|
|
|
+ buildFilamentPresetOptions,
|
|
|
|
|
+ resolveFilamentId,
|
|
|
|
|
+ type FilamentPresetOption,
|
|
|
|
|
+ type FilamentPresetSource,
|
|
|
|
|
+} from '../utils/filamentPresets';
|
|
|
import { Card, CardContent } from './Card';
|
|
import { Card, CardContent } from './Card';
|
|
|
import { Button } from './Button';
|
|
import { Button } from './Button';
|
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
|
|
|
+import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
|
|
|
|
|
|
|
|
interface KProfileCardProps {
|
|
interface KProfileCardProps {
|
|
|
profile: KProfile;
|
|
profile: KProfile;
|
|
@@ -42,18 +49,26 @@ const truncateK = (value: string) => {
|
|
|
return (Math.trunc(num * 1000) / 1000).toFixed(3);
|
|
return (Math.trunc(num * 1000) / 1000).toFixed(3);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-// Get flow type label from nozzle_id (e.g., "HH00-0.4" -> "HF", "HS00-0.4" -> "S")
|
|
|
|
|
-const getFlowTypeLabel = (nozzleId: string) => {
|
|
|
|
|
- if (nozzleId.startsWith('HH')) return 'HF'; // High Flow
|
|
|
|
|
- return 'S'; // Standard Flow (default)
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-// Extract nozzle type prefix from nozzle_id (e.g., "HH00-0.4" -> "HH00")
|
|
|
|
|
|
|
+// nozzle_id encodes the flow type, per the slicer's own generator:
|
|
|
|
|
+// "H" + (Standard ? "S" : "H") + "00" + "-" + diameter
|
|
|
|
|
+// so "HS00-0.4" is Standard and "HH00-0.4" is High Flow. The "00" is a literal,
|
|
|
|
|
+// not a material code.
|
|
|
|
|
+const STANDARD_FLOW = 'HS00';
|
|
|
|
|
+const HIGH_FLOW = 'HH00';
|
|
|
|
|
+
|
|
|
|
|
+// Many printers omit nozzle_id from their extrusion_cali_get response entirely
|
|
|
|
|
+// (#1748) — the field simply isn't in the payload. BambuStudio treats that as
|
|
|
|
|
+// Standard (its parser falls back to nvtStandard when the key is absent), and
|
|
|
|
|
+// so do we: the flow type stays a real, editable value rather than a blank.
|
|
|
const getNozzleTypePrefix = (nozzleId: string) => {
|
|
const getNozzleTypePrefix = (nozzleId: string) => {
|
|
|
const match = nozzleId.match(/^([A-Z]{2}\d{2})/);
|
|
const match = nozzleId.match(/^([A-Z]{2}\d{2})/);
|
|
|
- return match ? match[1] : 'HH00';
|
|
|
|
|
|
|
+ return match ? match[1] : STANDARD_FLOW;
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+// Short label for the profile list.
|
|
|
|
|
+const getFlowTypeLabel = (nozzleId: string) =>
|
|
|
|
|
+ getNozzleTypePrefix(nozzleId) === HIGH_FLOW ? 'HF' : 'S';
|
|
|
|
|
+
|
|
|
// Extract filament name from profile name (e.g., "High Flow_Devil Design PLA Basic" -> "Devil Design PLA Basic")
|
|
// Extract filament name from profile name (e.g., "High Flow_Devil Design PLA Basic" -> "Devil Design PLA Basic")
|
|
|
const extractFilamentName = (profileName: string) => {
|
|
const extractFilamentName = (profileName: string) => {
|
|
|
// Profile names are formatted as "{Flow Type}_{Filament Name}" or "{Flow Type} {Filament Name}"
|
|
// Profile names are formatted as "{Flow Type}_{Filament Name}" or "{Flow Type} {Filament Name}"
|
|
@@ -149,9 +164,11 @@ interface KProfileModalProps {
|
|
|
profile?: KProfile;
|
|
profile?: KProfile;
|
|
|
printerId: number;
|
|
printerId: number;
|
|
|
nozzleDiameter: string;
|
|
nozzleDiameter: string;
|
|
|
- existingProfiles?: KProfile[]; // Existing profiles for filament selection
|
|
|
|
|
|
|
+ existingProfiles?: KProfile[]; // Existing profiles, used for name resolution
|
|
|
builtinFilaments?: { filament_id: string; name: string }[]; // Filament ID → name lookup
|
|
builtinFilaments?: { filament_id: string; name: string }[]; // Filament ID → name lookup
|
|
|
|
|
+ filamentPresets?: FilamentPresetOption[]; // Every filament this install knows, tiered
|
|
|
isDualNozzle?: boolean; // Whether this is a dual-nozzle printer
|
|
isDualNozzle?: boolean; // Whether this is a dual-nozzle printer
|
|
|
|
|
+ supportsFlowType?: boolean; // Model sells both Standard and High Flow nozzles
|
|
|
initialNote?: string; // Initial note value for the profile
|
|
initialNote?: string; // Initial note value for the profile
|
|
|
initialNoteKey?: string | null; // Key the note was stored under (for clearing)
|
|
initialNoteKey?: string | null; // Key the note was stored under (for clearing)
|
|
|
onClose: () => void;
|
|
onClose: () => void;
|
|
@@ -166,7 +183,9 @@ function KProfileModal({
|
|
|
nozzleDiameter,
|
|
nozzleDiameter,
|
|
|
existingProfiles = [],
|
|
existingProfiles = [],
|
|
|
builtinFilaments = [],
|
|
builtinFilaments = [],
|
|
|
|
|
+ filamentPresets = [],
|
|
|
isDualNozzle = false,
|
|
isDualNozzle = false,
|
|
|
|
|
+ supportsFlowType = true,
|
|
|
initialNote = '',
|
|
initialNote = '',
|
|
|
initialNoteKey = null,
|
|
initialNoteKey = null,
|
|
|
onClose,
|
|
onClose,
|
|
@@ -181,10 +200,19 @@ function KProfileModal({
|
|
|
const [kValue, setKValue] = useState(
|
|
const [kValue, setKValue] = useState(
|
|
|
profile?.k_value ? truncateK(profile.k_value) : '0.020'
|
|
profile?.k_value ? truncateK(profile.k_value) : '0.020'
|
|
|
);
|
|
);
|
|
|
- const [filamentId, setFilamentId] = useState(profile?.filament_id || '');
|
|
|
|
|
|
|
+ // What the Filament select is bound to. When editing, the printer's own
|
|
|
|
|
+ // filament_id (the select is read-only). For a new profile, the *preset
|
|
|
|
|
+ // handle* from the tiered list — a local row id, an Orca UUID, a Bambu Cloud
|
|
|
|
|
+ // setting_id or a builtin filament id — which is resolved to a real
|
|
|
|
|
+ // filament_id on submit, since only some tiers carry one directly.
|
|
|
|
|
+ const [filamentChoice, setFilamentChoice] = useState(profile?.filament_id || '');
|
|
|
// Split nozzle into type and diameter
|
|
// Split nozzle into type and diameter
|
|
|
|
|
+ // Both selects are read-only while editing: they report what the printer
|
|
|
|
|
+ // holds, they don't set it. '' means the printer reported no nozzle_id, which
|
|
|
|
|
+ // single-nozzle models never do (#1748) — showing "High Flow" there was the
|
|
|
|
|
+ // UI inventing a value the printer never sent.
|
|
|
const [nozzleType, setNozzleType] = useState(
|
|
const [nozzleType, setNozzleType] = useState(
|
|
|
- profile?.nozzle_id ? getNozzleTypePrefix(profile.nozzle_id) : 'HH00'
|
|
|
|
|
|
|
+ profile ? getNozzleTypePrefix(profile.nozzle_id) : STANDARD_FLOW
|
|
|
);
|
|
);
|
|
|
const [modalDiameter, setModalDiameter] = useState(
|
|
const [modalDiameter, setModalDiameter] = useState(
|
|
|
profile?.nozzle_diameter || nozzleDiameter
|
|
profile?.nozzle_diameter || nozzleDiameter
|
|
@@ -197,40 +225,49 @@ function KProfileModal({
|
|
|
const [isSyncing, setIsSyncing] = useState(false);
|
|
const [isSyncing, setIsSyncing] = useState(false);
|
|
|
const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
|
|
const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
|
|
|
const [note, setNote] = useState(initialNote);
|
|
const [note, setNote] = useState(initialNote);
|
|
|
-
|
|
|
|
|
- // Extract unique filaments from existing K-profiles on the printer
|
|
|
|
|
- // Use builtin filament table for accurate name resolution (filament_id → name)
|
|
|
|
|
- // Falls back to extracting from profile name for custom/unknown presets
|
|
|
|
|
- const knownFilaments = React.useMemo(() => {
|
|
|
|
|
- // Build lookup map from builtin filament names (includes cloud presets from parent)
|
|
|
|
|
- const builtinMap = new Map<string, string>();
|
|
|
|
|
- for (const bf of builtinFilaments) {
|
|
|
|
|
- builtinMap.set(bf.filament_id, bf.name);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const filamentMap = new Map<string, { id: string; name: string }>();
|
|
|
|
|
- for (const p of existingProfiles) {
|
|
|
|
|
- if (p.filament_id && !filamentMap.has(p.filament_id)) {
|
|
|
|
|
- // Prefer builtin name (accurate), fall back to extracting from profile name
|
|
|
|
|
- const builtinName = builtinMap.get(p.filament_id);
|
|
|
|
|
- const filamentName = builtinName || extractFilamentName(p.name || '');
|
|
|
|
|
- filamentMap.set(p.filament_id, {
|
|
|
|
|
- id: p.filament_id,
|
|
|
|
|
- name: filamentName || p.filament_id,
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- return Array.from(filamentMap.values()).sort((a, b) =>
|
|
|
|
|
- a.name.localeCompare(b.name)
|
|
|
|
|
- );
|
|
|
|
|
- }, [existingProfiles, builtinFilaments]);
|
|
|
|
|
|
|
+ const [filamentQuery, setFilamentQuery] = useState('');
|
|
|
|
|
+ // The modal defers its own close so the printer has time to process the
|
|
|
|
|
+ // command; that timer must not outlive the modal.
|
|
|
|
|
+ const { schedule: scheduleClose } = useCancellableTimeout();
|
|
|
|
|
+
|
|
|
|
|
+ // Name for the filament an existing profile is bound to. The builtin table
|
|
|
|
|
+ // (which the parent has already merged with the user's cloud presets) is
|
|
|
|
|
+ // authoritative; a profile whose filament_id is in neither falls back to the
|
|
|
|
|
+ // name the printer stored for it.
|
|
|
|
|
+ const editedFilamentName = React.useMemo(() => {
|
|
|
|
|
+ if (!profile?.filament_id) return '';
|
|
|
|
|
+ const builtinName = builtinFilaments.find(bf => bf.filament_id === profile.filament_id)?.name;
|
|
|
|
|
+ if (builtinName) return builtinName;
|
|
|
|
|
+ const fromProfile = existingProfiles.find(p => p.filament_id === profile.filament_id);
|
|
|
|
|
+ return extractFilamentName(fromProfile?.name || profile.name || '') || profile.filament_id;
|
|
|
|
|
+ }, [profile, existingProfiles, builtinFilaments]);
|
|
|
|
|
+
|
|
|
|
|
+ // The tiered list, grouped for rendering. Order is fixed app-wide —
|
|
|
|
|
+ // imported, then Orca Cloud, then Bambu Cloud, then the hardcoded table —
|
|
|
|
|
+ // and buildFilamentPresetOptions has already sorted by it, so grouping is
|
|
|
|
|
+ // just a partition that preserves that order.
|
|
|
|
|
+ const presetGroups = React.useMemo(() => {
|
|
|
|
|
+ const labels: [FilamentPresetSource, string][] = [
|
|
|
|
|
+ ['local', t('kProfiles.modal.source.local')],
|
|
|
|
|
+ ['orca_cloud', t('kProfiles.modal.source.orcaCloud')],
|
|
|
|
|
+ ['cloud', t('kProfiles.modal.source.bambuCloud')],
|
|
|
|
|
+ ['builtin', t('kProfiles.modal.source.builtin')],
|
|
|
|
|
+ ];
|
|
|
|
|
+ const query = filamentQuery.trim().toLowerCase();
|
|
|
|
|
+ const matches = query
|
|
|
|
|
+ ? filamentPresets.filter(p => p.name.toLowerCase().includes(query))
|
|
|
|
|
+ : filamentPresets;
|
|
|
|
|
+ return labels
|
|
|
|
|
+ .map(([source, label]) => ({ source, label, items: matches.filter(p => p.source === source) }))
|
|
|
|
|
+ .filter(g => g.items.length > 0);
|
|
|
|
|
+ }, [filamentPresets, filamentQuery, t]);
|
|
|
|
|
|
|
|
const saveMutation = useMutation({
|
|
const saveMutation = useMutation({
|
|
|
mutationFn: (data: KProfileCreate) => {
|
|
mutationFn: (data: KProfileCreate) => {
|
|
|
console.log('[KProfile] Calling API...');
|
|
console.log('[KProfile] Calling API...');
|
|
|
return api.setKProfile(printerId, data);
|
|
return api.setKProfile(printerId, data);
|
|
|
},
|
|
},
|
|
|
- onSuccess: (result) => {
|
|
|
|
|
|
|
+ onSuccess: (result, variables) => {
|
|
|
console.log('[KProfile] Save success:', result);
|
|
console.log('[KProfile] Save success:', result);
|
|
|
showToast(t('kProfiles.toast.profileSaved'));
|
|
showToast(t('kProfiles.toast.profileSaved'));
|
|
|
// Save note if it changed (including clearing it)
|
|
// Save note if it changed (including clearing it)
|
|
@@ -243,8 +280,10 @@ function KProfileModal({
|
|
|
// Editing: use setting_id if available, or composite key with slot_id
|
|
// Editing: use setting_id if available, or composite key with slot_id
|
|
|
profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`;
|
|
profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`;
|
|
|
} else {
|
|
} else {
|
|
|
- // New profile: use name as key (will be matched when profile is loaded)
|
|
|
|
|
- profileKey = `name_${name}_${filamentId}`;
|
|
|
|
|
|
|
+ // New profile: use name as key (matched against the reloaded profile,
|
|
|
|
|
+ // so it has to be the resolved filament_id that was sent — not the
|
|
|
|
|
+ // preset handle the user picked).
|
|
|
|
|
+ profileKey = `name_${name}_${variables.filament_id}`;
|
|
|
}
|
|
}
|
|
|
onSaveNote(profileKey, note);
|
|
onSaveNote(profileKey, note);
|
|
|
}
|
|
}
|
|
@@ -252,7 +291,7 @@ function KProfileModal({
|
|
|
setIsSyncing(true);
|
|
setIsSyncing(true);
|
|
|
// Add delay before closing to give printer time to process the save
|
|
// Add delay before closing to give printer time to process the save
|
|
|
// onSave will trigger refetch in the parent component
|
|
// onSave will trigger refetch in the parent component
|
|
|
- setTimeout(() => {
|
|
|
|
|
|
|
+ scheduleClose(() => {
|
|
|
setIsSyncing(false);
|
|
setIsSyncing(false);
|
|
|
onSave();
|
|
onSave();
|
|
|
}, 2500);
|
|
}, 2500);
|
|
@@ -276,7 +315,7 @@ function KProfileModal({
|
|
|
setIsSyncing(true);
|
|
setIsSyncing(true);
|
|
|
// Add longer delay for delete - printer needs more time to process
|
|
// Add longer delay for delete - printer needs more time to process
|
|
|
// before it can return the updated profile list
|
|
// before it can return the updated profile list
|
|
|
- setTimeout(() => {
|
|
|
|
|
|
|
+ scheduleClose(() => {
|
|
|
setIsSyncing(false);
|
|
setIsSyncing(false);
|
|
|
onClose();
|
|
onClose();
|
|
|
}, 4000);
|
|
}, 4000);
|
|
@@ -316,14 +355,46 @@ function KProfileModal({
|
|
|
// Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4")
|
|
// Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4")
|
|
|
const nozzleId = `${nozzleType}-${modalDiameter}`;
|
|
const nozzleId = `${nozzleType}-${modalDiameter}`;
|
|
|
|
|
|
|
|
|
|
+ // An edit is delete + re-add on single-nozzle printers, so the nozzle
|
|
|
|
|
+ // fields have to survive the round trip — both selects are disabled while
|
|
|
|
|
+ // editing. Rebuilding them blindly from the selects is what let a 0.6mm
|
|
|
|
|
+ // profile come back as "HH00-0.4" once the parse defaults had stamped it
|
|
|
|
|
+ // 0.4 (#1748), so prefer whatever the printer reported. Where it reported
|
|
|
|
|
+ // no nozzle_id at all, send the rebuilt one rather than an empty string —
|
|
|
|
|
+ // the field is part of the profile's identity on the wire and the slicer
|
|
|
|
|
+ // always populates it.
|
|
|
|
|
+ const editNozzleId = profile ? profile.nozzle_id || nozzleId : nozzleId;
|
|
|
|
|
+ const editDiameter = profile ? profile.nozzle_diameter : modalDiameter;
|
|
|
|
|
+
|
|
|
|
|
+ // The printer indexes its calibration table by filament_id, so the preset
|
|
|
|
|
+ // the user picked has to be reduced to one before anything is sent. Only
|
|
|
|
|
+ // the builtin tier and Bambu's official cloud presets carry one outright;
|
|
|
|
|
+ // a cloud *user* preset needs its detail fetched, and imported / Orca
|
|
|
|
|
+ // presets have no Bambu id at all and map to the generic for their
|
|
|
|
|
+ // material. Refuse rather than guess when nothing resolves — a profile
|
|
|
|
|
+ // filed under the wrong filament is invisible to the slot that needs it.
|
|
|
|
|
+ let resolvedFilamentId = profile?.filament_id || '';
|
|
|
|
|
+ if (!profile) {
|
|
|
|
|
+ const picked = filamentPresets.find(p => p.id === filamentChoice);
|
|
|
|
|
+ if (!picked) {
|
|
|
|
|
+ showToast(t('kProfiles.toast.selectFilament'), 'error');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ resolvedFilamentId = await resolveFilamentId(picked, api.getCloudSettingDetail);
|
|
|
|
|
+ if (!resolvedFilamentId) {
|
|
|
|
|
+ showToast(t('kProfiles.toast.filamentNotResolvable', { name: picked.name }), 'error');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// For editing or single extruder: just save one profile
|
|
// For editing or single extruder: just save one profile
|
|
|
if (profile || selectedExtruders.length === 1) {
|
|
if (profile || selectedExtruders.length === 1) {
|
|
|
const payload = {
|
|
const payload = {
|
|
|
name: name,
|
|
name: name,
|
|
|
k_value: formattedKValue,
|
|
k_value: formattedKValue,
|
|
|
- filament_id: filamentId,
|
|
|
|
|
- nozzle_id: nozzleId,
|
|
|
|
|
- nozzle_diameter: modalDiameter,
|
|
|
|
|
|
|
+ filament_id: resolvedFilamentId,
|
|
|
|
|
+ nozzle_id: editNozzleId,
|
|
|
|
|
+ nozzle_diameter: editDiameter,
|
|
|
extruder_id: profile ? profile.extruder_id : selectedExtruders[0],
|
|
extruder_id: profile ? profile.extruder_id : selectedExtruders[0],
|
|
|
setting_id: profile?.setting_id,
|
|
setting_id: profile?.setting_id,
|
|
|
slot_id: profile?.slot_id ?? 0,
|
|
slot_id: profile?.slot_id ?? 0,
|
|
@@ -341,7 +412,7 @@ function KProfileModal({
|
|
|
const batchPayload = selectedExtruders.map(extruderId => ({
|
|
const batchPayload = selectedExtruders.map(extruderId => ({
|
|
|
name: name,
|
|
name: name,
|
|
|
k_value: formattedKValue,
|
|
k_value: formattedKValue,
|
|
|
- filament_id: filamentId,
|
|
|
|
|
|
|
+ filament_id: resolvedFilamentId,
|
|
|
nozzle_id: nozzleId,
|
|
nozzle_id: nozzleId,
|
|
|
nozzle_diameter: modalDiameter,
|
|
nozzle_diameter: modalDiameter,
|
|
|
extruder_id: extruderId,
|
|
extruder_id: extruderId,
|
|
@@ -356,7 +427,7 @@ function KProfileModal({
|
|
|
showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
|
|
showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
|
|
|
// Save note for new batch profiles
|
|
// Save note for new batch profiles
|
|
|
if (onSaveNote && note) {
|
|
if (onSaveNote && note) {
|
|
|
- const profileKey = `name_${name}_${filamentId}`;
|
|
|
|
|
|
|
+ const profileKey = `name_${name}_${resolvedFilamentId}`;
|
|
|
onSaveNote(profileKey, note);
|
|
onSaveNote(profileKey, note);
|
|
|
}
|
|
}
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
@@ -370,7 +441,7 @@ function KProfileModal({
|
|
|
setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length });
|
|
setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length });
|
|
|
// Wait for final sync before closing
|
|
// Wait for final sync before closing
|
|
|
// onSave will trigger refetch in the parent component
|
|
// onSave will trigger refetch in the parent component
|
|
|
- setTimeout(() => {
|
|
|
|
|
|
|
+ scheduleClose(() => {
|
|
|
setIsSyncing(false);
|
|
setIsSyncing(false);
|
|
|
setSavingProgress({ current: 0, total: 0 });
|
|
setSavingProgress({ current: 0, total: 0 });
|
|
|
onSave();
|
|
onSave();
|
|
@@ -454,49 +525,77 @@ function KProfileModal({
|
|
|
{/* Filament - read-only when editing */}
|
|
{/* Filament - read-only when editing */}
|
|
|
<div>
|
|
<div>
|
|
|
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.filament')}</label>
|
|
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.filament')}</label>
|
|
|
- <select
|
|
|
|
|
- value={filamentId}
|
|
|
|
|
- onChange={(e) => {
|
|
|
|
|
- const newFilamentId = e.target.value;
|
|
|
|
|
- setFilamentId(newFilamentId);
|
|
|
|
|
- // Auto-generate profile name when filament is selected (for new profiles)
|
|
|
|
|
- // Only auto-generate if name is empty - don't overwrite user input
|
|
|
|
|
- if (!profile && newFilamentId && !name) {
|
|
|
|
|
- const selectedFilament = knownFilaments.find(f => f.id === newFilamentId);
|
|
|
|
|
- if (selectedFilament) {
|
|
|
|
|
- const flowLabel = nozzleType === 'HH00' ? 'HF' : 'S';
|
|
|
|
|
- setName(`${flowLabel} ${selectedFilament.name}`);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }}
|
|
|
|
|
- disabled={!!profile}
|
|
|
|
|
- className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
|
|
|
|
|
- required={!profile}
|
|
|
|
|
- >
|
|
|
|
|
- <option value="">{t('kProfiles.modal.selectFilament')}</option>
|
|
|
|
|
- {/* Show current filament when editing - look up from knownFilaments */}
|
|
|
|
|
- {profile?.filament_id && (
|
|
|
|
|
- <option key={profile.filament_id} value={profile.filament_id}>
|
|
|
|
|
- {knownFilaments.find(f => f.id === profile.filament_id)?.name || profile.filament_id}
|
|
|
|
|
- </option>
|
|
|
|
|
- )}
|
|
|
|
|
- {/* Show known filaments from existing K-profiles (for new profiles) */}
|
|
|
|
|
- {!profile && knownFilaments.map((f) => (
|
|
|
|
|
- <option key={f.id} value={f.id}>
|
|
|
|
|
- {f.name}
|
|
|
|
|
- </option>
|
|
|
|
|
- ))}
|
|
|
|
|
- </select>
|
|
|
|
|
- {!profile && knownFilaments.length === 0 && (
|
|
|
|
|
- <p className="text-xs text-bambu-gray mt-1">
|
|
|
|
|
- {t('kProfiles.modal.noFilamentsHelp')}
|
|
|
|
|
- </p>
|
|
|
|
|
|
|
+ {profile ? (
|
|
|
|
|
+ // Editing or copying: the filament is fixed, so this is a
|
|
|
|
|
+ // readout rather than a control.
|
|
|
|
|
+ <div className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60">
|
|
|
|
|
+ {editedFilamentName || profile.filament_id}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ // A real list rather than a <select>: Chrome ignores almost
|
|
|
|
|
+ // every CSS property on <optgroup>, so a source heading inside
|
|
|
|
|
+ // a native dropdown can't be made to stand out.
|
|
|
|
|
+ <div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
|
|
|
|
|
+ <div className="relative border-b border-bambu-dark-tertiary">
|
|
|
|
|
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={filamentQuery}
|
|
|
|
|
+ onChange={(e) => setFilamentQuery(e.target.value)}
|
|
|
|
|
+ placeholder={t('kProfiles.modal.searchFilaments')}
|
|
|
|
|
+ className="w-full pl-10 pr-3 py-2 bg-bambu-dark text-white placeholder-bambu-gray focus:outline-none"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="max-h-56 overflow-y-auto bg-bambu-dark">
|
|
|
|
|
+ {presetGroups.length === 0 ? (
|
|
|
|
|
+ <p className="px-3 py-3 text-xs text-bambu-gray">
|
|
|
|
|
+ {filamentPresets.length === 0
|
|
|
|
|
+ ? t('kProfiles.modal.noFilamentsHelp')
|
|
|
|
|
+ : t('kProfiles.modal.noFilamentMatches')}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ ) : presetGroups.map((group) => (
|
|
|
|
|
+ <div key={group.source}>
|
|
|
|
|
+ <div className="sticky top-0 z-10 flex items-center gap-2 px-3 py-1.5 bg-bambu-dark-secondary border-y border-bambu-dark-tertiary">
|
|
|
|
|
+ <span className="text-xs font-bold uppercase tracking-wider text-bambu-green">
|
|
|
|
|
+ {group.label}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span className="text-[10px] text-bambu-gray">{group.items.length}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {group.items.map((f) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={f.id}
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ setFilamentChoice(f.id);
|
|
|
|
|
+ // Auto-generate the profile name, but never over
|
|
|
|
|
+ // an entry the user typed.
|
|
|
|
|
+ if (!name) {
|
|
|
|
|
+ const flowLabel = nozzleType === HIGH_FLOW ? 'HF' : 'S';
|
|
|
|
|
+ setName(`${flowLabel} ${f.name}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ className={`w-full text-left px-3 py-1.5 text-sm transition-colors ${
|
|
|
|
|
+ filamentChoice === f.id
|
|
|
|
|
+ ? 'bg-bambu-green/20 text-white'
|
|
|
|
|
+ : 'text-white hover:bg-bambu-dark-tertiary'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {f.name}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Flow Type and Nozzle Size - read-only when editing */}
|
|
|
|
|
- <div className="grid grid-cols-2 gap-4">
|
|
|
|
|
- <div>
|
|
|
|
|
|
|
+ {/* Flow Type and Nozzle Size - read-only when editing. Flow type
|
|
|
|
|
+ is hidden on models sold with a single nozzle variant (the
|
|
|
|
|
+ A-series), where the choice would be meaningless — same gate
|
|
|
|
|
+ the slicer applies via support_nozzle_volume(). */}
|
|
|
|
|
+ <div className={supportsFlowType ? 'grid grid-cols-2 gap-4' : ''}>
|
|
|
|
|
+ <div className={supportsFlowType ? '' : 'hidden'}>
|
|
|
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.flowType')}</label>
|
|
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.flowType')}</label>
|
|
|
<select
|
|
<select
|
|
|
value={nozzleType}
|
|
value={nozzleType}
|
|
@@ -505,10 +604,10 @@ function KProfileModal({
|
|
|
setNozzleType(newNozzleType);
|
|
setNozzleType(newNozzleType);
|
|
|
// Update profile name when flow type changes (for new profiles)
|
|
// Update profile name when flow type changes (for new profiles)
|
|
|
// Only auto-generate if name is empty - don't overwrite user input
|
|
// Only auto-generate if name is empty - don't overwrite user input
|
|
|
- if (!profile && filamentId && !name) {
|
|
|
|
|
- const selectedFilament = knownFilaments.find(f => f.id === filamentId);
|
|
|
|
|
|
|
+ if (!profile && filamentChoice && !name) {
|
|
|
|
|
+ const selectedFilament = filamentPresets.find(f => f.id === filamentChoice);
|
|
|
if (selectedFilament) {
|
|
if (selectedFilament) {
|
|
|
- const flowLabel = newNozzleType === 'HS00' ? 'HF' : 'S';
|
|
|
|
|
|
|
+ const flowLabel = newNozzleType === HIGH_FLOW ? 'HF' : 'S';
|
|
|
setName(`${flowLabel} ${selectedFilament.name}`);
|
|
setName(`${flowLabel} ${selectedFilament.name}`);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -516,8 +615,8 @@ function KProfileModal({
|
|
|
disabled={!!profile}
|
|
disabled={!!profile}
|
|
|
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
|
|
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
|
|
|
>
|
|
>
|
|
|
- <option value="HH00">{t('kProfiles.modal.highFlow')}</option>
|
|
|
|
|
- <option value="HS00">{t('kProfiles.modal.standard')}</option>
|
|
|
|
|
|
|
+ <option value={HIGH_FLOW}>{t('kProfiles.modal.highFlow')}</option>
|
|
|
|
|
+ <option value={STANDARD_FLOW}>{t('kProfiles.modal.standard')}</option>
|
|
|
</select>
|
|
</select>
|
|
|
</div>
|
|
</div>
|
|
|
<div>
|
|
<div>
|
|
@@ -772,13 +871,12 @@ export function KProfilesView() {
|
|
|
refetchOnMount: 'always', // Always refetch when component mounts
|
|
refetchOnMount: 'always', // Always refetch when component mounts
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // Also fetch 0.4mm profiles for the filament dropdown (most filaments are calibrated for 0.4mm)
|
|
|
|
|
- const { data: allProfiles } = useQuery({
|
|
|
|
|
- queryKey: ['kprofiles', selectedPrinter, '0.4'],
|
|
|
|
|
- queryFn: () => api.getKProfiles(selectedPrinter!, '0.4'),
|
|
|
|
|
- enabled: !!selectedPrinter,
|
|
|
|
|
- staleTime: 60000, // Cache for 1 minute
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ // A second fetch for 0.4mm profiles used to seed the Add-Profile filament
|
|
|
|
|
+ // dropdown. The dropdown is built from the filament preset tiers now
|
|
|
|
|
+ // (#2719), so the round trip bought nothing — and it fired concurrently
|
|
|
|
|
+ // with the fetch above whenever a different nozzle was selected, which is
|
|
|
|
|
+ // exactly the two-requests-in-flight case that made K-profile fetches time
|
|
|
|
|
+ // out (#1748).
|
|
|
|
|
|
|
|
// Fetch builtin filament names for accurate filament_id → name resolution
|
|
// Fetch builtin filament names for accurate filament_id → name resolution
|
|
|
const { data: builtinFilaments } = useQuery({
|
|
const { data: builtinFilaments } = useQuery({
|
|
@@ -794,6 +892,28 @@ export function KProfilesView() {
|
|
|
staleTime: 300000, // Cache for 5 minutes
|
|
staleTime: 300000, // Cache for 5 minutes
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // The other three filament tiers, so a printer with no K-profiles yet can
|
|
|
|
|
+ // still be given its first one (#2719). Each query stands alone and fails
|
|
|
|
|
+ // quietly: not being signed in to a cloud should thin the list, not break
|
|
|
|
|
+ // the page, and the builtin tier above guarantees it is never empty.
|
|
|
|
|
+ const { data: localPresets } = useQuery({
|
|
|
|
|
+ queryKey: ['localPresets'],
|
|
|
|
|
+ queryFn: () => api.getLocalPresets(),
|
|
|
|
|
+ retry: false,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const { data: orcaCloudList } = useQuery({
|
|
|
|
|
+ queryKey: ['orcaCloudProfilesForKProfiles'],
|
|
|
|
|
+ queryFn: () => api.orcaCloudListProfiles(),
|
|
|
|
|
+ retry: false,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const { data: cloudSettings } = useQuery({
|
|
|
|
|
+ queryKey: ['cloudSettings'],
|
|
|
|
|
+ queryFn: () => api.getCloudSettings(),
|
|
|
|
|
+ retry: false,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
// Fetch K-profile notes (stored locally)
|
|
// Fetch K-profile notes (stored locally)
|
|
|
const {
|
|
const {
|
|
|
data: notesData,
|
|
data: notesData,
|
|
@@ -860,6 +980,19 @@ export function KProfilesView() {
|
|
|
}));
|
|
}));
|
|
|
}, [builtinFilamentMap]);
|
|
}, [builtinFilamentMap]);
|
|
|
|
|
|
|
|
|
|
+ // Every filament this install knows about, ranked in the app-wide order:
|
|
|
|
|
+ // imported presets, then Orca Cloud, then Bambu Cloud, then the hardcoded
|
|
|
|
|
+ // built-in table as the floor.
|
|
|
|
|
+ const filamentPresets = React.useMemo(
|
|
|
|
|
+ () => buildFilamentPresetOptions({
|
|
|
|
|
+ localPresets: localPresets?.filament,
|
|
|
|
|
+ orcaProfiles: orcaCloudList?.filament,
|
|
|
|
|
+ cloudSettings: cloudSettings?.filament,
|
|
|
|
|
+ builtinFilaments,
|
|
|
|
|
+ }),
|
|
|
|
|
+ [localPresets?.filament, orcaCloudList?.filament, cloudSettings?.filament, builtinFilaments]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
// Resolve filament name: builtin table first, then extract from profile name
|
|
// Resolve filament name: builtin table first, then extract from profile name
|
|
|
const resolveFilamentName = React.useCallback((profile: KProfile) => {
|
|
const resolveFilamentName = React.useCallback((profile: KProfile) => {
|
|
|
return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name);
|
|
return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name);
|
|
@@ -911,6 +1044,18 @@ export function KProfilesView() {
|
|
|
const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
|
|
const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
|
|
|
const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
|
|
const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
|
|
|
|
|
|
|
|
|
|
+ // Whether this printer model is sold with both Standard and High Flow
|
|
|
|
|
+ // nozzles. Comes from the model, not from whether the payload happened to
|
|
|
|
|
+ // carry a nozzle_id — most printers omit that field entirely (#1748) while
|
|
|
|
|
+ // still offering both flows. Only the A-series has a single variant.
|
|
|
|
|
+ const supportsFlowType = selectedPrinterData?.supports_nozzle_flow_type ?? true;
|
|
|
|
|
+
|
|
|
|
|
+ // Don't strand the list behind a filter whose control just disappeared.
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!supportsFlowType) setFlowTypeFilter('all');
|
|
|
|
|
+ }, [supportsFlowType]);
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
// Keyboard shortcuts
|
|
// Keyboard shortcuts
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
@@ -1002,7 +1147,10 @@ export function KProfilesView() {
|
|
|
name: p.name,
|
|
name: p.name,
|
|
|
k_value: parseFloat(p.k_value).toFixed(6),
|
|
k_value: parseFloat(p.k_value).toFixed(6),
|
|
|
filament_id: p.filament_id,
|
|
filament_id: p.filament_id,
|
|
|
- nozzle_id: p.nozzle_id || `HH00-${nozzleDiameter}`,
|
|
|
|
|
|
|
+ // An export from a printer that reports no nozzle_id carries
|
|
|
|
|
+ // none; fall back to Standard, the same default the slicer's
|
|
|
|
|
+ // parser uses for a missing field.
|
|
|
|
|
+ nozzle_id: p.nozzle_id || `${STANDARD_FLOW}-${nozzleDiameter}`,
|
|
|
nozzle_diameter: p.nozzle_diameter || nozzleDiameter,
|
|
nozzle_diameter: p.nozzle_diameter || nozzleDiameter,
|
|
|
extruder_id: p.extruder_id ?? 0,
|
|
extruder_id: p.extruder_id ?? 0,
|
|
|
slot_id: 0, // Always create new
|
|
slot_id: 0, // Always create new
|
|
@@ -1248,17 +1396,19 @@ export function KProfilesView() {
|
|
|
</select>
|
|
</select>
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
- <div className="w-32">
|
|
|
|
|
- <select
|
|
|
|
|
- value={flowTypeFilter}
|
|
|
|
|
- onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
|
|
|
|
|
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
|
|
|
|
- >
|
|
|
|
|
- <option value="all">{t('kProfiles.allFlow')}</option>
|
|
|
|
|
- <option value="hf">{t('kProfiles.hfOnly')}</option>
|
|
|
|
|
- <option value="s">{t('kProfiles.sOnly')}</option>
|
|
|
|
|
- </select>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ {supportsFlowType && (
|
|
|
|
|
+ <div className="w-32">
|
|
|
|
|
+ <select
|
|
|
|
|
+ value={flowTypeFilter}
|
|
|
|
|
+ onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
|
|
|
|
|
+ className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
|
|
|
|
+ >
|
|
|
|
|
+ <option value="all">{t('kProfiles.allFlow')}</option>
|
|
|
|
|
+ <option value="hf">{t('kProfiles.hfOnly')}</option>
|
|
|
|
|
+ <option value="s">{t('kProfiles.sOnly')}</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
<div className="w-32">
|
|
<div className="w-32">
|
|
|
<select
|
|
<select
|
|
|
value={sortOption}
|
|
value={sortOption}
|
|
@@ -1451,9 +1601,11 @@ export function KProfilesView() {
|
|
|
profile={editingProfile}
|
|
profile={editingProfile}
|
|
|
printerId={selectedPrinter}
|
|
printerId={selectedPrinter}
|
|
|
nozzleDiameter={nozzleDiameter}
|
|
nozzleDiameter={nozzleDiameter}
|
|
|
- existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
|
|
|
|
|
|
|
+ existingProfiles={kprofiles?.profiles}
|
|
|
builtinFilaments={enrichedBuiltinFilaments}
|
|
builtinFilaments={enrichedBuiltinFilaments}
|
|
|
|
|
+ filamentPresets={filamentPresets}
|
|
|
isDualNozzle={isDualNozzle}
|
|
isDualNozzle={isDualNozzle}
|
|
|
|
|
+ supportsFlowType={supportsFlowType}
|
|
|
initialNote={note}
|
|
initialNote={note}
|
|
|
initialNoteKey={key}
|
|
initialNoteKey={key}
|
|
|
onSaveNote={handleSaveNote}
|
|
onSaveNote={handleSaveNote}
|
|
@@ -1476,9 +1628,11 @@ export function KProfilesView() {
|
|
|
<KProfileModal
|
|
<KProfileModal
|
|
|
printerId={selectedPrinter}
|
|
printerId={selectedPrinter}
|
|
|
nozzleDiameter={nozzleDiameter}
|
|
nozzleDiameter={nozzleDiameter}
|
|
|
- existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
|
|
|
|
|
|
|
+ existingProfiles={kprofiles?.profiles}
|
|
|
builtinFilaments={enrichedBuiltinFilaments}
|
|
builtinFilaments={enrichedBuiltinFilaments}
|
|
|
|
|
+ filamentPresets={filamentPresets}
|
|
|
isDualNozzle={isDualNozzle}
|
|
isDualNozzle={isDualNozzle}
|
|
|
|
|
+ supportsFlowType={supportsFlowType}
|
|
|
onSaveNote={handleSaveNote}
|
|
onSaveNote={handleSaveNote}
|
|
|
hasPermission={hasPermission}
|
|
hasPermission={hasPermission}
|
|
|
onClose={() => {
|
|
onClose={() => {
|
|
@@ -1497,9 +1651,11 @@ export function KProfilesView() {
|
|
|
<KProfileModal
|
|
<KProfileModal
|
|
|
printerId={selectedPrinter}
|
|
printerId={selectedPrinter}
|
|
|
nozzleDiameter={nozzleDiameter}
|
|
nozzleDiameter={nozzleDiameter}
|
|
|
- existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
|
|
|
|
|
|
|
+ existingProfiles={kprofiles?.profiles}
|
|
|
builtinFilaments={enrichedBuiltinFilaments}
|
|
builtinFilaments={enrichedBuiltinFilaments}
|
|
|
|
|
+ filamentPresets={filamentPresets}
|
|
|
isDualNozzle={isDualNozzle}
|
|
isDualNozzle={isDualNozzle}
|
|
|
|
|
+ supportsFlowType={supportsFlowType}
|
|
|
onSaveNote={handleSaveNote}
|
|
onSaveNote={handleSaveNote}
|
|
|
hasPermission={hasPermission}
|
|
hasPermission={hasPermission}
|
|
|
// Pass profile data but without slot_id to create a new profile
|
|
// Pass profile data but without slot_id to create a new profile
|