소스 검색

Render the previews properly instead of sketching them

Model preview
-------------
The camera distance came from `maxDim * 1.8`, which accounts for neither
the camera's field of view nor the viewport's aspect ratio, so a tall
narrow panel was framed as though it were square -- the model sat in the
middle with a screenful of dead space above it. Solved from the bounding
sphere against both fields of view instead, so it fills the frame at any
panel shape. Near/far now scale to the subject rather than staying at the
0.1/10000 defaults.

Lighting was two directional lamps over 0.6 flat ambient on a Phong
material: every surface facing the same way got an identical colour,
which is what flattened models into silhouettes. Now a MeshStandard
material lit by a generated RoomEnvironment through PMREMGenerator, with
ACES tone mapping so the lit side of a saturated filament colour doesn't
clip to white and drain the hue.

Added a contact shadow. Two things would have made it silently draw
nothing: the build plate is an unlit MeshBasicMaterial and cannot receive
shadows, so the catcher is a separate ShadowMaterial plane; and three's
default directional shadow camera is a +/-5 unit box, which nothing on a
256mm bed falls inside.

The PMREM render target is disposed on unmount -- it is GPU memory the
collector cannot reclaim, and this viewer is opened and closed repeatedly
from the file manager. Device pixel ratio is capped at 2; a 3x phone
screen was quadrupling fragment load for no visible gain.

G-code preview
--------------
Switched gcode-preview from `lineWidth: 2` to `renderTubes`. A 2px
screen-space line has no thickness in the scene, so it cannot occlude the
layer behind it -- hence the stringy surface and the shimmer where layers
overlap. Tubes are built from real extrusion width and height, so the
print occludes itself.

The flag is marked experimental upstream, and the 0.42 extrusion width is
a hardcoded default that is right for a 0.4 nozzle and wrong for a 0.6.
Both are worth revisiting if this holds up in use.

Modal
-----
Removed the G-code tab. G-code has its own full-page viewer, and a
preview of a model is a different question from a preview of a print.
That left dead weight behind it: the render branch, the GcodeViewer
import, the has_gcode capability (still computed, never read), the Code2
icon, and two orphaned strings in all 13 locales. One test was repurposed
to assert the tab is absent so it cannot creep back; two others only
exercised that tab's disabled state and went with it.
maziggy 4 주 전
부모
커밋
2d584e02a4

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
CHANGELOG.md


+ 3 - 48
frontend/src/__tests__/components/ModelViewerModal.test.tsx

@@ -155,7 +155,8 @@ describe('ModelViewerModal', () => {
   });
   });
 
 
   describe('tabs', () => {
   describe('tabs', () => {
-    it('renders 3D Model and G-code tabs', async () => {
+    it('renders the 3D Model tab and no G-code tab', async () => {
+      // G-code has its own full-page viewer; the modal is model-only.
       render(
       render(
         <ModelViewerModal
         <ModelViewerModal
           archiveId={1}
           archiveId={1}
@@ -166,8 +167,8 @@ describe('ModelViewerModal', () => {
 
 
       await waitFor(() => {
       await waitFor(() => {
         expect(screen.getByText('3D Model')).toBeInTheDocument();
         expect(screen.getByText('3D Model')).toBeInTheDocument();
-        expect(screen.getByText('G-code Preview')).toBeInTheDocument();
       });
       });
+      expect(screen.queryByText('G-code Preview')).not.toBeInTheDocument();
     });
     });
 
 
     it('shows not available label when model is not available', async () => {
     it('shows not available label when model is not available', async () => {
@@ -193,52 +194,6 @@ describe('ModelViewerModal', () => {
       });
       });
     });
     });
 
 
-    it('shows not sliced label when gcode is not available', async () => {
-      server.use(
-        http.get('/api/v1/archives/:id/capabilities', () => {
-          return HttpResponse.json({
-            ...mockCapabilities,
-            has_gcode: false,
-          });
-        })
-      );
-
-      render(
-        <ModelViewerModal
-          archiveId={1}
-          title="Test Model"
-          onClose={mockOnClose}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.getByText('(not sliced)')).toBeInTheDocument();
-      });
-    });
-
-    it('disables tab when capability is not available', async () => {
-      server.use(
-        http.get('/api/v1/archives/:id/capabilities', () => {
-          return HttpResponse.json({
-            ...mockCapabilities,
-            has_gcode: false,
-          });
-        })
-      );
-
-      render(
-        <ModelViewerModal
-          archiveId={1}
-          title="Test Model"
-          onClose={mockOnClose}
-        />
-      );
-
-      await waitFor(() => {
-        const gcodeTab = screen.getByText('G-code Preview').closest('button');
-        expect(gcodeTab).toBeDisabled();
-      });
-    });
   });
   });
 
 
   describe('fullscreen', () => {
   describe('fullscreen', () => {

+ 8 - 1
frontend/src/components/GcodeViewer.tsx

@@ -55,8 +55,15 @@ export function GcodeViewer({
       // Pass full color array - library uses index as tool number
       // Pass full color array - library uses index as tool number
       extrusionColor: hasMultiColor ? filamentColors : primaryColor,
       extrusionColor: hasMultiColor ? filamentColors : primaryColor,
       disableGradient: true,
       disableGradient: true,
+      // Volumetric extrusions rather than lines. `lineWidth: 2` drew each move
+      // as a 2px screen-space line, which is why the surface came out stringy,
+      // aliased, and banded where layers overlapped -- a line has no thickness
+      // in the scene, so it cannot occlude the layer behind it. Tubes are built
+      // from the real extrusion width and height, so the print reads as a solid
+      // object the way it does in a desktop slicer.
+      renderTubes: true,
+      extrusionWidth: 0.42,
       lineHeight: 0.2,
       lineHeight: 0.2,
-      lineWidth: 2,
       renderTravel: false,
       renderTravel: false,
       renderExtrusion: true,
       renderExtrusion: true,
     });
     });

+ 141 - 27
frontend/src/components/ModelViewer.tsx

@@ -4,11 +4,50 @@ import * as THREE from 'three';
 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
 import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
 import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
 import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
 import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
+import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
 import JSZip from 'jszip';
 import JSZip from 'jszip';
 import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
 import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
 import { Button } from './Button';
 import { Button } from './Button';
 import { getAuthToken } from '../api/client';
 import { getAuthToken } from '../api/client';
 
 
+/**
+ * Frame the camera on a bounding box.
+ *
+ * The previous heuristic was `maxDim * 1.8`, which ignores both the camera's
+ * field of view and the viewport's aspect ratio. In a tall, narrow panel the
+ * horizontal field of view is much narrower than the vertical one, so that
+ * distance pushed the model into the middle of the frame with a screenful of
+ * empty space above it. Solving the distance from the bounding *sphere*
+ * against both fields of view fills the frame at any viewport shape.
+ */
+function fitCameraToBox(
+  camera: THREE.PerspectiveCamera,
+  controls: OrbitControls,
+  box: THREE.Box3,
+  padding = 1.15,
+): void {
+  const size = box.getSize(new THREE.Vector3());
+  const center = box.getCenter(new THREE.Vector3());
+  // Circumscribed sphere: conservative, so the model never crops on rotation.
+  const radius = Math.max(size.length() / 2, 0.001);
+
+  const vFov = THREE.MathUtils.degToRad(camera.fov);
+  const hFov = 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect);
+  const distance = padding * Math.max(radius / Math.sin(vFov / 2), radius / Math.sin(hFov / 2));
+
+  // Keep the established three-quarter view; only the distance changes.
+  const direction = new THREE.Vector3(0.7, 0.5, 0.7).normalize();
+  camera.position.copy(center).addScaledVector(direction, distance);
+  // Clip planes scaled to the subject, so a small model doesn't z-fight and a
+  // large one isn't sliced by the far plane.
+  camera.near = Math.max(distance / 1000, 0.01);
+  camera.far = distance + radius * 4;
+  camera.updateProjectionMatrix();
+
+  controls.target.copy(center);
+  controls.update();
+}
+
 interface BuildVolume {
 interface BuildVolume {
   x: number;
   x: number;
   y: number;
   y: number;
@@ -524,14 +563,21 @@ function buildModelGroup(
   const group = new THREE.Group();
   const group = new THREE.Group();
 
 
   // Create materials for each extruder color
   // Create materials for each extruder color
-  const getMaterial = (extruder: number): THREE.MeshPhongMaterial => {
+  const getMaterial = (extruder: number): THREE.MeshStandardMaterial => {
     const defaultColor = '#00ae42';
     const defaultColor = '#00ae42';
     const colorStr = filamentColors?.[extruder] || defaultColor;
     const colorStr = filamentColors?.[extruder] || defaultColor;
     // Convert hex color string to THREE.js color
     // Convert hex color string to THREE.js color
     const color = new THREE.Color(colorStr);
     const color = new THREE.Color(colorStr);
-    return new THREE.MeshPhongMaterial({
+    // Matte plastic against the scene's environment map. Phong lit only by
+    // direct lights gave every same-facing surface an identical colour, which
+    // is what flattened models into silhouettes. Roughness is high because
+    // FDM prints are not glossy, but not 1.0 -- a little specular is what
+    // makes layer-scale surface detail legible.
+    return new THREE.MeshStandardMaterial({
       color,
       color,
-      shininess: 30,
+      roughness: 0.62,
+      metalness: 0.0,
+      envMapIntensity: 0.55,
       flatShading: false,
       flatShading: false,
     });
     });
   };
   };
@@ -605,6 +651,7 @@ function buildModelGroup(
     if (mergedGeometry) {
     if (mergedGeometry) {
       const material = getMaterial(extruder);
       const material = getMaterial(extruder);
       const mesh = new THREE.Mesh(mergedGeometry, material);
       const mesh = new THREE.Mesh(mergedGeometry, material);
+      mesh.castShadow = true;
       group.add(mesh);
       group.add(mesh);
     }
     }
 
 
@@ -632,6 +679,12 @@ export function ModelViewer({
   const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
   const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
   const sceneRef = useRef<THREE.Scene | null>(null);
   const sceneRef = useRef<THREE.Scene | null>(null);
   const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
   const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
+  // Held so the environment map and its generator can be released on unmount;
+  // a PMREM render target is GPU memory the garbage collector cannot reclaim.
+  const pmremRef = useRef<THREE.PMREMGenerator | null>(null);
+  const environmentRef = useRef<THREE.Texture | null>(null);
+  const keyLightRef = useRef<THREE.DirectionalLight | null>(null);
+  const shadowCatcherRef = useRef<THREE.Mesh | null>(null);
   const controlsRef = useRef<OrbitControls | null>(null);
   const controlsRef = useRef<OrbitControls | null>(null);
   const modelGroupRef = useRef<THREE.Group | null>(null);
   const modelGroupRef = useRef<THREE.Group | null>(null);
   const plateRef = useRef<THREE.Mesh | null>(null);
   const plateRef = useRef<THREE.Mesh | null>(null);
@@ -661,7 +714,18 @@ export function ModelViewer({
     // Renderer
     // Renderer
     const renderer = new THREE.WebGLRenderer({ antialias: true });
     const renderer = new THREE.WebGLRenderer({ antialias: true });
     renderer.setSize(width, height);
     renderer.setSize(width, height);
-    renderer.setPixelRatio(window.devicePixelRatio);
+    // Cap the device pixel ratio: a 3x phone screen quadruples the fragment
+    // load for no visible gain on a model this simple.
+    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+    // Filmic tone mapping keeps the bright side of a saturated filament colour
+    // from clipping to white, which is what made every model read as flat paint.
+    renderer.toneMapping = THREE.ACESFilmicToneMapping;
+    // Deliberately below 1.0: RoomEnvironment is a bright white box, and
+    // anything at or above unity clipped the lit side of a saturated
+    // filament colour to white, draining the hue out of the model.
+    renderer.toneMappingExposure = 0.85;
+    renderer.shadowMap.enabled = true;
+    renderer.shadowMap.type = THREE.PCFSoftShadowMap;
     container.appendChild(renderer.domElement);
     container.appendChild(renderer.domElement);
     rendererRef.current = renderer;
     rendererRef.current = renderer;
 
 
@@ -671,17 +735,40 @@ export function ModelViewer({
     controls.dampingFactor = 0.05;
     controls.dampingFactor = 0.05;
     controlsRef.current = controls;
     controlsRef.current = controls;
 
 
-    // Lights
-    const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
-    scene.add(ambientLight);
-
-    const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
-    directionalLight.position.set(100, 100, 100);
-    scene.add(directionalLight);
-
-    const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
-    directionalLight2.position.set(-100, 50, -100);
-    scene.add(directionalLight2);
+    // Image-based lighting. A generated room gives the model a real light
+    // environment -- soft gradients across curved surfaces, a hint of
+    // reflection -- which is the single biggest difference between this and a
+    // desktop slicer's viewport. Two directional lights on flat ambient could
+    // never produce that; every surface facing the same way got the same
+    // colour, so the model read as a flat silhouette.
+    const pmrem = new THREE.PMREMGenerator(renderer);
+    const environment = pmrem.fromScene(new RoomEnvironment(), 0.04);
+    scene.environment = environment.texture;
+    pmremRef.current = pmrem;
+    environmentRef.current = environment.texture;
+
+    // One key light on top, purely for the contact shadow and a highlight
+    // direction; the environment supplies the fill.
+    // Mostly overhead. An oblique key threw a long shadow across the whole
+    // bed; a print sitting on a plate wants a contact shadow beneath it.
+    const keyLight = new THREE.DirectionalLight(0xffffff, 0.75);
+    keyLight.position.set(60, 260, 90);
+    keyLight.castShadow = true;
+    keyLight.shadow.mapSize.set(2048, 2048);
+    keyLight.shadow.bias = -0.0005;
+    keyLight.shadow.normalBias = 0.02;
+    // Three's default shadow camera is a +/-5 unit box; on a 256mm bed the
+    // model falls entirely outside it and no shadow is drawn at all.
+    const shadowExtent = Math.max(buildVolume.x, buildVolume.y) * 0.75;
+    keyLight.shadow.camera.left = -shadowExtent;
+    keyLight.shadow.camera.right = shadowExtent;
+    keyLight.shadow.camera.top = shadowExtent;
+    keyLight.shadow.camera.bottom = -shadowExtent;
+    keyLight.shadow.camera.near = 1;
+    keyLight.shadow.camera.far = shadowExtent * 6;
+    keyLight.shadow.camera.updateProjectionMatrix();
+    scene.add(keyLight);
+    keyLightRef.current = keyLight;
 
 
     // Grid - use the larger dimension for the grid size
     // Grid - use the larger dimension for the grid size
     const gridSize = Math.max(buildVolume.x, buildVolume.y);
     const gridSize = Math.max(buildVolume.x, buildVolume.y);
@@ -704,6 +791,21 @@ export function ModelViewer({
     scene.add(plate);
     scene.add(plate);
     plateRef.current = plate;
     plateRef.current = plate;
 
 
+    // Dedicated shadow catcher just above the plate. The plate itself is an
+    // unlit MeshBasicMaterial and cannot receive shadows; ShadowMaterial draws
+    // nothing but the shadow, so the tinted plate shows through unchanged.
+    // Without a contact shadow the model reads as pasted onto the background
+    // rather than resting on the bed.
+    const shadowCatcher = new THREE.Mesh(
+      new THREE.PlaneGeometry(buildVolume.x, buildVolume.y),
+      new THREE.ShadowMaterial({ opacity: 0.22 }),
+    );
+    shadowCatcher.rotation.x = -Math.PI / 2;
+    shadowCatcher.position.y = -0.49;
+    shadowCatcher.receiveShadow = true;
+    scene.add(shadowCatcher);
+    shadowCatcherRef.current = shadowCatcher;
+
     // Animation loop - keep it simple for reliability
     // Animation loop - keep it simple for reliability
     let animationId: number;
     let animationId: number;
     const animate = () => {
     const animate = () => {
@@ -787,11 +889,21 @@ export function ModelViewer({
       resizeObserver.disconnect();
       resizeObserver.disconnect();
       cancelAnimationFrame(animationId);
       cancelAnimationFrame(animationId);
       controls.dispose();
       controls.dispose();
+      // The environment map is a render target; disposing the renderer alone
+      // leaves it allocated on the GPU, and this viewer is opened and closed
+      // repeatedly from the file manager.
+      environmentRef.current?.dispose();
+      environmentRef.current = null;
+      pmremRef.current?.dispose();
+      pmremRef.current = null;
+      scene.environment = null;
       renderer.dispose();
       renderer.dispose();
       container.removeChild(renderer.domElement);
       container.removeChild(renderer.domElement);
       modelGroupRef.current = null;
       modelGroupRef.current = null;
       plateRef.current = null;
       plateRef.current = null;
       gridRef.current = null;
       gridRef.current = null;
+      keyLightRef.current = null;
+      shadowCatcherRef.current = null;
     };
     };
   }, [url, buildVolume, fileType, t]);
   }, [url, buildVolume, fileType, t]);
 
 
@@ -808,8 +920,14 @@ export function ModelViewer({
     const group = isStlModel
     const group = isStlModel
       ? (() => {
       ? (() => {
           const materialColor = filamentColors?.[0] || '#00ae42';
           const materialColor = filamentColors?.[0] || '#00ae42';
-          const material = new THREE.MeshPhongMaterial({ color: new THREE.Color(materialColor), shininess: 30 });
+          const material = new THREE.MeshStandardMaterial({
+            color: new THREE.Color(materialColor),
+            roughness: 0.62,
+            metalness: 0.0,
+            envMapIntensity: 0.55,
+          });
           const mesh = new THREE.Mesh(stlGeometry!, material);
           const mesh = new THREE.Mesh(stlGeometry!, material);
+          mesh.castShadow = true;
           const stlGroup = new THREE.Group();
           const stlGroup = new THREE.Group();
           stlGroup.add(mesh);
           stlGroup.add(mesh);
           return stlGroup;
           return stlGroup;
@@ -872,21 +990,17 @@ export function ModelViewer({
       gridRef.current.position.z = plateCenterZ;
       gridRef.current.position.z = plateCenterZ;
     }
     }
 
 
+    // Follows the plate, or the shadow lands on empty space beside the bed.
+    if (shadowCatcherRef.current) {
+      shadowCatcherRef.current.position.x = plateCenterX;
+      shadowCatcherRef.current.position.z = plateCenterZ;
+    }
+
     // Recalculate bounding box after positioning
     // Recalculate bounding box after positioning
     const finalBox = new THREE.Box3().setFromObject(group);
     const finalBox = new THREE.Box3().setFromObject(group);
-    const finalCenter = finalBox.getCenter(new THREE.Vector3());
-    const finalSize = finalBox.getSize(new THREE.Vector3());
 
 
     // Adjust camera to fit model
     // Adjust camera to fit model
-    const maxDim = Math.max(finalSize.x, finalSize.y, finalSize.z);
-    const cameraDistance = maxDim * 1.8;
-    cameraRef.current.position.set(
-      finalCenter.x + cameraDistance * 0.7,
-      finalCenter.y + cameraDistance * 0.5,
-      finalCenter.z + cameraDistance * 0.7
-    );
-    controlsRef.current.target.copy(finalCenter);
-    controlsRef.current.update();
+    fitCameraToBox(cameraRef.current, controlsRef.current, finalBox);
 
 
     setLoading(false);
     setLoading(false);
   }, [parsedData, stlGeometry, selectedPlateId, filamentColors, buildVolume]);
   }, [parsedData, stlGeometry, selectedPlateId, filamentColors, buildVolume]);

+ 5 - 31
frontend/src/components/ModelViewerModal.tsx

@@ -1,16 +1,16 @@
 import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
 import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
-import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Minimize2, ChevronDown } from 'lucide-react';
+import { X, ExternalLink, Box, Cog, Loader2, Layers, Check, Maximize2, Minimize2, ChevronDown } from 'lucide-react';
 import { ModelViewer } from './ModelViewer';
 import { ModelViewer } from './ModelViewer';
-import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
 import { Button } from './Button';
 import { api, withStreamToken } from '../api/client';
 import { api, withStreamToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import { isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 
 
-type ViewTab = '3d' | 'gcode';
+// The modal shows the model only; G-code has its own full-page viewer.
+type ViewTab = '3d';
 
 
 interface ModelViewerModalProps {
 interface ModelViewerModalProps {
   archiveId?: number;
   archiveId?: number;
@@ -27,7 +27,6 @@ interface ModelViewerModalProps {
 
 
 interface Capabilities {
 interface Capabilities {
   has_model: boolean;
   has_model: boolean;
-  has_gcode: boolean;
   has_source: boolean;
   has_source: boolean;
   build_volume: { x: number; y: number; z: number };
   build_volume: { x: number; y: number; z: number };
   filament_colors: string[];
   filament_colors: string[];
@@ -173,15 +172,13 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
       // the 3D-tab + g-code-tab gating (#1543).
       // the 3D-tab + g-code-tab gating (#1543).
       const isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf';
       const isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf';
       const hasModel = isThreeMfFamily || normalizedType === 'stl';
       const hasModel = isThreeMfFamily || normalizedType === 'stl';
-      const hasGcode = isThreeMfFamily || normalizedType === 'gcode';
       setCapabilities({
       setCapabilities({
         has_model: hasModel,
         has_model: hasModel,
-        has_gcode: hasGcode,
         has_source: false,
         has_source: false,
         build_volume: { x: 256, y: 256, z: 256 },
         build_volume: { x: 256, y: 256, z: 256 },
         filament_colors: [],
         filament_colors: [],
       });
       });
-      setActiveTab(hasModel ? '3d' : hasGcode ? 'gcode' : null);
+      setActiveTab(hasModel ? '3d' : null);
       setLoading(false);
       setLoading(false);
       return;
       return;
     }
     }
@@ -199,14 +196,12 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
         // Auto-select the first available tab
         // Auto-select the first available tab
         if (caps.has_model) {
         if (caps.has_model) {
           setActiveTab('3d');
           setActiveTab('3d');
-        } else if (caps.has_gcode) {
-          setActiveTab('gcode');
         }
         }
         setLoading(false);
         setLoading(false);
       })
       })
       .catch(() => {
       .catch(() => {
         // Fallback to 3D model tab if capabilities check fails
         // Fallback to 3D model tab if capabilities check fails
-        setCapabilities({ has_model: true, has_gcode: false, has_source: false, build_volume: { x: 256, y: 256, z: 256 }, filament_colors: [] });
+        setCapabilities({ has_model: true, has_source: false, build_volume: { x: 256, y: 256, z: 256 }, filament_colors: [] });
         setActiveTab('3d');
         setActiveTab('3d');
         setLoading(false);
         setLoading(false);
       });
       });
@@ -505,21 +500,6 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
               {t('modelViewer.tabs.model')}
               {t('modelViewer.tabs.model')}
               {!capabilities.has_model && <span className="text-xs">({t('modelViewer.notAvailable')})</span>}
               {!capabilities.has_model && <span className="text-xs">({t('modelViewer.notAvailable')})</span>}
             </button>
             </button>
-            <button
-              onClick={() => capabilities.has_gcode && setActiveTab('gcode')}
-              disabled={!capabilities.has_gcode}
-              className={`flex items-center gap-2 px-6 py-3 text-sm font-medium transition-colors ${
-                activeTab === 'gcode'
-                  ? 'text-bambu-green border-b-2 border-bambu-green'
-                  : capabilities.has_gcode
-                    ? 'text-bambu-gray hover:text-white'
-                    : 'text-bambu-gray/30 cursor-not-allowed'
-              }`}
-            >
-              <Code2 className="w-4 h-4" />
-              {t('modelViewer.tabs.gcode')}
-              {!capabilities.has_gcode && <span className="text-xs">({t('modelViewer.notSliced')})</span>}
-            </button>
           </div>
           </div>
         )}
         )}
 
 
@@ -761,12 +741,6 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
                   />
                   />
               </div>
               </div>
             </div>
             </div>
-          ) : activeTab === 'gcode' && capabilities ? (
-            <GcodeViewer
-              gcodeUrl={isLibrary ? api.getLibraryFileGcodeUrl(libraryFileId!) : api.getArchiveGcode(archiveId!)}
-              filamentColors={capabilities.filament_colors}
-              className="w-full h-full"
-            />
           ) : (
           ) : (
             <div className="w-full h-full flex items-center justify-center text-bambu-gray">
             <div className="w-full h-full flex items-center justify-center text-bambu-gray">
               {t('modelViewer.noPreview')}
               {t('modelViewer.noPreview')}

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

@@ -5597,10 +5597,8 @@ export default {
     openInSlicerFailed: 'Konnte nicht im Slicer öffnen',
     openInSlicerFailed: 'Konnte nicht im Slicer öffnen',
     tabs: {
     tabs: {
       model: '3D-Modell',
       model: '3D-Modell',
-      gcode: 'G-Code Vorschau',
     },
     },
     notAvailable: 'nicht verfügbar',
     notAvailable: 'nicht verfügbar',
-    notSliced: 'nicht geslicet',
     plates: 'Platten',
     plates: 'Platten',
     allPlates: 'Alle Platten',
     allPlates: 'Alle Platten',
     plateNumber: 'Platte {{number}}',
     plateNumber: 'Platte {{number}}',

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

@@ -5646,10 +5646,8 @@ export default {
     openInSlicerFailed: 'Could not open in slicer',
     openInSlicerFailed: 'Could not open in slicer',
     tabs: {
     tabs: {
       model: '3D Model',
       model: '3D Model',
-      gcode: 'G-code Preview',
     },
     },
     notAvailable: 'not available',
     notAvailable: 'not available',
-    notSliced: 'not sliced',
     plates: 'Plates',
     plates: 'Plates',
     allPlates: 'All Plates',
     allPlates: 'All Plates',
     plateNumber: 'Plate {{number}}',
     plateNumber: 'Plate {{number}}',

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

@@ -5605,10 +5605,8 @@ export default {
     openInSlicerFailed: 'No se pudo abrir en el laminador',
     openInSlicerFailed: 'No se pudo abrir en el laminador',
     tabs: {
     tabs: {
       model: 'Modelo 3D',
       model: 'Modelo 3D',
-      gcode: 'Vista previa de G-code',
     },
     },
     notAvailable: 'no disponible',
     notAvailable: 'no disponible',
-    notSliced: 'no laminado',
     plates: 'Camas',
     plates: 'Camas',
     allPlates: 'Todas las camas',
     allPlates: 'Todas las camas',
     plateNumber: 'Cama {{number}}',
     plateNumber: 'Cama {{number}}',

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

@@ -5587,10 +5587,8 @@ export default {
     openInSlicerFailed: "Impossible d'ouvrir dans le slicer",
     openInSlicerFailed: "Impossible d'ouvrir dans le slicer",
     tabs: {
     tabs: {
       model: 'Modèle 3D',
       model: 'Modèle 3D',
-      gcode: 'Aperçu G-code',
     },
     },
     notAvailable: 'indisponible',
     notAvailable: 'indisponible',
-    notSliced: 'pas découpé',
     plates: 'Plateaux',
     plates: 'Plateaux',
     allPlates: 'Tous les plateaux',
     allPlates: 'Tous les plateaux',
     plateNumber: 'Plateau {{number}}',
     plateNumber: 'Plateau {{number}}',

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

@@ -5586,10 +5586,8 @@ export default {
     openInSlicerFailed: 'Impossibile aprire nello slicer',
     openInSlicerFailed: 'Impossibile aprire nello slicer',
     tabs: {
     tabs: {
       model: 'Modello 3D',
       model: 'Modello 3D',
-      gcode: 'Anteprima G-code',
     },
     },
     notAvailable: 'non disponibile',
     notAvailable: 'non disponibile',
-    notSliced: 'non sezionato',
     plates: 'Piatti',
     plates: 'Piatti',
     allPlates: 'Tutti i piatti',
     allPlates: 'Tutti i piatti',
     plateNumber: 'Piatto {{number}}',
     plateNumber: 'Piatto {{number}}',

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

@@ -5598,10 +5598,8 @@ export default {
     openInSlicerFailed: 'スライサーで開けませんでした',
     openInSlicerFailed: 'スライサーで開けませんでした',
     tabs: {
     tabs: {
       model: '3Dモデル',
       model: '3Dモデル',
-      gcode: 'G-codeプレビュー',
     },
     },
     notAvailable: '利用不可',
     notAvailable: '利用不可',
-    notSliced: '未スライス',
     plates: 'プレート',
     plates: 'プレート',
     allPlates: '全プレート',
     allPlates: '全プレート',
     plateNumber: 'プレート {{number}}',
     plateNumber: 'プレート {{number}}',

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

@@ -5331,10 +5331,8 @@ export default {
     openInSlicerFailed: '슬라이서에서 열 수 없습니다',
     openInSlicerFailed: '슬라이서에서 열 수 없습니다',
     tabs: {
     tabs: {
       model: '3D 모델',
       model: '3D 모델',
-      gcode: 'G-code 미리보기'
     },
     },
     notAvailable: '사용 불가',
     notAvailable: '사용 불가',
-    notSliced: '슬라이싱되지 않음',
     plates: '플레이트',
     plates: '플레이트',
     allPlates: '모든 플레이트',
     allPlates: '모든 플레이트',
     plateNumber: '플레이트 {{number}}',
     plateNumber: '플레이트 {{number}}',

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

@@ -5586,10 +5586,8 @@ export default {
     openInSlicerFailed: 'Não foi possível abrir no fatiador',
     openInSlicerFailed: 'Não foi possível abrir no fatiador',
     tabs: {
     tabs: {
       model: 'Modelo 3D',
       model: 'Modelo 3D',
-      gcode: 'Pré-visualização G-code',
     },
     },
     notAvailable: 'Não disponível',
     notAvailable: 'Não disponível',
-    notSliced: 'Não fatiado',
     plates: 'Placas',
     plates: 'Placas',
     allPlates: 'Todas as Placas',
     allPlates: 'Todas as Placas',
     plateNumber: 'Placa {{number}}',
     plateNumber: 'Placa {{number}}',

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

@@ -5319,10 +5319,8 @@ export default {
     openInSlicerFailed: "Не удалось открыть в слайсере",
     openInSlicerFailed: "Не удалось открыть в слайсере",
     tabs: {
     tabs: {
       model: "3D-модель",
       model: "3D-модель",
-      gcode: "Предпросмотр G-code",
     },
     },
     notAvailable: "недоступно",
     notAvailable: "недоступно",
-    notSliced: "не нарезано",
     plates: "Пластины",
     plates: "Пластины",
     allPlates: "Все пластины",
     allPlates: "Все пластины",
     plateNumber: "Пластина {{number}}",
     plateNumber: "Пластина {{number}}",

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

@@ -5561,10 +5561,8 @@ export default {
     openInSlicerFailed: 'Dilimleyicide açılamadı',
     openInSlicerFailed: 'Dilimleyicide açılamadı',
     tabs: {
     tabs: {
       model: '3B Model',
       model: '3B Model',
-      gcode: 'G-kod Önizleme',
     },
     },
     notAvailable: 'mevcut değil',
     notAvailable: 'mevcut değil',
-    notSliced: 'dilimlenmemiş',
     plates: 'Plakalar',
     plates: 'Plakalar',
     allPlates: 'Tüm Plakalar',
     allPlates: 'Tüm Plakalar',
     plateNumber: 'Plaka {{number}}',
     plateNumber: 'Plaka {{number}}',

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

@@ -5640,10 +5640,8 @@ export default {
     openInSlicerFailed: "Не вдалося відкрити у слайсері",
     openInSlicerFailed: "Не вдалося відкрити у слайсері",
     tabs: {
     tabs: {
       model: "3D-модель",
       model: "3D-модель",
-      gcode: "Попередній перегляд G-коду",
     },
     },
     notAvailable: "недоступно",
     notAvailable: "недоступно",
-    notSliced: "не нарізано",
     plates: "Пластини",
     plates: "Пластини",
     allPlates: "Усі пластини",
     allPlates: "Усі пластини",
     plateNumber: "Пластина {{number}}",
     plateNumber: "Пластина {{number}}",

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

@@ -5586,10 +5586,8 @@ export default {
     openInSlicerFailed: '无法在切片软件中打开',
     openInSlicerFailed: '无法在切片软件中打开',
     tabs: {
     tabs: {
       model: '3D 模型',
       model: '3D 模型',
-      gcode: 'G-code 预览',
     },
     },
     notAvailable: '不可用',
     notAvailable: '不可用',
-    notSliced: '未切片',
     plates: '板',
     plates: '板',
     allPlates: '所有板',
     allPlates: '所有板',
     plateNumber: '板 {{number}}',
     plateNumber: '板 {{number}}',

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

@@ -5586,10 +5586,8 @@ export default {
     openInSlicerFailed: '無法在切片軟體中開啟',
     openInSlicerFailed: '無法在切片軟體中開啟',
     tabs: {
     tabs: {
       model: '3D 模型',
       model: '3D 模型',
-      gcode: 'G-code 預覽',
     },
     },
     notAvailable: '不可用',
     notAvailable: '不可用',
-    notSliced: '未切片',
     plates: '板',
     plates: '板',
     allPlates: '所有板',
     allPlates: '所有板',
     plateNumber: '板 {{number}}',
     plateNumber: '板 {{number}}',

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
static/assets/index-CRGw6Y1U.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-D1NzqW4U.js"></script>
+    <script type="module" crossorigin src="/assets/index-CRGw6Y1U.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-D4VkH83v.css">
     <link rel="stylesheet" crossorigin href="/assets/index-D4VkH83v.css">
   </head>
   </head>
   <body>
   <body>

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.