import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'; import JSZip from 'jszip'; import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react'; import { Button } from './Button'; import { getAuthToken } from '../api/client'; interface BuildVolume { x: number; y: number; z: number; } interface ModelViewerProps { url: string; fileType?: string; buildVolume?: BuildVolume; filamentColors?: string[]; selectedPlateId?: number | null; className?: string; } interface MeshData { vertices: number[]; triangles: number[]; extruder: number; // Per-mesh extruder index for coloring } interface ObjectData { id: string; meshes: MeshData[]; defaultExtruder: number; // Default extruder for object (used if mesh doesn't have specific one) plateId?: number | null; } interface BuildItem { objectId: string; transform: THREE.Matrix4; extruder?: number; // Can override object's extruder plateId?: number | null; } interface Parsed3MFData { objects: Map; buildItems: BuildItem[]; plateBounds: Map; plateOffsets: Map; } // Yield to the browser event loop so the main thread can repaint, process // user input (especially the modal's close button), and avoid the // "page unresponsive" dialog while we crunch through large 3MFs in // straight-line JS. setTimeout(_, 0) is sufficient — we don't need rAF // here, the goal is just to surrender control so queued tasks run. function nextTick(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } // Yield once per N iterations of a hot loop. Picked so each batch is // ~5-10 ms of work on a typical desktop — fine-grained enough to keep // frames flowing, coarse enough not to drown the loop in setTimeout // dispatch overhead. Adjust if profiling shows otherwise. const YIELD_EVERY_N_VERTICES = 20000; const YIELD_EVERY_N_TRIANGLES = 20000; // Parse 3MF transform - keep in 3MF coordinate space (Z-up) function parseTransform3MF(transformStr: string | null): THREE.Matrix4 { const matrix = new THREE.Matrix4(); if (!transformStr) { return matrix; // Identity matrix } // 3MF transform is a 3x4 affine matrix in row-major order: // "m00 m01 m02 m10 m11 m12 m20 m21 m22 m30 m31 m32" // Where (m30, m31, m32) is the translation vector const values = transformStr.trim().split(/\s+/).map(parseFloat); if (values.length >= 12) { // Three.js Matrix4.set takes row-major order arguments: // set(n11, n12, n13, n14, n21, n22, n23, n24, n31, n32, n33, n34, n41, n42, n43, n44) // 3MF row-major: m00, m01, m02, m10, m11, m12, m20, m21, m22, m30, m31, m32 matrix.set( values[0], values[1], values[2], values[9], // m00, m01, m02, tx values[3], values[4], values[5], values[10], // m10, m11, m12, ty values[6], values[7], values[8], values[11], // m20, m21, m22, tz 0, 0, 0, 1 ); } return matrix; } // Alias for backwards compatibility const parseTransform = parseTransform3MF; async function parseMeshFromDoc(doc: Document, defaultExtruder: number = 0): Promise { const meshes: MeshData[] = []; const meshElements = doc.getElementsByTagName('mesh'); for (let j = 0; j < meshElements.length; j++) { const meshEl = meshElements[j]; const vertices: number[] = []; const triangles: number[] = []; const vertexElements = meshEl.getElementsByTagName('vertex'); for (let k = 0; k < vertexElements.length; k++) { const v = vertexElements[k]; vertices.push( parseFloat(v.getAttribute('x') || '0'), parseFloat(v.getAttribute('y') || '0'), parseFloat(v.getAttribute('z') || '0') ); if (k > 0 && k % YIELD_EVERY_N_VERTICES === 0) { await nextTick(); } } const triangleElements = meshEl.getElementsByTagName('triangle'); for (let k = 0; k < triangleElements.length; k++) { const t = triangleElements[k]; triangles.push( parseInt(t.getAttribute('v1') || '0'), parseInt(t.getAttribute('v2') || '0'), parseInt(t.getAttribute('v3') || '0') ); if (k > 0 && k % YIELD_EVERY_N_TRIANGLES === 0) { await nextTick(); } } if (vertices.length > 0 && triangles.length > 0) { meshes.push({ vertices, triangles, extruder: defaultExtruder }); } } return meshes; } function parsePlateIdFromAttributes(element: Element): number | null { const plateAttribute = Array.from(element.attributes).find((attr) => { const name = attr.name.toLowerCase(); return ( name === 'plate_id' || name === 'plater_id' || name === 'plateid' || name === 'platerid' || name.endsWith(':plate_id') || name.endsWith(':plater_id') ); }); if (!plateAttribute?.value) return null; const parsed = Number.parseInt(plateAttribute.value, 10); return Number.isFinite(parsed) ? parsed : null; } async function parse3MF(arrayBuffer: ArrayBuffer): Promise { let zip: JSZip; try { zip = await JSZip.loadAsync(arrayBuffer); } catch { throw new Error('Unsupported file format'); } const objects = new Map(); const buildItems: BuildItem[] = []; const plateBounds = new Map(); const plateOffsets = new Map(); const parser = new DOMParser(); // Helper to load and parse a model file from the zip async function loadModelFile(path: string): Promise { // Normalize path (remove leading slash) const normalizedPath = path.startsWith('/') ? path.slice(1) : path; const file = zip.files[normalizedPath]; if (!file) return null; const content = await file.async('string'); return parser.parseFromString(content, 'application/xml'); } // Parse model_settings.config to get extruder assignments // Maps: object ID -> default extruder, and (object ID, part ID) -> part-specific extruder const extruderMapById = new Map(); const partExtruderMap = new Map(); // Key: "objectId:partId" const objectNameById = new Map(); const plateAssignmentsByObjectId = new Map(); const modelSettingsFile = zip.files['Metadata/model_settings.config']; if (modelSettingsFile) { try { const content = await modelSettingsFile.async('string'); const doc = parser.parseFromString(content, 'application/xml'); const objectElements = doc.getElementsByTagName('object'); for (let i = 0; i < objectElements.length; i++) { const objEl = objectElements[i]; const objectId = objEl.getAttribute('id'); if (!objectId) continue; // Find object-level extruder + name const directMetadata = Array.from(objEl.children).filter( (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder' ); if (directMetadata.length > 0) { const extruderVal = directMetadata[0].getAttribute('value'); if (extruderVal) { extruderMapById.set(objectId, Math.max(0, parseInt(extruderVal, 10) - 1)); } } const nameMetadata = Array.from(objEl.children).find( (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'name' ); const objectName = nameMetadata?.getAttribute('value'); if (objectName) { objectNameById.set(objectId, objectName); } // Find part-level extruders const partElements = objEl.getElementsByTagName('part'); for (let j = 0; j < partElements.length; j++) { const partEl = partElements[j]; const partId = partEl.getAttribute('id'); if (!partId) continue; // Look for extruder in part's direct children const partMetadata = Array.from(partEl.children).filter( (el) => el.tagName === 'metadata' && el.getAttribute('key') === 'extruder' ); if (partMetadata.length > 0) { const extruderVal = partMetadata[0].getAttribute('value'); if (extruderVal) { partExtruderMap.set(`${objectId}:${partId}`, Math.max(0, parseInt(extruderVal, 10) - 1)); } } } } // Parse plate -> object assignments const plateElements = doc.getElementsByTagName('plate'); for (let i = 0; i < plateElements.length; i++) { const plateEl = plateElements[i]; let plateId: number | null = null; const metadataElements = plateEl.getElementsByTagName('metadata'); let plateOffsetX = 0; let plateOffsetY = 0; for (let j = 0; j < metadataElements.length; j++) { const metaEl = metadataElements[j]; const key = metaEl.getAttribute('key'); if (key === 'plater_id' || key === 'plate_id') { const value = metaEl.getAttribute('value'); if (value) { const parsed = Number.parseInt(value, 10); if (Number.isFinite(parsed)) { plateId = parsed; } } } else if (key === 'pos_x') { const value = metaEl.getAttribute('value'); const parsed = value ? Number.parseFloat(value) : Number.NaN; if (Number.isFinite(parsed)) { plateOffsetX = parsed; } } else if (key === 'pos_y') { const value = metaEl.getAttribute('value'); const parsed = value ? Number.parseFloat(value) : Number.NaN; if (Number.isFinite(parsed)) { plateOffsetY = parsed; } } } if (plateId == null) continue; if (plateOffsetX !== 0 || plateOffsetY !== 0) { plateOffsets.set(plateId, { offsetX: plateOffsetX, offsetY: plateOffsetY }); } const modelInstances = plateEl.getElementsByTagName('model_instance'); for (let j = 0; j < modelInstances.length; j++) { const instanceEl = modelInstances[j]; const instanceMetadata = instanceEl.getElementsByTagName('metadata'); for (let k = 0; k < instanceMetadata.length; k++) { const metaEl = instanceMetadata[k]; if (metaEl.getAttribute('key') === 'object_id') { const value = metaEl.getAttribute('value'); if (value) { plateAssignmentsByObjectId.set(value, plateId); } } } } } } catch { // Silently ignore model_settings.config parsing errors } } // Parse plate_*.json for plate assignments by object name (source-only / unsliced files) const plateAssignmentsByName = new Map(); const plateJsonNames = Object.keys(zip.files).filter( (name) => name.startsWith('Metadata/plate_') && name.endsWith('.json') ); for (const name of plateJsonNames) { const match = name.match(/^Metadata\/plate_(\d+)\.json$/); if (!match) continue; const plateIndex = Number.parseInt(match[1], 10); if (!Number.isFinite(plateIndex)) continue; try { const payload = await zip.files[name].async('string'); const json = JSON.parse(payload) as { bbox_objects?: Array<{ name?: string }>; bbox_all?: number[] }; const objectsList = json.bbox_objects ?? []; for (const entry of objectsList) { if (entry?.name) { plateAssignmentsByName.set(entry.name, plateIndex); } } if (Array.isArray(json.bbox_all) && json.bbox_all.length >= 4) { const [minX, minY, maxX, maxY] = json.bbox_all; if ([minX, minY, maxX, maxY].every((value) => Number.isFinite(value))) { plateBounds.set(plateIndex, { minX, minY, maxX, maxY }); } } } catch { // Ignore plate json parsing errors } } // Find the main 3D model file const mainModelPath = Object.keys(zip.files).find( (name) => name === '3D/3dmodel.model' || name.endsWith('/3dmodel.model') ); if (!mainModelPath) { // Fallback: try to find any .model file const anyModelPath = Object.keys(zip.files).find((name) => name.endsWith('.model')); if (anyModelPath) { const doc = await loadModelFile(anyModelPath); if (doc) { const meshes = await parseMeshFromDoc(doc, 0); if (meshes.length > 0) { objects.set('1', { id: '1', meshes, defaultExtruder: 0 }); } } } return { objects, buildItems, plateBounds, plateOffsets }; } const mainDoc = await loadModelFile(mainModelPath); if (!mainDoc) return { objects, buildItems, plateBounds, plateOffsets }; // Parse objects - Bambu Studio uses components to reference external files const objectElements = mainDoc.getElementsByTagName('object'); for (let i = 0; i < objectElements.length; i++) { // Yield once per top-level object so the modal stays interactive // throughout the parse (#1412). Inner vertex/triangle/component // loops yield on their own. See nextTick() comment near the top. if (i > 0) { await nextTick(); } const objEl = objectElements[i]; const objectId = objEl.getAttribute('id'); if (!objectId) continue; const objectPlateId = parsePlateIdFromAttributes(objEl) ?? plateAssignmentsByObjectId.get(objectId) ?? null; // Get default extruder from model_settings.config map, falling back to attribute or default let defaultExtruder = extruderMapById.get(objectId) ?? -1; if (defaultExtruder < 0) { const extruderAttr = objEl.getAttribute('p:extruder') || objEl.getAttributeNS('http://schemas.microsoft.com/3dmanufacturing/production/2015/06', 'extruder') || '1'; defaultExtruder = Math.max(0, parseInt(extruderAttr, 10) - 1); } const meshes: MeshData[] = []; // Check for direct mesh in this object const objMeshElements = objEl.getElementsByTagName('mesh'); for (let j = 0; j < objMeshElements.length; j++) { const meshEl = objMeshElements[j]; const vertices: number[] = []; const triangles: number[] = []; const vertexElements = meshEl.getElementsByTagName('vertex'); for (let k = 0; k < vertexElements.length; k++) { const v = vertexElements[k]; vertices.push( parseFloat(v.getAttribute('x') || '0'), parseFloat(v.getAttribute('y') || '0'), parseFloat(v.getAttribute('z') || '0') ); if (k > 0 && k % YIELD_EVERY_N_VERTICES === 0) { await nextTick(); } } const triangleElements = meshEl.getElementsByTagName('triangle'); for (let k = 0; k < triangleElements.length; k++) { const t = triangleElements[k]; triangles.push( parseInt(t.getAttribute('v1') || '0'), parseInt(t.getAttribute('v2') || '0'), parseInt(t.getAttribute('v3') || '0') ); if (k > 0 && k % YIELD_EVERY_N_TRIANGLES === 0) { await nextTick(); } } if (vertices.length > 0 && triangles.length > 0) { meshes.push({ vertices, triangles, extruder: defaultExtruder }); } } // Check for component references (Bambu Studio style) const componentElements = objEl.getElementsByTagName('component'); for (let j = 0; j < componentElements.length; j++) { // Yield before each component — each one triggers another async file // load + DOM parse + vertex/triangle iteration. Multi-color "parted" // statues from MakerWorld can have dozens of components; without // this yield the whole chain runs as one long synchronous burst // between awaits and freezes the modal close button (#1412). await nextTick(); const compEl = componentElements[j]; // p:path attribute contains the external file reference const extPath = compEl.getAttribute('p:path') || compEl.getAttributeNS('http://schemas.microsoft.com/3dmanufacturing/production/2015/06', 'path'); // objectid in component corresponds to part id in model_settings const compObjectId = compEl.getAttribute('objectid'); if (extPath) { const extDoc = await loadModelFile(extPath); if (extDoc) { // Look up per-part extruder, falling back to object's default const partKey = compObjectId ? `${objectId}:${compObjectId}` : null; const compExtruder = partKey ? (partExtruderMap.get(partKey) ?? defaultExtruder) : defaultExtruder; const extMeshes = await parseMeshFromDoc(extDoc, compExtruder); // Apply component transform if present const compTransformStr = compEl.getAttribute('transform'); const compTransform = parseTransform(compTransformStr); for (const mesh of extMeshes) { if (compTransformStr) { // Apply transform to vertices (in 3MF coordinate space, before Y/Z swap) const transformedVertices: number[] = []; for (let k = 0; k < mesh.vertices.length; k += 3) { const v = new THREE.Vector3(mesh.vertices[k], mesh.vertices[k + 1], mesh.vertices[k + 2]); v.applyMatrix4(compTransform); transformedVertices.push(v.x, v.y, v.z); } meshes.push({ vertices: transformedVertices, triangles: mesh.triangles, extruder: mesh.extruder }); } else { meshes.push(mesh); } } } } } if (meshes.length > 0) { objects.set(objectId, { id: objectId, meshes, defaultExtruder, plateId: objectPlateId }); } } // Parse build items (placement on build plate) const buildElements = mainDoc.getElementsByTagName('build'); if (buildElements.length > 0) { const itemElements = buildElements[0].getElementsByTagName('item'); for (let i = 0; i < itemElements.length; i++) { const itemEl = itemElements[i]; const objectId = itemEl.getAttribute('objectid'); if (!objectId) continue; const transform = parseTransform(itemEl.getAttribute('transform')); const itemPlateId = parsePlateIdFromAttributes(itemEl); const objectPlateId = objects.get(objectId)?.plateId ?? null; const objectName = objectNameById.get(objectId); const namePlateId = objectName ? plateAssignmentsByName.get(objectName) ?? null : null; buildItems.push({ objectId, transform, plateId: itemPlateId ?? objectPlateId ?? namePlateId ?? null }); } } return { objects, buildItems, plateBounds, plateOffsets }; } function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry { const geometry = new THREE.BufferGeometry(); // Convert from 3MF Z-up to Three.js Y-up coordinate system // 3MF: X right, Y back, Z up -> Three.js: X right, Y up, Z forward const positions = new Float32Array(mesh.vertices.length); for (let i = 0; i < mesh.vertices.length; i += 3) { positions[i] = mesh.vertices[i]; // X stays X positions[i + 1] = mesh.vertices[i + 2]; // Y becomes Z (up) positions[i + 2] = mesh.vertices[i + 1]; // Z becomes Y } geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setIndex(mesh.triangles); // Compute normals geometry.computeVertexNormals(); return geometry; } function disposeGroup(group: THREE.Group) { group.traverse((child) => { if (child instanceof THREE.Mesh) { child.geometry.dispose(); if (Array.isArray(child.material)) { for (const material of child.material) { material.dispose(); } } else { child.material.dispose(); } } }); } function buildModelGroup( parsedData: Parsed3MFData, selectedPlateId: number | null, filamentColors?: string[], ): THREE.Group { const { objects, buildItems } = parsedData; const group = new THREE.Group(); // Create materials for each extruder color const getMaterial = (extruder: number): THREE.MeshPhongMaterial => { const defaultColor = '#00ae42'; const colorStr = filamentColors?.[extruder] || defaultColor; // Convert hex color string to THREE.js color const color = new THREE.Color(colorStr); return new THREE.MeshPhongMaterial({ color, shininess: 30, flatShading: false, }); }; // Group geometries by extruder index (using per-mesh extruder) const geometriesByExtruder = new Map(); const hasPlateAssignments = buildItems.some((item) => item.plateId != null); const plateFilteredItems = selectedPlateId == null || !hasPlateAssignments ? buildItems : buildItems.filter((item) => item.plateId === selectedPlateId); const activeBuildItems = plateFilteredItems.length > 0 ? plateFilteredItems : buildItems; // If we have build items, use them for positioning if (activeBuildItems.length > 0) { for (const item of activeBuildItems) { const objectData = objects.get(item.objectId); if (!objectData) continue; for (const meshData of objectData.meshes) { // Use mesh's extruder, or item override, or object default const extruder = item.extruder ?? meshData.extruder; // Apply build transform to vertices in 3MF space BEFORE coordinate conversion const transformedVertices: number[] = []; for (let k = 0; k < meshData.vertices.length; k += 3) { const v = new THREE.Vector3( meshData.vertices[k], meshData.vertices[k + 1], meshData.vertices[k + 2] ); v.applyMatrix4(item.transform); transformedVertices.push(v.x, v.y, v.z); } // Now create geometry with coordinate conversion const geometry = createGeometryFromMesh({ vertices: transformedVertices, triangles: meshData.triangles, extruder: extruder, }); if (!geometriesByExtruder.has(extruder)) { geometriesByExtruder.set(extruder, []); } geometriesByExtruder.get(extruder)!.push(geometry); } } } else { // Fallback: just add all objects without transforms for (const objectData of objects.values()) { for (const meshData of objectData.meshes) { // Use per-mesh extruder const extruder = meshData.extruder; const geometry = createGeometryFromMesh(meshData); if (!geometriesByExtruder.has(extruder)) { geometriesByExtruder.set(extruder, []); } geometriesByExtruder.get(extruder)!.push(geometry); } } } // Create meshes for each extruder group for (const [extruder, geometries] of geometriesByExtruder) { if (geometries.length === 0) continue; const mergedGeometry = geometries.length === 1 ? geometries[0] : mergeGeometries(geometries, false); if (mergedGeometry) { const material = getMaterial(extruder); const mesh = new THREE.Mesh(mergedGeometry, material); group.add(mesh); } // Dispose individual geometries if merged if (geometries.length > 1) { for (const geom of geometries) { geom.dispose(); } } } return group; } export function ModelViewer({ url, fileType, buildVolume = { x: 256, y: 256, z: 256 }, filamentColors, selectedPlateId = null, className = '', }: ModelViewerProps) { const { t } = useTranslation(); const containerRef = useRef(null); const rendererRef = useRef(null); const sceneRef = useRef(null); const cameraRef = useRef(null); const controlsRef = useRef(null); const modelGroupRef = useRef(null); const plateRef = useRef(null); const gridRef = useRef(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [parsedData, setParsedData] = useState(null); const [stlGeometry, setStlGeometry] = useState(null); useEffect(() => { if (!containerRef.current) return; const container = containerRef.current; const width = container.clientWidth; const height = container.clientHeight; // Scene const scene = new THREE.Scene(); scene.background = new THREE.Color(0x1a1a1a); sceneRef.current = scene; // Camera const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000); camera.position.set(150, 150, 150); cameraRef.current = camera; // Renderer const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(width, height); renderer.setPixelRatio(window.devicePixelRatio); container.appendChild(renderer.domElement); rendererRef.current = renderer; // Controls const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; 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); // Grid - use the larger dimension for the grid size const gridSize = Math.max(buildVolume.x, buildVolume.y); const gridDivisions = Math.ceil(gridSize / 16); const gridHelper = new THREE.GridHelper(gridSize, gridDivisions, 0x444444, 0x333333); scene.add(gridHelper); gridRef.current = gridHelper; // Build plate indicator const plateGeometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y); const plateMaterial = new THREE.MeshBasicMaterial({ color: 0x00ae42, transparent: true, opacity: 0.15, side: THREE.DoubleSide, }); const plate = new THREE.Mesh(plateGeometry, plateMaterial); plate.rotation.x = -Math.PI / 2; plate.position.y = -0.5; // Slightly below Y=0 so models sit on top scene.add(plate); plateRef.current = plate; // Animation loop - keep it simple for reliability let animationId: number; const animate = () => { animationId = requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); }; animate(); setLoading(true); setError(null); setParsedData(null); setStlGeometry(null); const normalizedType = (fileType || url.split('?')[0].split('.').pop() || '').toLowerCase(); // Build auth headers for fetch const headers: HeadersInit = {}; const token = getAuthToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; } if (normalizedType === 'stl') { fetch(url, { headers }) .then((res) => { if (!res.ok) throw new Error(t('modelViewer.errors.failedToLoad')); return res.arrayBuffer(); }) .then((buffer) => { const loader = new STLLoader(); const geometry = loader.parse(buffer); geometry.computeVertexNormals(); geometry.rotateX(-Math.PI / 2); setStlGeometry(geometry); }) .catch((err) => { setError(err.message); setLoading(false); }); } else if (normalizedType === '3mf') { fetch(url, { headers }) .then((res) => { if (!res.ok) throw new Error(t('modelViewer.errors.failedToLoad')); return res.arrayBuffer(); }) .then(parse3MF) .then((parsed) => { if (parsed.objects.size === 0) { throw new Error(t('modelViewer.errors.noMeshes')); } setParsedData(parsed); }) .catch((err) => { setError(err.message); setLoading(false); }); } else { setError(t('modelViewer.errors.unsupportedFormat')); setLoading(false); } // Handle resize (window + container) const handleResize = () => { if (!container) return; const w = container.clientWidth; const h = container.clientHeight; if (w === 0 || h === 0) return; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); }; window.addEventListener('resize', handleResize); const resizeObserver = new ResizeObserver(() => { handleResize(); }); resizeObserver.observe(container); return () => { window.removeEventListener('resize', handleResize); resizeObserver.disconnect(); cancelAnimationFrame(animationId); controls.dispose(); renderer.dispose(); container.removeChild(renderer.domElement); modelGroupRef.current = null; plateRef.current = null; gridRef.current = null; }; }, [url, buildVolume, fileType, t]); useEffect(() => { if (!sceneRef.current || !cameraRef.current || !controlsRef.current) return; if (!parsedData && !stlGeometry) return; if (modelGroupRef.current) { sceneRef.current.remove(modelGroupRef.current); disposeGroup(modelGroupRef.current); } const isStlModel = !!stlGeometry; const group = isStlModel ? (() => { const materialColor = filamentColors?.[0] || '#00ae42'; const material = new THREE.MeshPhongMaterial({ color: new THREE.Color(materialColor), shininess: 30 }); const mesh = new THREE.Mesh(stlGeometry!, material); const stlGroup = new THREE.Group(); stlGroup.add(mesh); return stlGroup; })() : buildModelGroup(parsedData!, selectedPlateId ?? null, filamentColors); modelGroupRef.current = group; sceneRef.current.add(group); // Get bounding box to position model const box = new THREE.Box3().setFromObject(group); const center = box.getCenter(new THREE.Vector3()); // Always place models on the build plate (Y=0) group.position.y = -box.min.y; const selectedPlateBounds = (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0) ? parsedData!.plateBounds.get(selectedPlateId) : undefined; const selectedPlateOffset = (!isStlModel && selectedPlateId != null) ? parsedData!.plateOffsets.get(selectedPlateId) : undefined; const shouldCenterOnPlate = isStlModel || parsedData!.buildItems.length === 0 || (selectedPlateId != null && !selectedPlateBounds && !selectedPlateOffset); const centerOffsetX = shouldCenterOnPlate ? -center.x : 0; const centerOffsetZ = shouldCenterOnPlate ? -center.z : 0; let plateOffsetX = 0; let plateOffsetZ = 0; if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) { const plateBox = new THREE.Box3().setFromObject(group); plateOffsetX = plateBox.min.x - selectedPlateBounds.minX; plateOffsetZ = plateBox.min.z - selectedPlateBounds.minY; } const plateCenterX = buildVolume.x / 2; const plateCenterZ = buildVolume.y / 2; if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) { group.position.x = centerOffsetX - plateOffsetX; group.position.z = centerOffsetZ - plateOffsetZ; } else if (!isStlModel && selectedPlateId != null && selectedPlateOffset) { group.position.x = centerOffsetX + (plateCenterX - selectedPlateOffset.offsetX); group.position.z = centerOffsetZ + (plateCenterZ - selectedPlateOffset.offsetY); } else if (shouldCenterOnPlate) { group.position.x = centerOffsetX + plateCenterX; group.position.z = centerOffsetZ + plateCenterZ; } else { group.position.x = centerOffsetX; group.position.z = centerOffsetZ; } if (plateRef.current) { plateRef.current.position.x = plateCenterX; plateRef.current.position.z = plateCenterZ; } if (gridRef.current) { gridRef.current.position.x = plateCenterX; gridRef.current.position.z = plateCenterZ; } // Recalculate bounding box after positioning 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 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(); setLoading(false); }, [parsedData, stlGeometry, selectedPlateId, filamentColors, buildVolume]); const resetView = () => { if (cameraRef.current && controlsRef.current) { cameraRef.current.position.set(150, 150, 150); controlsRef.current.target.set(0, 50, 0); controlsRef.current.update(); } }; const zoom = (factor: number) => { if (cameraRef.current) { cameraRef.current.position.multiplyScalar(factor); } }; return (
{loading && (
)} {error && (

{error}

)} {!loading && !error && (
)}
); }