mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-05 21:51:23 +02:00
When the printer reports ams_filament_backup=True, compute_deficit_for_queue_item pools remaining_grams across spools matching (preset, colour) on the same printer (scoped per extruder on dual-nozzle) before declaring a per-slot shortfall. Identity is strict: same slicer_filament preset AND same colour (alpha-normalised). Two PETG HF spools in different colours are NOT pooled — the firmware would swap correctly but the print would change colour mid-run. Spoolman side mirrors the rule via filament.id + color_hex. Backup OFF falls back to the pre-PR per-slot accounting line-for-line. 8 new test cases in TestFilamentDeficitBackupAware pin pool covers, pool insufficient, different presets, backup-OFF regression, dual- extruder side scoping, no-preset never pairs, colour-strict, and alpha-hex normalisation. The 8 pre-existing test_filament_deficit.py cases stay green. feat(printers): AMS Filament Backup modal with BS-style ring per pair Badge click on the Filaments section header (#1766) now opens a modal: filament-colour ring per backup pair, material name + rotation count in the centre, slot labels distributed around the colour band on contrast-aware pills. Closely modelled on Bambu Studio's Auto Refill widget. Lone slots are intentionally not listed. R / L badges per ring when the extruder map carries two distinct values; collapses to no- badge rendering for single-nozzle printers misflagged as dual. Esc keypress closes the modal. Theme-aware via CSS variables matching AMSHistoryModal. computeBackupGroups helper in utils/amsHelpers defensively dedupes duplicate ams.id entries observed on switch-VP aggregations. 10 modal render cases pin: Esc closes / unmount nulls the listener / ring renders for pairs and omits lone slots / R-L badges only when extruder map has distinct values / empty state / toggle gating. 13 frontend cases pin computeBackupGroups identity rules. feat(printers): active-print P-N pill on AMS slot tiles during RUNNING While the printer is mid-print, each AMS slot tile referenced by status.ams_mapping carries a small "P1 / P2 / P3" pill in the top- right corner, naming which print-slot is mapped to that AMS slot. Catches the #1762 comment-2 scenario: a queue job set for "any X1C" staged to a printer with mismatched filament, no way to verify mid- print. Same wire data (status.ams_mapping is already on the wire) — the addition is purely surface. The existing ring-bambu-green highlight for effectiveTrayNow keeps its meaning (currently extruding RIGHT NOW); the pill is the per-slot static assignment for the active print. chore(scheduler): log Print Anyway short-circuit at INFO _block_on_filament_deficit logs at INFO when it honours item.skip_filament_check, so a future "Print Anyway didn't work" report (third commenter on #1762 hit this shape) has actionable evidence in the standard support bundle without DEBUG. Bundled because the deficit fix makes the original symptom disappear for users with backup ON.
335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
/**
|
||
* #1762 — AMS Filament Backup status modal.
|
||
*
|
||
* Opens from the AmsBackupBadge click. Shows the global toggle and a
|
||
* BambuStudio-style ring graphic per backup pair — each ring represents
|
||
* the rotation order the firmware will follow when the active slot runs
|
||
* out.
|
||
*
|
||
* On dual-extruder printers, each ring carries a small "R" / "L" badge
|
||
* because the firmware can't cross extruders even with the global backup
|
||
* bit set.
|
||
*
|
||
* Theme-aware via CSS variables, matching AMSHistoryModal — adapts to
|
||
* every background variant the user has picked.
|
||
*/
|
||
import { useEffect } from 'react';
|
||
import { X } from 'lucide-react';
|
||
import { useTranslation } from 'react-i18next';
|
||
|
||
import { Toggle } from './Toggle';
|
||
import {
|
||
computeBackupGroups,
|
||
normalizeColor,
|
||
type AmsUnitLike,
|
||
type BackupGroup,
|
||
} from '../utils/amsHelpers';
|
||
|
||
interface AmsBackupModalProps {
|
||
isOpen: boolean;
|
||
state: boolean | null;
|
||
amsUnits: AmsUnitLike[] | undefined;
|
||
amsExtruderMap: Record<string, number> | undefined;
|
||
isDualNozzle: boolean;
|
||
canToggle: boolean;
|
||
pending: boolean;
|
||
onToggle: (next: boolean) => void;
|
||
onClose: () => void;
|
||
}
|
||
|
||
/**
|
||
* Compact slot label like "A·3" / "HT·1" — the ring is small, every char
|
||
* counts toward readability.
|
||
*/
|
||
function formatSlotLabel(amsId: number, slotIdx: number, totalTraysOnUnit: number): string {
|
||
const isHt = totalTraysOnUnit === 1 || amsId >= 128;
|
||
const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
|
||
const letter = String.fromCharCode(65 + normalizedId);
|
||
return isHt ? `HT·${slotIdx + 1}` : `${letter}·${slotIdx + 1}`;
|
||
}
|
||
|
||
/** Pick a readable text colour for a given filament hex. */
|
||
function pickContrastTextColor(rgbaHex: string | null | undefined): string {
|
||
const s = (rgbaHex || '').replace('#', '').slice(0, 6);
|
||
if (s.length !== 6) return '#FFFFFF';
|
||
const r = parseInt(s.slice(0, 2), 16);
|
||
const g = parseInt(s.slice(2, 4), 16);
|
||
const b = parseInt(s.slice(4, 6), 16);
|
||
if ([r, g, b].some(Number.isNaN)) return '#FFFFFF';
|
||
const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||
return luma > 0.55 ? '#1A1A1A' : '#FFFFFF';
|
||
}
|
||
|
||
function BackupRing({
|
||
group,
|
||
trayCountByAms,
|
||
innerBg,
|
||
textPrimary,
|
||
textSecondary,
|
||
showExtruderBadge,
|
||
extruderLabel,
|
||
}: {
|
||
group: BackupGroup;
|
||
trayCountByAms: Map<number, number>;
|
||
innerBg: string;
|
||
textPrimary: string;
|
||
textSecondary: string;
|
||
showExtruderBadge: boolean;
|
||
extruderLabel: string;
|
||
}) {
|
||
const filamentHex = normalizeColor(group.trayColor || undefined);
|
||
const ringTextColor = pickContrastTextColor(group.trayColor);
|
||
// The pill background that sits behind each slot label — keeps text legible
|
||
// regardless of the filament fill colour.
|
||
const labelPillBg = ringTextColor === '#FFFFFF' ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.7)';
|
||
const n = group.members.length;
|
||
|
||
// Geometry: -100..100 viewport. Outer ring 92, inner cutout 56.
|
||
// Slot labels sit on the colour band at radius 76.
|
||
const labelRadius = 76;
|
||
|
||
return (
|
||
<div className="relative flex flex-col items-center">
|
||
{showExtruderBadge && (
|
||
<span
|
||
className="absolute -top-1 -left-1 z-10 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold shadow"
|
||
style={{
|
||
backgroundColor: textPrimary,
|
||
color: innerBg,
|
||
}}
|
||
aria-label={extruderLabel}
|
||
title={extruderLabel}
|
||
>
|
||
{extruderLabel}
|
||
</span>
|
||
)}
|
||
<svg viewBox="-100 -100 200 200" className="w-44 h-44">
|
||
{/* Subtle outer ring — gives a crisp edge on light AND dark themes. */}
|
||
<circle cx="0" cy="0" r="95" fill="none" stroke={textSecondary} strokeOpacity="0.25" strokeWidth="1" />
|
||
{/* Colour band */}
|
||
<circle cx="0" cy="0" r="92" fill={filamentHex} />
|
||
{/* Inner cutout */}
|
||
<circle cx="0" cy="0" r="56" fill={innerBg} />
|
||
{/* Inner ring border for definition between centre and colour band */}
|
||
<circle cx="0" cy="0" r="56" fill="none" stroke={textSecondary} strokeOpacity="0.3" strokeWidth="1" />
|
||
{/* Centre: material name */}
|
||
<text
|
||
x="0"
|
||
y="-4"
|
||
textAnchor="middle"
|
||
dominantBaseline="middle"
|
||
fontSize="14"
|
||
fontWeight="700"
|
||
fill={textPrimary}
|
||
>
|
||
{group.displayName || '—'}
|
||
</text>
|
||
{/* Centre: rotation count */}
|
||
<text
|
||
x="0"
|
||
y="16"
|
||
textAnchor="middle"
|
||
dominantBaseline="middle"
|
||
fontSize="11"
|
||
fontWeight="500"
|
||
fill={textSecondary}
|
||
>
|
||
{`${n}× ↻`}
|
||
</text>
|
||
{/* Slot labels around the ring, each on a pill for legibility */}
|
||
{group.members.map((m, i) => {
|
||
const angleDeg = (i * 360) / n - 90;
|
||
const rad = (angleDeg * Math.PI) / 180;
|
||
const x = labelRadius * Math.cos(rad);
|
||
const y = labelRadius * Math.sin(rad);
|
||
const label = formatSlotLabel(m.amsId, m.slotIdx, trayCountByAms.get(m.amsId) ?? 4);
|
||
// Approximate pill width based on char count (each digit ≈ 6.5 px @ 12 px font).
|
||
const pillWidth = Math.max(22, label.length * 7 + 8);
|
||
return (
|
||
<g key={`${m.amsId}-${m.slotIdx}`}>
|
||
<rect
|
||
x={x - pillWidth / 2}
|
||
y={y - 9}
|
||
width={pillWidth}
|
||
height={18}
|
||
rx={9}
|
||
ry={9}
|
||
fill={labelPillBg}
|
||
/>
|
||
<text
|
||
x={x}
|
||
y={y}
|
||
textAnchor="middle"
|
||
dominantBaseline="middle"
|
||
fontSize="12"
|
||
fontWeight="700"
|
||
fill={ringTextColor}
|
||
>
|
||
{label}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
</svg>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AmsBackupModal({
|
||
isOpen,
|
||
state,
|
||
amsUnits,
|
||
amsExtruderMap,
|
||
isDualNozzle,
|
||
canToggle,
|
||
pending,
|
||
onToggle,
|
||
onClose,
|
||
}: AmsBackupModalProps) {
|
||
const { t } = useTranslation();
|
||
|
||
// Close on Escape key while the modal is open. Captures at the window
|
||
// level so it works even when focus isn't inside the modal subtree
|
||
// (e.g. after the Toggle is clicked).
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') {
|
||
e.stopPropagation();
|
||
onClose();
|
||
}
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [isOpen, onClose]);
|
||
|
||
if (!isOpen) return null;
|
||
|
||
// Theme-aware tokens, matching AMSHistoryModal.
|
||
const modalBg = 'var(--bg-secondary)';
|
||
const sectionBg = 'var(--bg-primary)';
|
||
const borderColor = 'var(--border-color)';
|
||
const textPrimary = 'var(--text-primary)';
|
||
const textSecondary = 'var(--text-secondary)';
|
||
|
||
// Effective dual-nozzle detection: only split per extruder if the map
|
||
// actually carries 2 distinct values across the AMS units we have data
|
||
// for. Empty / single-value maps collapse to a single section to avoid
|
||
// misleading badges.
|
||
const effectiveDualNozzle = (() => {
|
||
if (!isDualNozzle) return false;
|
||
if (!amsExtruderMap) return false;
|
||
const distinctValues = new Set<number>();
|
||
for (const ams of amsUnits || []) {
|
||
const raw = amsExtruderMap[String(ams.id)];
|
||
if (raw === undefined) continue;
|
||
distinctValues.add(Number(raw));
|
||
if (distinctValues.size > 1) return true;
|
||
}
|
||
return false;
|
||
})();
|
||
|
||
const groups = computeBackupGroups(amsUnits, amsExtruderMap, effectiveDualNozzle);
|
||
const trayCountByAms = new Map<number, number>(
|
||
(amsUnits || []).map((u) => [u.id, u.tray.length]),
|
||
);
|
||
|
||
// Only pairs are rendered — lone slots are deliberately suppressed.
|
||
const pairs = groups.filter((g) => g.members.length >= 2);
|
||
|
||
const isOn = state === true;
|
||
const isUnknown = state === null;
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||
onClick={onClose}
|
||
data-testid="ams-backup-modal"
|
||
>
|
||
<div
|
||
className="rounded-xl w-full max-w-2xl max-h-[90vh] overflow-hidden shadow-xl flex flex-col"
|
||
style={{ backgroundColor: modalBg }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="ams-backup-modal-title"
|
||
>
|
||
<div
|
||
className="flex items-center justify-between px-5 py-3 border-b"
|
||
style={{ borderColor }}
|
||
>
|
||
<h2
|
||
id="ams-backup-modal-title"
|
||
className="text-base font-semibold"
|
||
style={{ color: textPrimary }}
|
||
>
|
||
{t('printers.amsBackup.modalTitle')}
|
||
</h2>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="p-1 rounded-md transition-colors hover:bg-black/10"
|
||
style={{ color: textSecondary }}
|
||
aria-label={t('common.close')}
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
className="flex items-center justify-between px-5 py-3 border-b"
|
||
style={{ borderColor, backgroundColor: sectionBg }}
|
||
>
|
||
<div className="min-w-0 mr-3">
|
||
<div className="text-sm font-medium" style={{ color: textPrimary }}>
|
||
{isUnknown
|
||
? t('printers.amsBackup.stateUnknown')
|
||
: isOn
|
||
? t('printers.amsBackup.stateOn')
|
||
: t('printers.amsBackup.stateOff')}
|
||
</div>
|
||
<p className="text-xs mt-0.5" style={{ color: textSecondary }}>
|
||
{t('printers.amsBackup.modalHelp')}
|
||
</p>
|
||
</div>
|
||
<Toggle
|
||
checked={isOn}
|
||
onChange={onToggle}
|
||
disabled={!canToggle || isUnknown || pending}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto px-5 py-6">
|
||
{pairs.length === 0 ? (
|
||
<p
|
||
className="text-sm text-center py-8"
|
||
style={{ color: textSecondary }}
|
||
>
|
||
{t('printers.amsBackup.modalNoPairs')}
|
||
</p>
|
||
) : (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 justify-items-center">
|
||
{pairs.map((g) => (
|
||
<BackupRing
|
||
key={g.key}
|
||
group={g}
|
||
trayCountByAms={trayCountByAms}
|
||
innerBg={modalBg}
|
||
textPrimary={textPrimary}
|
||
textSecondary={textSecondary}
|
||
showExtruderBadge={effectiveDualNozzle}
|
||
extruderLabel={
|
||
g.extruder === 0
|
||
? t('printers.amsBackup.extruderRightShort')
|
||
: t('printers.amsBackup.extruderLeftShort')
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|