Przeglądaj źródła

feat(skip-objects): select items directly on the build plate

Pairs the top-down plate preview with the slicer's per-object pick mask
(Metadata/pick_N.png), whose pixel colours encode the same identify_id the
firmware's skip command takes, so a click resolves to a real object rather
than an inferred bounding box. Several objects can be selected before one
confirmation; selected and already-skipped items are highlighted on the
plate; the checklist stays available when no mask exists.

view=pick serves only the active plate's mask and 404s otherwise, unlike
every other view. A render returned in a mask's place would be decoded as
object IDs — dark pixels yield small integers that collide with real ones —
and a click would then skip an arbitrary object, mid-print, irreversibly.
The 404 is what tells the UI to fall back to the checklist.

Click mapping goes through the contained rect, since the canvas paints at
mask resolution under object-contain; clicks on a letterbox bar are rejected
rather than clamped onto whichever object touches the border. Confirming
names the object when one is selected and counts them when several are,
which is what plates of identically-named clones need.

No printer-control command path was added or changed; the layer, permission
and existing skip-command guards are untouched.
maziggy 1 miesiąc temu
rodzic
commit
41ad1d65c7

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.5b2] - Unreleased
 
 ### Added
+- **Skip Objects can now be selected directly on the top-down build plate** — The skip dialog pairs the plate preview with the slicer's exact per-object pick mask, so clicking a model selects the same object id the printer firmware expects. Multiple objects can be selected before one confirmation, selected and already-skipped items are highlighted on the plate, and the checklist remains available when a pick mask is missing. Selecting every remaining object keeps the printer's existing stop-print warning, and the dialog closes once the skip is confirmed. Confirming names the object when one is selected and counts them when several are, which is what plates of identically-named clones need. The existing layer, permission, and printer-command guards are unchanged.
 - **Assigning a spool to an AMS slot now tells you whether the printer actually accepted it (#2582, reporter @gyrene2083)** — Until now, assigning a spool to an AMS tray was fire-and-forget: Bambuddy pushed the filament setting to the printer and immediately reported success, whether or not the tray took it. When the assignment silently didn't land — the reporter's case, where a spool assigned in Bambuddy never showed up in Bambu Studio — nothing told you, and the only way to tell it had loaded was to run a flow calibration and watch for the K-profile to appear. Because a print only deducts filament from the spool assigned to the *exact* tray it pulls from, a silently-dropped assignment also meant that print recorded no filament usage, which is what made the whole thing feel random. Bambuddy now reads the AMS telemetry back after every assignment (from both **Printers → assign spool** and **Configure Slot**) and toasts the outcome: **"Filament loaded on slot X"** once the tray echoes back the filament id that was pushed, a warning if the filament loaded but the **flow-calibration (K-profile) wasn't applied**, or **"couldn't confirm the assignment — check the AMS slot"** if the tray never reflects it within ~30s. The confirmation is derived entirely from the periodic status the printer already sends (an on-demand pushall is nudged so it lands quickly), covers regular AMS, AMS-HT, and external-spool slots, and if the printer goes silent it simply stays quiet rather than inventing a failure. No configuration; the toast appears automatically on assign.
 - **Bed levelling, flow calibration, and nozzle-offset calibration now have an "Auto" option, matching Bambu Studio** — These three print options were previously on/off only, so the only way to run bed levelling was to force a full level before every print. Bambu Studio has long offered a third "Auto" state that lets the printer skip the calibration when it was done recently, and that state is what most people actually want. All three options (in the Schedule/Print dialog, the queue bulk-edit, and Settings → Workflow → Default Print Options) are now a three-way **Off / Auto / On** choice, and new prints default to **Auto**. "On" still forces the calibration every time; "Off" skips it entirely; "Auto" lets the printer decide. Existing queued prints and your saved workflow defaults are migrated automatically — anything that was "on" becomes "On (force)" and anything "off" stays "Off", so nothing changes for in-flight jobs until you opt into Auto. The wire encoding mirrors Bambu Studio's exactly (verified against its source), including how prints sent through a Virtual Printer inherit the slicer's own Auto/On/Off pick.
 

+ 25 - 10
backend/app/api/routes/printers.py

@@ -1054,7 +1054,8 @@ async def get_printer_cover(
     """Get the cover image for the current print job.
 
     Args:
-        view: Optional view type. Use "top" for top-down build plate view (useful for skip objects).
+        view: Optional view type. Use "top" for the top-down build plate view or
+              "pick" for the slicer's object-ID mask used by skip objects.
               Default returns angled 3D perspective view.
     """
     # Fetch the printer in a short-lived session and release the pooled DB
@@ -1297,7 +1298,14 @@ async def _produce_cover_image(
             # Try common thumbnail paths in 3MF files
             # Use plate_num to get the correct plate's thumbnail for multi-plate projects
             # Use top-down view if requested (better for skip objects modal)
-            if view == "top":
+            if view == "pick":
+                # Only the active plate's mask, with no fallback: every other view
+                # falls back to plate 1 because a slightly wrong picture is better
+                # than none, but a mask is coordinates, not decoration. Plate 1's
+                # mask over plate 3's layout would resolve clicks to whichever
+                # object happened to occupy that pixel on a different plate.
+                thumbnail_paths = [f"Metadata/pick_{plate_num}.png"]
+            elif view == "top":
                 thumbnail_paths = [
                     f"Metadata/top_{plate_num}.png",
                     # Fall back to plate 1 if specific plate not found
@@ -1328,14 +1336,21 @@ async def _produce_cover_image(
                 except KeyError:
                     continue
 
-            # If no specific thumbnail found, try any PNG in Metadata
-            for name in zf.namelist():
-                if name.startswith("Metadata/") and name.endswith(".png"):
-                    image_data = zf.read(name)
-                    if printer_id not in _cover_cache:
-                        _cover_cache[printer_id] = {}
-                    _cover_cache[printer_id][(subtask_name, view_key)] = image_data
-                    return image_data
+            # If no specific thumbnail found, try any PNG in Metadata. Never for
+            # "pick": handing back a rendered thumbnail in place of the object-ID
+            # mask is worse than nothing, because the caller can't tell the
+            # difference and decodes the render's pixel colours as object IDs —
+            # dark pixels yield small integers that collide with real IDs, so a
+            # click would select an arbitrary object and skip it irreversibly.
+            # A 404 is what tells the UI to fall back to the checklist.
+            if view != "pick":
+                for name in zf.namelist():
+                    if name.startswith("Metadata/") and name.endswith(".png"):
+                        image_data = zf.read(name)
+                        if printer_id not in _cover_cache:
+                            _cover_cache[printer_id] = {}
+                        _cover_cache[printer_id][(subtask_name, view_key)] = image_data
+                        return image_data
 
             _cover_404_cache.setdefault(printer_id, set()).add(cache_key)
             raise HTTPException(404, "No thumbnail found in 3MF file")

+ 108 - 0
backend/tests/integration/test_printers_api.py

@@ -450,6 +450,114 @@ class TestPrintersAPI:
         assert response.status_code == 200
         assert response.content == b"PLATE_4_PNG"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cover_pick_view_serves_active_plate_object_mask(
+        self, async_client: AsyncClient, printer_factory, db_session, tmp_path
+    ):
+        """The skip-items UI needs the slicer's exact object-ID mask, not an
+        inferred bounding box, so a plate click resolves to the firmware ID."""
+        import zipfile
+        from unittest.mock import MagicMock, patch
+
+        from backend.app.services.bambu_ftp import cache_3mf_download
+        from backend.app.services.bambu_mqtt import PrinterState
+
+        printer = await printer_factory()
+        threemf_path = tmp_path / "PickMask.3mf"
+        with zipfile.ZipFile(threemf_path, "w") as zf:
+            zf.writestr("Metadata/pick_1.png", b"PICK_ONE")
+            zf.writestr("Metadata/pick_3.png", b"PICK_THREE")
+            zf.writestr("Metadata/plate_3.gcode", "; active plate\n")
+
+        cache_3mf_download(printer.id, "PickMask.3mf", threemf_path)
+
+        state = PrinterState()
+        state.connected = True
+        state.state = "RUNNING"
+        state.subtask_name = "PickMask"
+        state.gcode_file = "PickMask.3mf"
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status = MagicMock(return_value=state)
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/cover?view=pick")
+
+        assert response.status_code == 200
+        assert response.content == b"PICK_THREE"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cover_pick_view_404s_rather_than_serving_a_render(
+        self, async_client: AsyncClient, printer_factory, db_session, tmp_path
+    ):
+        """A mask is coordinates, not decoration. Archives without pick_N.png
+        (Handy jobs, older slicers) must 404 so the UI drops to the checklist —
+        every other view's fallback to a rendered thumbnail would be decoded as
+        object IDs here, and a click would skip an arbitrary object."""
+        import zipfile
+        from unittest.mock import MagicMock, patch
+
+        from backend.app.services.bambu_ftp import cache_3mf_download
+        from backend.app.services.bambu_mqtt import PrinterState
+
+        printer = await printer_factory()
+        threemf_path = tmp_path / "NoMask.3mf"
+        with zipfile.ZipFile(threemf_path, "w") as zf:
+            zf.writestr("Metadata/top_1.png", b"TOP_RENDER")
+            zf.writestr("Metadata/plate_1.png", b"PLATE_RENDER")
+            zf.writestr("Metadata/plate_1.gcode", "; active plate\n")
+
+        cache_3mf_download(printer.id, "NoMask.3mf", threemf_path)
+
+        state = PrinterState()
+        state.connected = True
+        state.state = "RUNNING"
+        state.subtask_name = "NoMask"
+        state.gcode_file = "NoMask.3mf"
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status = MagicMock(return_value=state)
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/cover?view=pick")
+
+        assert response.status_code == 404
+        assert b"RENDER" not in response.content
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cover_pick_view_does_not_borrow_another_plates_mask(
+        self, async_client: AsyncClient, printer_factory, db_session, tmp_path
+    ):
+        """Plate 1's mask over plate 3's layout resolves clicks to whatever
+        occupied that pixel on a different plate, so the active plate's mask is
+        the only acceptable answer."""
+        import zipfile
+        from unittest.mock import MagicMock, patch
+
+        from backend.app.services.bambu_ftp import cache_3mf_download
+        from backend.app.services.bambu_mqtt import PrinterState
+
+        printer = await printer_factory()
+        threemf_path = tmp_path / "OtherPlate.3mf"
+        with zipfile.ZipFile(threemf_path, "w") as zf:
+            zf.writestr("Metadata/pick_1.png", b"PICK_ONE")
+            zf.writestr("Metadata/top_3.png", b"TOP_THREE")
+            zf.writestr("Metadata/plate_3.gcode", "; active plate\n")
+
+        cache_3mf_download(printer.id, "OtherPlate.3mf", threemf_path)
+
+        state = PrinterState()
+        state.connected = True
+        state.state = "RUNNING"
+        state.subtask_name = "OtherPlate"
+        state.gcode_file = "OtherPlate.3mf"
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status = MagicMock(return_value=state)
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/cover?view=pick")
+
+        assert response.status_code == 404
+        assert response.content != b"PICK_ONE"
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_cover_3mf_scan_fallback_for_per_plate_archive(

+ 67 - 0
frontend/src/__tests__/components/SkipObjectsModal.test.ts

@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest';
+import { pickObjectIdAt, plateClickToMaskPoint } from '../../utils/skipObjects';
+
+function imageData(width: number, height: number, pixels: number[]): ImageData {
+  return {
+    width,
+    height,
+    data: new Uint8ClampedArray(pixels),
+    colorSpace: 'srgb',
+  } as ImageData;
+}
+
+describe('pickObjectIdAt', () => {
+  it('decodes the slicer object ID from RGB channels', () => {
+    const pick = imageData(2, 1, [
+      0, 0, 0, 0,
+      52, 18, 1, 255,
+    ]);
+
+    expect(pickObjectIdAt(pick, 1, 0)).toBe(1 * 65536 + 18 * 256 + 52);
+  });
+
+  it('treats transparent and black pixels as empty plate space', () => {
+    const pick = imageData(2, 1, [
+      8, 0, 0, 0,
+      0, 0, 0, 255,
+    ]);
+
+    expect(pickObjectIdAt(pick, 0, 0)).toBeNull();
+    expect(pickObjectIdAt(pick, 1, 0)).toBeNull();
+  });
+
+  it('clamps click coordinates to the image bounds', () => {
+    const pick = imageData(1, 1, [63, 0, 0, 255]);
+
+    expect(pickObjectIdAt(pick, 99, -4)).toBe(63);
+  });
+});
+
+describe('plateClickToMaskPoint', () => {
+  const square = { left: 100, top: 50, width: 400, height: 400 };
+
+  it('maps a click through the display scale when the mask fills the box', () => {
+    // 400px box, 200px mask: the centre of the box is the centre of the mask.
+    expect(plateClickToMaskPoint(square, 200, 200, 300, 250)).toEqual({ x: 100, y: 100 });
+  });
+
+  it('offsets by the letterbox bars when the mask is not square', () => {
+    // A 200x100 mask in a 400x400 box renders 400x200, leaving 100px bars top
+    // and bottom. Without that offset this click would read 100px too low.
+    expect(plateClickToMaskPoint(square, 200, 100, 300, 250)).toEqual({ x: 100, y: 50 });
+  });
+
+  it('rejects clicks on a letterbox bar rather than clamping onto an edge object', () => {
+    expect(plateClickToMaskPoint(square, 200, 100, 300, 100)).toBeNull();
+    expect(plateClickToMaskPoint(square, 200, 100, 300, 400)).toBeNull();
+  });
+
+  it('rejects clicks outside the plate box', () => {
+    expect(plateClickToMaskPoint(square, 200, 200, 90, 250)).toBeNull();
+    expect(plateClickToMaskPoint(square, 200, 200, 300, 460)).toBeNull();
+  });
+
+  it('returns null for a collapsed box instead of dividing by zero', () => {
+    expect(plateClickToMaskPoint({ left: 0, top: 0, width: 0, height: 0 }, 200, 200, 0, 0)).toBeNull();
+  });
+});

+ 308 - 312
frontend/src/components/SkipObjectsModal.tsx

@@ -1,20 +1,18 @@
-import { useState } from 'react';
-import { useQuery, useMutation } from '@tanstack/react-query';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useMutation, useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
-import { X, Loader2, Monitor, AlertCircle, Box, Maximize2 } from 'lucide-react';
+import { AlertCircle, Box, CheckSquare, Loader2, Maximize2, Square, X } from 'lucide-react';
 import { api, withStreamToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
+import { pickObjectIdAt, plateClickToMaskPoint } from '../utils/skipObjects';
 import { ConfirmModal } from './ConfirmModal';
 
-// Custom Skip Objects icon - arrow jumping over boxes
 export const SkipObjectsIcon = ({ className }: { className?: string }) => (
   <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
-    {/* Three boxes at the bottom */}
     <rect x="2" y="15" width="5" height="5" rx="0.5" />
     <rect x="9.5" y="15" width="5" height="5" rx="0.5" fill="currentColor" opacity="0.3" />
     <rect x="17" y="15" width="5" height="5" rx="0.5" />
-    {/* Curved arrow jumping over first box */}
     <path d="M4 12 C4 6, 14 6, 14 12" />
     <polyline points="12,10 14,12 12,14" />
   </svg>
@@ -26,12 +24,25 @@ interface SkipObjectsModalProps {
   onClose: () => void;
 }
 
+interface PrintableObject {
+  id: number;
+  name: string;
+  x: number | null;
+  y: number | null;
+  skipped: boolean;
+}
+
 export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModalProps) {
   const { t } = useTranslation();
   const { showToast } = useToast();
   const { hasPermission } = useAuth();
-  const [pendingSkip, setPendingSkip] = useState<{ id: number; name: string } | null>(null);
+  const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
+  const [confirming, setConfirming] = useState(false);
   const [enlarged, setEnlarged] = useState(false);
+  const [pickReady, setPickReady] = useState(false);
+  const pickDataRef = useRef<ImageData | null>(null);
+  const overlayRef = useRef<HTMLCanvasElement | null>(null);
+  const enlargedOverlayRef = useRef<HTMLCanvasElement | null>(null);
 
   const { data: status } = useQuery({
     queryKey: ['printerStatus', printerId],
@@ -47,338 +58,323 @@ export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModa
     refetchInterval: isOpen ? 5000 : false,
   });
 
+  const hasObjects = (objectsData?.objects.length ?? 0) > 0;
+  const topViewUrl = hasObjects && status?.cover_url
+    ? withStreamToken(`${status.cover_url}?view=top`)
+    : null;
+  const pickViewUrl = hasObjects && status?.cover_url
+    ? withStreamToken(`${status.cover_url}?view=pick`)
+    : null;
+
+  const activeObjects = useMemo(
+    () => (objectsData?.objects ?? []).filter((object) => !object.skipped),
+    [objectsData],
+  );
+  const selectedObjects = useMemo(
+    () => activeObjects.filter((object) => selectedIds.has(object.id)),
+    [activeObjects, selectedIds],
+  );
+  const allSelected = activeObjects.length > 0 && selectedObjects.length === activeObjects.length;
+  const skippingAllRemaining = allSelected && selectedIds.size > 0;
+  const canSubmit = selectedIds.size > 0
+    && (status?.layer_num ?? 0) > 1
+    && hasPermission('printers:control');
+
   const skipObjectsMutation = useMutation({
     mutationFn: (objectIds: number[]) => api.skipObjects(printerId, objectIds),
-    onSuccess: (data) => {
+    onSuccess: async (data) => {
       showToast(data.message || t('printers.skipObjects.objectsSkipped'));
-      setPendingSkip(null);
-      refetchObjects();
+      // Refresh before closing: this modal is the only on-demand refetch of the
+      // shared printableObjects query, so the printer card behind it would keep
+      // showing the pre-skip count otherwise.
+      await refetchObjects();
+      setConfirming(false);
+      setSelectedIds(new Set());
+      onClose();
+    },
+    onError: (error: Error) => {
+      setConfirming(false);
+      showToast(error.message || t('printers.toast.failedToSkipObjects'), 'error');
     },
-    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSkipObjects'), 'error'),
   });
 
-  if (!isOpen) return null;
+  useEffect(() => {
+    if (isOpen) return;
+    setSelectedIds(new Set());
+    setConfirming(false);
+    setEnlarged(false);
+  }, [isOpen]);
 
-  return (
-    <>
-    <div
-      className="fixed inset-0 z-50 flex items-center justify-center"
-      onClick={onClose}
-      onKeyDown={(e) => {
-        if (e.key === 'Escape') {
-          if (enlarged) setEnlarged(false);
-          else onClose();
-        }
-      }}
-      tabIndex={-1}
-      ref={(el) => el?.focus()}
-    >
-      {/* Backdrop */}
-      <div className="absolute inset-0 bg-black/50 z-0" />
-      {/* Modal */}
-      <div
-        className="relative z-10 bg-white dark:bg-bambu-dark border border-gray-200 dark:border-bambu-dark-tertiary rounded-xl shadow-2xl w-[560px] max-h-[85vh] flex flex-col overflow-hidden"
-        onClick={(e) => e.stopPropagation()}
-      >
-        {/* Header */}
-        <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark">
-          <div className="flex items-center gap-2">
-            <SkipObjectsIcon className="w-4 h-4 text-bambu-green" />
-            <span className="text-sm font-medium text-gray-900 dark:text-white">{t('printers.skipObjects.title')}</span>
-          </div>
-          <button
-            onClick={onClose}
-            className="p-1 text-gray-500 dark:text-bambu-gray hover:text-gray-900 dark:hover:text-white rounded transition-colors"
-          >
-            <X className="w-4 h-4" />
-          </button>
-        </div>
+  useEffect(() => {
+    pickDataRef.current = null;
+    setPickReady(false);
+    if (!isOpen || !pickViewUrl) return;
 
-        {!objectsData ? (
-          <div className="flex items-center justify-center py-12">
-            <Loader2 className="w-5 h-5 animate-spin text-bambu-gray" />
-          </div>
-        ) : objectsData.objects.length === 0 ? (
-          <div className="text-center py-8 px-4 text-bambu-gray">
-            <p className="text-sm">{t('printers.noObjectsFound')}</p>
-            <p className="text-xs mt-1 opacity-70">{t('printers.objectsLoadedOnPrintStart')}</p>
-          </div>
-        ) : (
-          <div className="flex flex-col overflow-hidden">
-            {/* Info Banner */}
-            <div className="flex items-center gap-3 px-4 py-2.5 bg-blue-50 dark:bg-blue-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
-              <div className="flex-shrink-0 w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-500/20 flex items-center justify-center">
-                <Monitor className="w-4 h-4 text-blue-500 dark:text-blue-400" />
-              </div>
-              <div className="flex-1 min-w-0">
-                <p className="text-xs text-blue-600 dark:text-blue-300">{t('printers.skipObjects.matchIdsInfo')}</p>
-                <p className="text-[10px] text-blue-500/70 dark:text-blue-300/60">{t('printers.skipObjects.printerShowsIds')}</p>
-              </div>
-              <div className="flex-shrink-0 text-xs text-gray-500 dark:text-bambu-gray">
-                {objectsData.skipped_count}/{objectsData.total} {t('printers.skipObjects.skipped')}
-              </div>
-            </div>
-
-            {/* Layer Warning */}
-            {(status?.layer_num ?? 0) <= 1 && (
-              <div className="flex items-center gap-2 px-4 py-2 bg-amber-50 dark:bg-amber-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
-                <AlertCircle className="w-4 h-4 text-amber-500 dark:text-amber-400 flex-shrink-0" />
-                <p className="text-xs text-amber-600 dark:text-amber-400">
-                  {t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 })}
-                </p>
-              </div>
-            )}
-
-            {/* Content: Image + List side by side */}
-            <div className="flex flex-1 overflow-hidden">
-              {/* Left: Preview Image with object markers */}
-              <div className="w-52 flex-shrink-0 p-4 border-r border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark-secondary overflow-y-auto">
-                <div className="relative cursor-pointer group" onClick={() => setEnlarged(true)}>
-                  {status?.cover_url ? (
-                    <img
-                      src={withStreamToken(`${status.cover_url}?view=top`)}
-                      alt={t('printers.printPreview')}
-                      className="w-full aspect-square object-contain rounded-lg bg-gray-900 dark:bg-gray-900 border border-gray-300 dark:border-gray-600"
-                    />
-                  ) : (
-                    <div className="w-full aspect-square rounded-lg bg-gray-100 dark:bg-bambu-dark flex items-center justify-center">
-                      <Box className="w-8 h-8 text-gray-300 dark:text-bambu-gray/30" />
-                    </div>
-                  )}
-                  {/* Enlarge hint */}
-                  <div className="absolute top-2 right-2 p-1 bg-black/60 rounded opacity-0 group-hover:opacity-100 transition-opacity">
-                    <Maximize2 className="w-3.5 h-3.5 text-white" />
-                  </div>
-                  {/* Object ID markers overlay - positioned based on object data */}
-                  {objectsData.objects.length > 0 && (
-                    <div className="absolute inset-0 pointer-events-none">
-                      {objectsData.objects.map((obj, idx) => {
-                        let x: number, y: number;
+    let cancelled = false;
+    const image = new Image();
+    image.onload = () => {
+      if (cancelled) return;
+      const canvas = document.createElement('canvas');
+      canvas.width = image.naturalWidth || 512;
+      canvas.height = image.naturalHeight || 512;
+      const context = canvas.getContext('2d', { willReadFrequently: true });
+      if (!context) return;
+      context.drawImage(image, 0, 0);
+      pickDataRef.current = context.getImageData(0, 0, canvas.width, canvas.height);
+      setPickReady(true);
+    };
+    image.onerror = () => {
+      if (!cancelled) setPickReady(false);
+    };
+    image.src = pickViewUrl;
 
-                        // Use position data if available, otherwise fall back to grid
-                        if (obj.x != null && obj.y != null && objectsData.bbox_all) {
-                          // bbox_all defines the visible area in the top_N.png image
-                          // Format: [x_min, y_min, x_max, y_max] in mm
-                          const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;
-                          const bboxWidth = xMax - xMin;
-                          const bboxHeight = yMax - yMin;
+    return () => {
+      cancelled = true;
+    };
+  }, [isOpen, pickViewUrl]);
 
-                          // The image shows bbox_all area with some padding (~5-10%)
-                          const padding = 8;
-                          const contentArea = 100 - (padding * 2);
+  useEffect(() => {
+    const pickData = pickDataRef.current;
+    if (!pickData || !objectsData) return;
 
-                          // Map object position to image percentage
-                          x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;
-                          // Y axis: image Y increases downward, but 3D Y increases toward back
-                          y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;
+    const skippedIds = new Set(objectsData.objects.filter((object) => object.skipped).map((object) => object.id));
+    for (const canvas of [overlayRef.current, enlargedOverlayRef.current]) {
+      if (!canvas) continue;
+      canvas.width = pickData.width;
+      canvas.height = pickData.height;
+      const context = canvas.getContext('2d');
+      if (!context) continue;
+      const overlay = context.createImageData(pickData.width, pickData.height);
 
-                          // Clamp to valid range
-                          x = Math.max(5, Math.min(95, x));
-                          y = Math.max(5, Math.min(95, y));
-                        } else if (obj.x != null && obj.y != null) {
-                          // Fallback: use full build plate (256mm)
-                          const buildPlate = 256;
-                          x = (obj.x / buildPlate) * 100;
-                          y = 100 - (obj.y / buildPlate) * 100;
-                          x = Math.max(5, Math.min(95, x));
-                          y = Math.max(5, Math.min(95, y));
-                        } else {
-                          // Fallback: arrange in a grid pattern over the build plate area
-                          const cols = Math.ceil(Math.sqrt(objectsData.objects.length));
-                          const row = Math.floor(idx / cols);
-                          const col = idx % cols;
-                          const rows = Math.ceil(objectsData.objects.length / cols);
-                          x = 15 + (col * (70 / cols)) + (35 / cols);
-                          y = 15 + (row * (70 / rows)) + (35 / rows);
-                        }
+      for (let offset = 0; offset < pickData.data.length; offset += 4) {
+        const objectId = pickData.data[offset] + (pickData.data[offset + 1] << 8) + (pickData.data[offset + 2] << 16);
+        const selected = selectedIds.has(objectId);
+        const skipped = skippedIds.has(objectId);
+        if (!selected && !skipped) continue;
+        const pixel = offset / 4;
+        const x = pixel % pickData.width;
+        const y = Math.floor(pixel / pickData.width);
+        const stripe = ((x + y) % 14) < 7;
+        overlay.data[offset] = selected ? (stripe ? 37 : 74) : 148;
+        overlay.data[offset + 1] = selected ? (stripe ? 199 : 222) : 163;
+        overlay.data[offset + 2] = selected ? (stripe ? 91 : 128) : 184;
+        overlay.data[offset + 3] = selected ? (stripe ? 205 : 145) : 175;
+      }
+      context.putImageData(overlay, 0, 0);
+    }
+  }, [enlarged, objectsData, pickReady, selectedIds]);
 
-                        return (
-                          <div
-                            key={obj.id}
-                            className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${
-                              obj.skipped
-                                ? 'bg-red-500 text-white line-through'
-                                : 'bg-bambu-green text-black'
-                            }`}
-                            style={{
-                              left: `${x}%`,
-                              top: `${y}%`,
-                              transform: 'translate(-50%, -50%)'
-                            }}
-                            title={obj.name}
-                          >
-                            {obj.id}
-                          </div>
-                        );
-                      })}
-                    </div>
-                  )}
-                  {/* Object count overlay */}
-                  <div className="absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm">
-                    {t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}
-                  </div>
-                </div>
-              </div>
-
-              {/* Right: Object List with prominent IDs */}
-              <div className="flex-1 min-w-0 overflow-y-auto">
-                {objectsData.objects.map((obj) => (
-                  <div
-                    key={obj.id}
-                    className={`
-                      flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-bambu-dark-tertiary/50 last:border-0
-                      ${obj.skipped ? 'bg-red-50 dark:bg-red-500/10' : 'hover:bg-gray-50 dark:hover:bg-bambu-dark/50'}
-                    `}
-                  >
-                    {/* Large prominent ID badge */}
-                    <div className={`
-                      w-12 h-12 flex-shrink-0 rounded-lg flex flex-col items-center justify-center
-                      ${obj.skipped
-                        ? 'bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/40'
-                        : 'bg-green-100 dark:bg-bambu-green/20 border border-green-300 dark:border-bambu-green/40'}
-                    `}>
-                      <span className={`text-lg font-mono font-bold ${obj.skipped ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-bambu-green'}`}>
-                        {obj.id}
-                      </span>
-                      <span className={`text-[8px] uppercase tracking-wider ${obj.skipped ? 'text-red-700/70 dark:text-red-400/60' : 'text-green-500/60 dark:text-bambu-green/60'}`}>
-                        ID
-                      </span>
-                    </div>
+  const toggleObject = (object: PrintableObject) => {
+    if (object.skipped) return;
+    setSelectedIds((current) => {
+      const next = new Set(current);
+      if (next.has(object.id)) next.delete(object.id);
+      else next.add(object.id);
+      return next;
+    });
+  };
 
-                    {/* Object name and status */}
-                    <div className="flex-1 min-w-0">
-                      <span className={`block text-sm truncate ${obj.skipped ? 'text-red-500 dark:text-red-400 line-through' : 'text-gray-900 dark:text-white'}`}>
-                        {obj.name}
-                      </span>
-                      {obj.skipped && (
-                        <span className="text-[10px] text-red-700/70 dark:text-red-400/60">{t('printers.willBeSkipped')}</span>
-                      )}
-                    </div>
+  const toggleFromPlate = (event: React.MouseEvent<HTMLCanvasElement>) => {
+    const pickData = pickDataRef.current;
+    if (!pickData) return;
+    const point = plateClickToMaskPoint(
+      event.currentTarget.getBoundingClientRect(),
+      pickData.width,
+      pickData.height,
+      event.clientX,
+      event.clientY,
+    );
+    if (!point) return;
+    const objectId = pickObjectIdAt(pickData, point.x, point.y);
+    const object = objectsData?.objects.find((candidate) => candidate.id === objectId);
+    if (object) toggleObject(object);
+  };
 
-                    {/* Skip button */}
-                    {!obj.skipped ? (
-                      <button
-                        onClick={() => setPendingSkip({ id: obj.id, name: obj.name })}
-                        disabled={skipObjectsMutation.isPending || (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')}
-                        className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${
-                          (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')
-                            ? 'bg-gray-100 dark:bg-bambu-dark text-gray-400 dark:text-bambu-gray/50 cursor-not-allowed'
-                            : 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-500/30 border border-red-300 dark:border-red-500/30'
-                        }`}
-                        title={!hasPermission('printers:control') ? t('printers.permission.noControl') : ((status?.layer_num ?? 0) <= 1 ? t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 }) : t('printers.skipObjects.skip'))}
-                      >
-                        {t('printers.skipObjects.skip')}
-                      </button>
-                    ) : (
-                      <span className="px-4 py-2 text-xs text-red-500 dark:text-red-400/70 bg-red-100 dark:bg-red-500/10 rounded-lg">
-                        {t('printers.skipObjects.skipped')}
-                      </span>
-                    )}
-                  </div>
-                ))}
-              </div>
-            </div>
-          </div>
-        )}
-      </div>
-    </div>
-    {pendingSkip && (
-      <ConfirmModal
-        variant="warning"
-        title={t('printers.skipObjects.confirmTitle')}
-        message={t('printers.skipObjects.confirmMessage', { name: pendingSkip.name })}
-        confirmText={t('printers.skipObjects.skip')}
-        isLoading={skipObjectsMutation.isPending}
-        onConfirm={() => skipObjectsMutation.mutate([pendingSkip.id])}
-        onCancel={() => setPendingSkip(null)}
+  const renderPlate = (large = false) => (
+    <div className={`relative aspect-square overflow-hidden rounded-lg border border-gray-300 bg-gray-900 dark:border-gray-600 ${pickReady ? 'cursor-crosshair' : ''}`}>
+      {topViewUrl ? (
+        <img src={topViewUrl} alt={t('printers.printPreview')} className="absolute inset-0 h-full w-full object-contain" />
+      ) : (
+        <div className="absolute inset-0 flex items-center justify-center">
+          <Box className="h-10 w-10 text-gray-500" />
+        </div>
+      )}
+      <canvas
+        ref={large ? enlargedOverlayRef : overlayRef}
+        onClick={toggleFromPlate}
+        className="absolute inset-0 h-full w-full object-contain"
+        aria-label={t('printers.skipObjects.selectObjectsToSkip')}
       />
-    )}
-    {/* Enlarged lightbox overlay */}
-    {enlarged && objectsData && (
-      <div
-        className="fixed inset-0 bg-black/90 flex items-center justify-center z-60"
-        onClick={() => setEnlarged(false)}
-      >
+      {!large && topViewUrl && (
         <button
-          onClick={() => setEnlarged(false)}
-          className="absolute top-4 right-4 p-2 text-white/70 hover:text-white transition-colors"
+          type="button"
+          onClick={(event) => {
+            event.stopPropagation();
+            setEnlarged(true);
+          }}
+          className="absolute right-2 top-2 rounded bg-black/65 p-1.5 text-white/80 hover:text-white"
+          title={t('common.expand')}
         >
-          <X className="w-6 h-6" />
+          <Maximize2 className="h-4 w-4" />
         </button>
-        <div
-          className="relative max-w-[600px] max-h-[80vh] aspect-square"
-          onClick={(e) => e.stopPropagation()}
+      )}
+      {!pickReady && topViewUrl && (
+        <div className="absolute bottom-2 left-2 rounded bg-black/70 px-2 py-1 text-[10px] text-white/80">
+          {t('common.unavailable')}
+        </div>
+      )}
+    </div>
+  );
+
+  if (!isOpen) return null;
+
+  return (
+    <>
+      <div
+        className="fixed inset-0 z-50 flex items-center justify-center p-4"
+        onClick={onClose}
+        onKeyDown={(event) => {
+          if (event.key !== 'Escape') return;
+          if (enlarged) setEnlarged(false);
+          else onClose();
+        }}
+        tabIndex={-1}
+        ref={(element) => element?.focus()}
+      >
+        <div className="absolute inset-0 bg-black/55" />
+        <section
+          className="relative z-10 flex max-h-[88vh] w-full max-w-[980px] flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-2xl dark:border-bambu-dark-tertiary dark:bg-bambu-dark"
+          onClick={(event) => event.stopPropagation()}
+          aria-label={t('printers.skipObjects.title')}
         >
-          {status?.cover_url ? (
-            <img
-              src={withStreamToken(`${status.cover_url}?view=top`)}
-              alt={t('printers.printPreview')}
-              className="w-full h-full object-contain rounded-lg bg-gray-900"
-            />
-          ) : (
-            <div className="w-full h-full rounded-lg bg-gray-800 flex items-center justify-center">
-              <Box className="w-16 h-16 text-gray-500" />
+          <header className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3 dark:border-bambu-dark-tertiary dark:bg-bambu-dark">
+            <div className="flex items-center gap-2">
+              <SkipObjectsIcon className="h-5 w-5 text-bambu-green" />
+              <div>
+                <h2 className="text-sm font-semibold text-gray-900 dark:text-white">{t('printers.skipObjects.title')}</h2>
+                <p className="text-xs text-gray-500 dark:text-bambu-gray">{t('printers.skipObjects.selectObjectsToSkip')}</p>
+              </div>
             </div>
-          )}
-          {/* Object ID markers overlay */}
-          {objectsData.objects.length > 0 && (
-            <div className="absolute inset-0 pointer-events-none">
-              {objectsData.objects.map((obj, idx) => {
-                let x: number, y: number;
+            <button type="button" onClick={onClose} className="rounded p-1 text-gray-500 hover:text-gray-900 dark:text-bambu-gray dark:hover:text-white">
+              <X className="h-5 w-5" />
+            </button>
+          </header>
 
-                if (obj.x != null && obj.y != null && objectsData.bbox_all) {
-                  const [xMin, yMin, xMax, yMax] = objectsData.bbox_all;
-                  const bboxWidth = xMax - xMin;
-                  const bboxHeight = yMax - yMin;
-                  const padding = 8;
-                  const contentArea = 100 - (padding * 2);
-                  x = padding + ((obj.x - xMin) / bboxWidth) * contentArea;
-                  y = padding + ((yMax - obj.y) / bboxHeight) * contentArea;
-                  x = Math.max(5, Math.min(95, x));
-                  y = Math.max(5, Math.min(95, y));
-                } else if (obj.x != null && obj.y != null) {
-                  const buildPlate = 256;
-                  x = (obj.x / buildPlate) * 100;
-                  y = 100 - (obj.y / buildPlate) * 100;
-                  x = Math.max(5, Math.min(95, x));
-                  y = Math.max(5, Math.min(95, y));
-                } else {
-                  const cols = Math.ceil(Math.sqrt(objectsData.objects.length));
-                  const row = Math.floor(idx / cols);
-                  const col = idx % cols;
-                  const rows = Math.ceil(objectsData.objects.length / cols);
-                  x = 15 + (col * (70 / cols)) + (35 / cols);
-                  y = 15 + (row * (70 / rows)) + (35 / rows);
-                }
+          {!objectsData ? (
+            <div className="flex items-center justify-center py-16">
+              <Loader2 className="h-6 w-6 animate-spin text-bambu-gray" />
+            </div>
+          ) : objectsData.objects.length === 0 ? (
+            <div className="px-4 py-12 text-center text-bambu-gray">
+              <p className="text-sm">{t('printers.noObjectsFound')}</p>
+              <p className="mt-1 text-xs opacity-70">{t('printers.objectsLoadedOnPrintStart')}</p>
+            </div>
+          ) : (
+            <>
+              {(status?.layer_num ?? 0) <= 1 && (
+                <div className="flex items-center gap-2 border-b border-amber-400/20 bg-amber-500/10 px-4 py-2 text-xs text-amber-400">
+                  <AlertCircle className="h-4 w-4 flex-shrink-0" />
+                  {t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 })}
+                </div>
+              )}
+              <div className="grid min-h-0 flex-1 grid-cols-[minmax(320px,1fr)_minmax(340px,0.9fr)] overflow-hidden max-md:grid-cols-1 max-md:overflow-y-auto">
+                <div className="border-r border-gray-200 bg-gray-50 p-4 dark:border-bambu-dark-tertiary dark:bg-bambu-dark-secondary max-md:border-b-0 max-md:border-r-0">
+                  {renderPlate()}
+                  <div className="mt-3 flex items-center justify-between text-xs text-gray-500 dark:text-bambu-gray">
+                    <span>{selectedIds.size}/{activeObjects.length}</span>
+                    <span>{objectsData.skipped_count} {t('printers.skipObjects.skipped')}</span>
+                  </div>
+                </div>
 
-                return (
-                  <div
-                    key={obj.id}
-                    className={`absolute flex items-center justify-center w-6 h-6 rounded-full text-[10px] font-bold shadow-lg ${
-                      obj.skipped
-                        ? 'bg-red-500 text-white line-through'
-                        : 'bg-bambu-green text-black'
-                    }`}
-                    style={{
-                      left: `${x}%`,
-                      top: `${y}%`,
-                      transform: 'translate(-50%, -50%)'
-                    }}
-                    title={obj.name}
+                <div className="flex min-h-0 flex-col">
+                  <button
+                    type="button"
+                    onClick={() => setSelectedIds(allSelected ? new Set() : new Set(activeObjects.map((object) => object.id)))}
+                    className="flex items-center gap-3 border-b border-gray-200 px-4 py-3 text-left text-sm font-semibold text-gray-900 hover:bg-gray-50 dark:border-bambu-dark-tertiary dark:text-white dark:hover:bg-white/5"
                   >
-                    {obj.id}
+                    {allSelected ? <CheckSquare className="h-5 w-5 text-bambu-green" /> : <Square className="h-5 w-5 text-bambu-gray" />}
+                    <span className="flex-1">{allSelected ? t('common.deselectAll') : t('common.selectAll')}</span>
+                    <span className="text-xs font-normal text-bambu-gray">{activeObjects.length}</span>
+                  </button>
+                  <div className="min-h-0 flex-1 overflow-y-auto">
+                    {objectsData.objects.map((object, index) => {
+                      const selected = selectedIds.has(object.id);
+                      return (
+                        <button
+                          type="button"
+                          key={object.id}
+                          onClick={() => toggleObject(object)}
+                          disabled={object.skipped}
+                          className={`flex w-full items-center gap-3 border-b border-gray-200 px-4 py-2.5 text-left transition-colors dark:border-bambu-dark-tertiary/60 ${
+                            object.skipped
+                              ? 'cursor-not-allowed bg-red-500/5 opacity-55'
+                              : selected
+                                ? 'bg-bambu-green/15 hover:bg-bambu-green/20'
+                                : 'hover:bg-gray-50 dark:hover:bg-white/5'
+                          }`}
+                        >
+                          {object.skipped || selected
+                            ? <CheckSquare className={`h-5 w-5 flex-shrink-0 ${object.skipped ? 'text-red-400' : 'text-bambu-green'}`} />
+                            : <Square className="h-5 w-5 flex-shrink-0 text-bambu-gray" />}
+                          <span className="w-8 flex-shrink-0 text-xs font-bold text-bambu-gray">{index + 1}</span>
+                          <span className={`min-w-0 flex-1 truncate text-sm ${object.skipped ? 'text-red-400 line-through' : 'text-gray-900 dark:text-white'}`}>{object.name}</span>
+                          <span className="text-[10px] text-bambu-gray">ID {object.id}</span>
+                        </button>
+                      );
+                    })}
                   </div>
-                );
-              })}
-            </div>
+                </div>
+              </div>
+
+              <footer className="flex items-center gap-3 border-t border-gray-200 bg-gray-50 px-4 py-3 dark:border-bambu-dark-tertiary dark:bg-bambu-dark">
+                <span className="mr-auto text-sm text-gray-600 dark:text-bambu-gray">
+                  {selectedIds.size === 0 ? t('printers.skipObjects.noObjectsSelected') : `${selectedIds.size}/${activeObjects.length}`}
+                </span>
+                <button type="button" onClick={onClose} className="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:border-bambu-dark-tertiary dark:text-white dark:hover:bg-white/5">
+                  {t('common.cancel')}
+                </button>
+                <button
+                  type="button"
+                  onClick={() => setConfirming(true)}
+                  disabled={!canSubmit || skipObjectsMutation.isPending}
+                  className="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-35"
+                >
+                  {skipObjectsMutation.isPending ? t('printers.skipObjects.skipping') : t('printers.skipObjects.skipSelected')}
+                </button>
+              </footer>
+            </>
           )}
-          {/* Active count badge */}
-          <div className="absolute bottom-2 right-2 px-2 py-1 bg-white/90 dark:bg-black/80 rounded text-[10px] text-gray-700 dark:text-white shadow-sm">
-            {t('printers.skipObjects.activeCount', { count: objectsData.objects.filter(o => !o.skipped).length })}
+        </section>
+      </div>
+
+      {confirming && (
+        <ConfirmModal
+          variant="warning"
+          title={t('printers.skipObjects.confirmTitle')}
+          message={skippingAllRemaining
+            ? t('printers.skipObjects.confirmAllMessage')
+            : selectedObjects.length === 1
+              // Naming one object is useful; joining 30 is a wall of text, and
+              // plates of clones share a name, so the list identifies nothing.
+              ? t('printers.skipObjects.confirmMessage', { name: selectedObjects[0].name })
+              : t('printers.skipObjects.confirmMultipleMessage', { count: selectedObjects.length })}
+          confirmText={t('printers.skipObjects.skipSelected')}
+          isLoading={skipObjectsMutation.isPending}
+          onConfirm={() => skipObjectsMutation.mutate([...selectedIds])}
+          onCancel={() => setConfirming(false)}
+        />
+      )}
+
+      {enlarged && (
+        <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/90 p-8" onClick={() => setEnlarged(false)}>
+          <button type="button" onClick={() => setEnlarged(false)} className="absolute right-4 top-4 rounded p-2 text-white/70 hover:text-white">
+            <X className="h-6 w-6" />
+          </button>
+          <div className="aspect-square max-h-[86vh] w-full max-w-[86vh]" onClick={(event) => event.stopPropagation()}>
+            {renderPlate(true)}
           </div>
         </div>
-      </div>
-    )}
-  </>
+      )}
+    </>
   );
 }

+ 2 - 0
frontend/src/i18n/locales/de.ts

@@ -481,6 +481,8 @@ export default {
       skip: 'Überspringen',
       confirmTitle: 'Objekt überspringen?',
       confirmMessage: 'Möchten Sie "{{name}}" wirklich überspringen? Dies kann nicht rückgängig gemacht werden.',
+      confirmAllMessage: 'Alle verbleibenden Objekte sind ausgewählt. Dadurch wird der Druckauftrag beendet. Fortfahren?',
+      confirmMultipleMessage: '{{count}} ausgewählte Objekte überspringen? Dies kann nicht rückgängig gemacht werden.',
     },
     // Confirm modals
     confirm: {

+ 2 - 0
frontend/src/i18n/locales/en.ts

@@ -484,6 +484,8 @@ export default {
       skip: 'Skip',
       confirmTitle: 'Skip Object?',
       confirmMessage: 'Are you sure you want to skip "{{name}}"? This cannot be undone.',
+      confirmAllMessage: 'All remaining objects are selected. This will stop the print job. Continue?',
+      confirmMultipleMessage: 'Skip {{count}} selected objects? This cannot be undone.',
     },
     // Confirm modals
     confirm: {

+ 2 - 0
frontend/src/i18n/locales/es.ts

@@ -481,6 +481,8 @@ export default {
       skip: 'Omitir',
       confirmTitle: '¿Omitir objeto?',
       confirmMessage: '¿Está seguro de que desea omitir "{{name}}"? Esto no se puede deshacer.',
+      confirmAllMessage: 'Se han seleccionado todos los objetos restantes. Esto detendrá el trabajo de impresión. ¿Continuar?',
+      confirmMultipleMessage: '¿Omitir {{count}} objetos seleccionados? Esto no se puede deshacer.',
     },
     // Confirm modals
     confirm: {

+ 2 - 0
frontend/src/i18n/locales/fr.ts

@@ -481,6 +481,8 @@ export default {
       skip: 'Sauter',
       confirmTitle: 'Sauter l\'objet ?',
       confirmMessage: 'Voulez-vous vraiment sauter "{{name}}" ? Cette action est irréversible.',
+      confirmAllMessage: 'Tous les objets restants sont sélectionnés. Cela arrêtera la tâche d’impression. Continuer ?',
+      confirmMultipleMessage: 'Sauter {{count}} objets sélectionnés ? Cette action est irréversible.',
     },
     // Confirm modals
     confirm: {

+ 2 - 0
frontend/src/i18n/locales/it.ts

@@ -481,6 +481,8 @@ export default {
       skip: 'Salta',
       confirmTitle: 'Saltare oggetto?',
       confirmMessage: 'Sei sicuro di voler saltare "{{name}}"? Questa azione non può essere annullata.',
+      confirmAllMessage: 'Sono selezionati tutti gli oggetti rimanenti. Questo interromperà il processo di stampa. Continuare?',
+      confirmMultipleMessage: 'Saltare {{count}} oggetti selezionati? Questa azione non può essere annullata.',
     },
     // Confirm modals
     confirm: {

+ 2 - 0
frontend/src/i18n/locales/ja.ts

@@ -480,6 +480,8 @@ export default {
       skip: 'スキップ',
       confirmTitle: 'オブジェクトをスキップしますか?',
       confirmMessage: '「{{name}}」をスキップしますか?この操作は元に戻せません。',
+      confirmAllMessage: '残りのオブジェクトがすべて選択されています。印刷ジョブは停止します。続行しますか?',
+      confirmMultipleMessage: '選択した{{count}}個のオブジェクトをスキップしますか?この操作は元に戻せません。',
     },
     // Confirm modals
     confirm: {

+ 3 - 1
frontend/src/i18n/locales/ko.ts

@@ -447,7 +447,9 @@ export default {
       waitForLayer: '객체를 건너뛰려면 2층 이상을 기다리세요 (현재 {{layer}}층)',
       skip: '건너뛰기',
       confirmTitle: '객체를 건너뛰시겠습니까?',
-      confirmMessage: '"{{name}}"을(를) 건너뛰시겠습니까? 이 작업은 취소할 수 없습니다.'
+      confirmMessage: '"{{name}}"을(를) 건너뛰시겠습니까? 이 작업은 취소할 수 없습니다.',
+      confirmAllMessage: '남아 있는 모든 개체가 선택되었습니다. 인쇄 작업이 중지됩니다. 계속하시겠습니까?',
+      confirmMultipleMessage: '선택한 {{count}}개의 객체를 건너뛰시겠습니까? 이 작업은 취소할 수 없습니다.',
     },
     confirm: {
       deleteTitle: '프린터 삭제',

+ 2 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -481,6 +481,8 @@ export default {
       skip: 'Ignorar',
       confirmTitle: 'Ignorar Objeto?',
       confirmMessage: 'Tem certeza de que deseja ignorar "{{name}}"? Isso não pode ser desfeito.',
+      confirmAllMessage: 'Todos os objetos restantes estão selecionados. Isso interromperá o trabalho de impressão. Continuar?',
+      confirmMultipleMessage: 'Ignorar {{count}} objetos selecionados? Isso não pode ser desfeito.',
     },
     // Confirm modals
     confirm: {

+ 2 - 0
frontend/src/i18n/locales/ru.ts

@@ -453,6 +453,8 @@ export default {
       skip: "Пропустить",
       confirmTitle: "Пропустить объект?",
       confirmMessage: "Пропустить объект «{{name}}»? Это действие нельзя отменить.",
+      confirmAllMessage: "Выбраны все оставшиеся объекты. Это остановит задание печати. Продолжить?",
+      confirmMultipleMessage: "Пропустить выбранные объекты ({{count}})? Это действие нельзя отменить.",
     },
     confirm: {
       deleteTitle: "Удалить принтер",

+ 2 - 0
frontend/src/i18n/locales/tr.ts

@@ -481,6 +481,8 @@ export default {
       skip: 'Atla',
       confirmTitle: 'Nesne Atlansın mı?',
       confirmMessage: '"{{name}}" atlamak istediğinizden emin misiniz? Bu geri alınamaz.',
+      confirmAllMessage: 'Kalan tüm nesneler seçildi. Bu, yazdırma işini durduracaktır. Devam edilsin mi?',
+      confirmMultipleMessage: 'Seçilen {{count}} nesne atlansın mı? Bu geri alınamaz.',
     },
     // Onay modalleri
     confirm: {

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -481,6 +481,8 @@ export default {
       skip: '跳过',
       confirmTitle: '跳过对象?',
       confirmMessage: '确定要跳过"{{name}}"吗?此操作无法撤销。',
+      confirmAllMessage: '已选择所有剩余对象。这将停止打印任务。是否继续?',
+      confirmMultipleMessage: '跳过选中的 {{count}} 个对象?此操作无法撤销。',
     },
     // Confirm modals
     confirm: {

+ 2 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -481,6 +481,8 @@ export default {
       skip: '跳過',
       confirmTitle: '跳過物件?',
       confirmMessage: '確定要跳過"{{name}}"嗎?此操作無法復原。',
+      confirmAllMessage: '已選取所有剩餘物件。這將停止列印工作。是否繼續?',
+      confirmMultipleMessage: '跳過選取的 {{count}} 個物件?此操作無法復原。',
     },
     // Confirm modals
     confirm: {

+ 36 - 0
frontend/src/utils/skipObjects.ts

@@ -0,0 +1,36 @@
+export function pickObjectIdAt(imageData: ImageData, x: number, y: number): number | null {
+  const pixelX = Math.max(0, Math.min(imageData.width - 1, Math.floor(x)));
+  const pixelY = Math.max(0, Math.min(imageData.height - 1, Math.floor(y)));
+  const offset = (pixelY * imageData.width + pixelX) * 4;
+  const red = imageData.data[offset];
+  const green = imageData.data[offset + 1];
+  const blue = imageData.data[offset + 2];
+  const alpha = imageData.data[offset + 3];
+  if (!alpha || (red === 0 && green === 0 && blue === 0)) return null;
+  return red + (green << 8) + (blue << 16);
+}
+
+/**
+ * Map a click on the plate canvas to a pixel in the object-ID mask.
+ *
+ * The canvas paints at the mask's own resolution and is displayed with
+ * object-contain inside a square box, so a mask that isn't square is
+ * letterboxed. Measuring off the element rect would then offset every click by
+ * the size of the bars. Returns null for clicks on a bar: those are off the
+ * plate, and pickObjectIdAt would otherwise clamp them onto whichever object
+ * touches that border.
+ */
+export function plateClickToMaskPoint(
+  bounds: { left: number; top: number; width: number; height: number },
+  maskWidth: number,
+  maskHeight: number,
+  clientX: number,
+  clientY: number,
+): { x: number; y: number } | null {
+  const scale = Math.min(bounds.width / maskWidth, bounds.height / maskHeight);
+  if (!(scale > 0)) return null;
+  const x = (clientX - bounds.left - (bounds.width - maskWidth * scale) / 2) / scale;
+  const y = (clientY - bounds.top - (bounds.height - maskHeight * scale) / 2) / scale;
+  if (x < 0 || y < 0 || x >= maskWidth || y >= maskHeight) return null;
+  return { x, y };
+}

Plik diff jest za duży
+ 0 - 1
static/assets/index-CKAbipPc.css


Plik diff jest za duży
+ 0 - 0
static/assets/index-ZS6qKcto.js


Plik diff jest za duży
+ 1 - 0
static/assets/index-kl51qImb.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DVKbiORm.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CKAbipPc.css">
+    <script type="module" crossorigin src="/assets/index-ZS6qKcto.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-kl51qImb.css">
   </head>
   <body>
     <div id="root"></div>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików