// 3D Model Viewer using Three.js let viewer = null; class ModelViewer { constructor(containerId) { this.container = document.getElementById(containerId); this.scene = null; this.camera = null; this.renderer = null; this.controls = null; this.model = null; this.animationId = null; } init() { // Create scene this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x2d2d2d); // Create camera const width = this.container.clientWidth; const height = this.container.clientHeight; this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000); this.camera.position.set(0, 0, 100); // Create renderer this.renderer = new THREE.WebGLRenderer({ antialias: true }); this.renderer.setSize(width, height); this.renderer.setPixelRatio(window.devicePixelRatio); this.container.innerHTML = ''; this.container.appendChild(this.renderer.domElement); // Add lights const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); this.scene.add(ambientLight); const directionalLight1 = new THREE.DirectionalLight(0xffffff, 0.8); directionalLight1.position.set(1, 1, 1); this.scene.add(directionalLight1); const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.4); directionalLight2.position.set(-1, -1, -1); this.scene.add(directionalLight2); // Add grid helper const gridHelper = new THREE.GridHelper(200, 20, 0x4a90e2, 0x404040); this.scene.add(gridHelper); // Add orbit controls (using built-in Three.js controls) this.setupOrbitControls(); // Handle window resize window.addEventListener('resize', () => this.onWindowResize()); // Start animation loop this.animate(); } setupOrbitControls() { // Manual orbit controls implementation let isDragging = false; let previousMousePosition = { x: 0, y: 0 }; let rotation = { x: 0, y: 0 }; let distance = 100; const canvas = this.renderer.domElement; canvas.addEventListener('mousedown', (e) => { isDragging = true; previousMousePosition = { x: e.clientX, y: e.clientY }; }); canvas.addEventListener('mousemove', (e) => { if (isDragging) { const deltaX = e.clientX - previousMousePosition.x; const deltaY = e.clientY - previousMousePosition.y; rotation.y += deltaX * 0.01; rotation.x += deltaY * 0.01; // Clamp vertical rotation rotation.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, rotation.x)); this.camera.position.x = distance * Math.sin(rotation.y) * Math.cos(rotation.x); this.camera.position.y = distance * Math.sin(rotation.x); this.camera.position.z = distance * Math.cos(rotation.y) * Math.cos(rotation.x); this.camera.lookAt(0, 0, 0); previousMousePosition = { x: e.clientX, y: e.clientY }; } }); canvas.addEventListener('mouseup', () => { isDragging = false; }); canvas.addEventListener('mouseleave', () => { isDragging = false; }); // Mouse wheel for zoom canvas.addEventListener('wheel', (e) => { e.preventDefault(); distance += e.deltaY * 0.1; distance = Math.max(10, Math.min(1000, distance)); this.camera.position.x = distance * Math.sin(rotation.y) * Math.cos(rotation.x); this.camera.position.y = distance * Math.sin(rotation.x); this.camera.position.z = distance * Math.cos(rotation.y) * Math.cos(rotation.x); this.camera.lookAt(0, 0, 0); }); } animate() { this.animationId = requestAnimationFrame(() => this.animate()); this.renderer.render(this.scene, this.camera); } onWindowResize() { const width = this.container.clientWidth; const height = this.container.clientHeight; this.camera.aspect = width / height; this.camera.updateProjectionMatrix(); this.renderer.setSize(width, height); } loadSTL(url) { return new Promise((resolve, reject) => { const loader = new THREE.STLLoader(); loader.load( url, (geometry) => { this.displayGeometry(geometry); resolve(); }, (progress) => { console.log('Loading: ' + (progress.loaded / progress.total * 100) + '%'); }, (error) => { console.error('Error loading STL:', error); reject(error); } ); }); } loadOBJ(url) { return new Promise((resolve, reject) => { const loader = new THREE.OBJLoader(); loader.load( url, (object) => { // Remove previous model if (this.model) { this.scene.remove(this.model); } this.model = object; // Apply material to all meshes object.traverse((child) => { if (child instanceof THREE.Mesh) { child.material = new THREE.MeshPhongMaterial({ color: 0x00ae42, shininess: 30, flatShading: false }); } }); this.scene.add(object); this.centerAndScaleModel(); resolve(); }, (progress) => { console.log('Loading: ' + (progress.loaded / progress.total * 100) + '%'); }, (error) => { console.error('Error loading OBJ:', error); reject(error); } ); }); } async load3MF(url) { return new Promise(async (resolve, reject) => { try { // Fetch the 3MF file const response = await fetch(url); const arrayBuffer = await response.arrayBuffer(); console.log('3MF file loaded, size:', arrayBuffer.byteLength); // Load JSZip library if not already loaded if (typeof JSZip === 'undefined') { await this.loadJSZip(); } // Parse the ZIP file const zip = await JSZip.loadAsync(arrayBuffer); console.log('ZIP parsed successfully'); // Find all 3D model files const modelFiles = []; const files = []; zip.forEach((relativePath, zipEntry) => { files.push(relativePath); if (relativePath.endsWith('.model')) { modelFiles.push({ path: relativePath, entry: zipEntry }); } }); console.log('Files in ZIP:', files); console.log('Model files found:', modelFiles.length); if (modelFiles.length === 0) { throw new Error('No 3D model found in 3MF file'); } // Find the main model file (usually 3dmodel.model) let mainModelFile = modelFiles.find(f => f.path.includes('3dmodel.model')); if (!mainModelFile) { mainModelFile = modelFiles[0]; // Fallback to first model file } // Read the main XML content const xmlContent = await mainModelFile.entry.async('text'); console.log('Main XML content length:', xmlContent.length); // Parse XML const parser = new DOMParser(); const xmlDoc = parser.parseFromString(xmlContent, 'text/xml'); // Remove previous model if (this.model) { this.scene.remove(this.model); } // Build a map of all objects by ID const objectMap = new Map(); const objectElements = xmlDoc.getElementsByTagName('object'); console.log(`Found ${objectElements.length} object elements`); for (let i = 0; i < objectElements.length; i++) { const obj = objectElements[i]; const id = obj.getAttribute('id'); const type = obj.getAttribute('type'); console.log(`Object ${i}: id=${id}, type=${type}`); if (id) { objectMap.set(id, obj); } } // Check if there's a build section (defines which objects to render) const buildElements = xmlDoc.getElementsByTagName('build'); let objectsToRender = []; console.log(`Found ${buildElements.length} build elements`); if (buildElements.length > 0) { // Use the build section to determine what to render const itemElements = buildElements[0].getElementsByTagName('item'); console.log(`Found ${itemElements.length} item elements in build section`); for (let i = 0; i < itemElements.length; i++) { const item = itemElements[i]; const objectId = item.getAttribute('objectid'); const transform = item.getAttribute('transform'); console.log(`Build item ${i}: objectid=${objectId}, has transform=${!!transform}`); if (objectId && objectMap.has(objectId)) { const obj = objectMap.get(objectId); // Check if this is a component (references other objects) const componentElements = obj.getElementsByTagName('component'); if (componentElements.length > 0) { // This is a composite object, add all its components as separate items console.log(`Object ${objectId} has ${componentElements.length} components`); for (let j = 0; j < componentElements.length; j++) { const comp = componentElements[j]; const compObjectId = comp.getAttribute('objectid'); const compTransform = comp.getAttribute('transform'); if (compObjectId && objectMap.has(compObjectId)) { objectsToRender.push({ object: objectMap.get(compObjectId), transform: compTransform, componentName: `Component ${j + 1}` }); } } } else { // Regular mesh object objectsToRender.push({ object: obj, transform: transform, componentName: `Model ${i + 1}` }); } } } } else { // No build section, render each object separately console.log('No build section found, rendering all objects with meshes separately'); for (let i = 0; i < objectElements.length; i++) { const obj = objectElements[i]; if (obj.getElementsByTagName('mesh').length > 0) { objectsToRender.push({ object: obj, transform: null, componentName: `Model ${i + 1}` }); } } } console.log(`Total objects to render: ${objectsToRender.length}`); // If no objects to render from main file, try loading separate object files if (objectsToRender.length === 0) { console.log('No objects in main model, loading separate object files...'); // Load all separate object files (like object_10.model, etc.) const objectFiles = modelFiles.filter(f => f.path.includes('/Objects/') && f.path.endsWith('.model') ); console.log(`Found ${objectFiles.length} separate object files`); for (let idx = 0; idx < objectFiles.length; idx++) { const objFile = objectFiles[idx]; try { const objXmlContent = await objFile.entry.async('text'); const objXmlDoc = parser.parseFromString(objXmlContent, 'text/xml'); const objElements = objXmlDoc.getElementsByTagName('object'); for (let i = 0; i < objElements.length; i++) { const obj = objElements[i]; if (obj.getElementsByTagName('mesh').length > 0) { objectsToRender.push({ object: obj, transform: null, componentName: objFile.path.split('/').pop().replace('.model', '') }); } } } catch (err) { console.error(`Error loading ${objFile.path}:`, err); } } console.log(`Loaded ${objectsToRender.length} objects from separate files`); } if (objectsToRender.length === 0) { throw new Error('No objects to render in 3MF file'); } // Store all components for later navigation this.threeMFComponents = objectsToRender; this.threeMFComponentIndex = 0; this.threeMFZip = zip; this.threeMFParser = parser; // Render the first component this.render3MFComponent(0); resolve(); } catch (error) { console.error('Error loading 3MF:', error); reject(error); } }); } render3MFComponent(componentIndex) { if (!this.threeMFComponents || componentIndex >= this.threeMFComponents.length) { return; } this.threeMFComponentIndex = componentIndex; const { object: obj, transform, componentName } = this.threeMFComponents[componentIndex]; // Remove previous model group if (this.model) { this.scene.remove(this.model); } // Create a new group for this component const group = new THREE.Group(); // Get the mesh for this object const meshElements = obj.getElementsByTagName('mesh'); if (meshElements.length === 0) { this.model = group; return; } const mesh = meshElements[0]; // Extract vertices for this mesh const vertices = []; const vertexElements = mesh.getElementsByTagName('vertex'); for (let i = 0; i < vertexElements.length; i++) { const v = vertexElements[i]; vertices.push( parseFloat(v.getAttribute('x')) || 0, parseFloat(v.getAttribute('y')) || 0, parseFloat(v.getAttribute('z')) || 0 ); } // Extract triangles for this mesh const indices = []; const triangleElements = mesh.getElementsByTagName('triangle'); for (let i = 0; i < triangleElements.length; i++) { const t = triangleElements[i]; indices.push( parseInt(t.getAttribute('v1')) || 0, parseInt(t.getAttribute('v2')) || 0, parseInt(t.getAttribute('v3')) || 0 ); } // Create geometry for this component if (vertices.length > 0 && indices.length > 0) { const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)); geometry.setIndex(indices); geometry.computeVertexNormals(); // Create material const material = new THREE.MeshPhongMaterial({ color: 0x00ae42, shininess: 30, flatShading: false }); // Create mesh const meshObj = new THREE.Mesh(geometry, material); // Apply transform if present if (transform) { // Parse transform matrix (format: m00 m01 m02 m10 m11 m12 m20 m21 m22 m30 m31 m32) const values = transform.split(' ').map(v => parseFloat(v)); if (values.length === 12) { const matrix = new THREE.Matrix4(); matrix.set( values[0], values[3], values[6], values[9], values[1], values[4], values[7], values[10], values[2], values[5], values[8], values[11], 0, 0, 0, 1 ); meshObj.applyMatrix4(matrix); } } group.add(meshObj); } // Add group to scene this.model = group; this.scene.add(group); this.centerAndScaleModel(); // Store the component name for UI display this.currentComponent3MFName = componentName; } next3MFComponent() { if (!this.threeMFComponents) return; const nextIndex = (this.threeMFComponentIndex + 1) % this.threeMFComponents.length; this.render3MFComponent(nextIndex); } previous3MFComponent() { if (!this.threeMFComponents) return; const prevIndex = (this.threeMFComponentIndex - 1 + this.threeMFComponents.length) % this.threeMFComponents.length; this.render3MFComponent(prevIndex); } get3MFComponentCount() { return this.threeMFComponents ? this.threeMFComponents.length : 0; } get3MFCurrentComponentIndex() { return this.threeMFComponentIndex; } async loadJSZip() { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js'; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); } displayGeometry(geometry) { // Remove previous model if (this.model) { this.scene.remove(this.model); } // Center the geometry geometry.computeBoundingBox(); const center = new THREE.Vector3(); geometry.boundingBox.getCenter(center); geometry.translate(-center.x, -center.y, -center.z); // Create material const material = new THREE.MeshPhongMaterial({ color: 0x00ae42, shininess: 30, flatShading: false }); // Create mesh this.model = new THREE.Mesh(geometry, material); this.scene.add(this.model); this.centerAndScaleModel(); } centerAndScaleModel() { if (!this.model) return; // Calculate bounding box const box = new THREE.Box3().setFromObject(this.model); const size = box.getSize(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z); // Scale model to fit in view const scale = 50 / maxDim; this.model.scale.set(scale, scale, scale); // Center model const center = box.getCenter(new THREE.Vector3()); this.model.position.sub(center.multiplyScalar(scale)); // Adjust camera this.camera.position.set(0, 30, 80); this.camera.lookAt(0, 0, 0); } async loadModel(url, fileType) { try { if (fileType === '.stl') { await this.loadSTL(url); } else if (fileType === '.obj') { await this.loadOBJ(url); } else if (fileType === '.3mf') { await this.load3MF(url); } else { throw new Error(`Unsupported file type: ${fileType}`); } } catch (error) { console.error('Error loading model:', error); throw error; } } dispose() { if (this.animationId) { cancelAnimationFrame(this.animationId); } if (this.renderer) { this.renderer.dispose(); } if (this.model) { this.scene.remove(this.model); } if (this.container) { this.container.innerHTML = ''; } } } // Global state for multi-file viewer let currentViewerState = { modelId: null, files: [], currentFileIndex: 0 }; // Global function to open 3D viewer async function open3DViewer(modelId) { try { // Fetch model details const response = await fetch(`${API_BASE}/models/${modelId}`); const model = await response.json(); // Collect all viewable files (primary + additional) const viewableFiles = []; // Add primary file if it's viewable if (['.stl', '.obj', '.3mf'].includes(model.file_type)) { viewableFiles.push({ id: model.id, name: model.file_name, type: model.file_type, isPrimary: true }); } // Add additional files if they're viewable if (model.files && model.files.length > 0) { model.files.forEach(file => { const fileType = file.file_type.toLowerCase(); if (['.stl', '.obj', '.3mf'].includes(fileType)) { viewableFiles.push({ id: file.id, name: file.file_name, type: fileType, isPrimary: false }); } }); } // Check if there are any viewable files if (viewableFiles.length === 0) { showNotification('No viewable 3D files found. Only STL, OBJ, and 3MF are supported.', 'error'); return; } // Initialize viewer state currentViewerState = { modelId: modelId, files: viewableFiles, currentFileIndex: 0 }; // Create viewer modal with file navigation const viewerModal = document.createElement('div'); viewerModal.id = 'viewer3dModal'; viewerModal.className = 'modal'; viewerModal.style.display = 'block'; viewerModal.innerHTML = `
`; document.body.appendChild(viewerModal); // Initialize viewer viewer = new ModelViewer('viewer3dContainer'); viewer.init(); // Load the first file await loadViewerFile(0); } catch (error) { console.error('Error opening 3D viewer:', error); showNotification('Failed to load 3D model: ' + error.message, 'error'); close3DViewer(); } } // Load a specific file in the viewer async function loadViewerFile(index) { try { const file = currentViewerState.files[index]; currentViewerState.currentFileIndex = index; // Update UI const fileNameEl = document.getElementById('currentFileName'); if (fileNameEl) { fileNameEl.textContent = `File: ${file.name}${file.isPrimary ? ' (Primary)' : ''}`; } const counterEl = document.getElementById('fileCounter'); if (counterEl && currentViewerState.files.length > 1) { counterEl.textContent = `${index + 1} / ${currentViewerState.files.length}`; } // Determine the download URL let modelUrl; if (file.isPrimary) { // For primary file, use the model download endpoint modelUrl = `/api/models/${currentViewerState.modelId}/file/primary`; } else { // For additional files, use the file-specific endpoint modelUrl = `/api/models/${currentViewerState.modelId}/file/${file.id}`; } // Load the model await viewer.loadModel(modelUrl, file.type); // If it's a 3MF file with multiple components, show component navigation if (file.type === '.3mf' && viewer.get3MFComponentCount && viewer.get3MFComponentCount() > 1) { const componentCount = viewer.get3MFComponentCount(); let componentNav = document.getElementById('componentNavigation'); if (!componentNav) { // Create component navigation area const navContainer = document.querySelector('[id="currentFileName"]').parentElement; componentNav = document.createElement('div'); componentNav.id = 'componentNavigation'; componentNav.style.cssText = 'margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center;'; navContainer.appendChild(componentNav); } componentNav.innerHTML = ` `; updateComponentCounter(); } else { // Hide component navigation if not a 3MF with components const componentNav = document.getElementById('componentNavigation'); if (componentNav) { componentNav.style.display = 'none'; } } } catch (error) { console.error('Error loading file:', error); showNotification('Failed to load file: ' + error.message, 'error'); } } // Update the component counter display function updateComponentCounter() { const counterEl = document.getElementById('componentCounter'); if (counterEl && viewer && viewer.get3MFComponentCount) { const componentCount = viewer.get3MFComponentCount(); const currentIndex = viewer.get3MFCurrentComponentIndex(); counterEl.textContent = `Component ${currentIndex + 1} / ${componentCount}`; } } // Navigate to next/previous 3MF component function cycleViewerComponent(direction) { if (viewer && viewer.next3MFComponent && viewer.previous3MFComponent) { if (direction > 0) { viewer.next3MFComponent(); } else { viewer.previous3MFComponent(); } updateComponentCounter(); } } // Cycle through files async function cycleViewerFile(direction) { const newIndex = currentViewerState.currentFileIndex + direction; // Wrap around let finalIndex = newIndex; if (newIndex < 0) { finalIndex = currentViewerState.files.length - 1; } else if (newIndex >= currentViewerState.files.length) { finalIndex = 0; } await loadViewerFile(finalIndex); } // Close 3D viewer function close3DViewer() { if (viewer) { viewer.dispose(); viewer = null; } const viewerModal = document.getElementById('viewer3dModal'); if (viewerModal) { viewerModal.remove(); } } // Close viewer when clicking outside window.addEventListener('click', (event) => { const viewerModal = document.getElementById('viewer3dModal'); if (event.target === viewerModal) { close3DViewer(); } });