From 88f133bf6c611d1c41a378699858ea1f1b6b136a Mon Sep 17 00:00:00 2001 From: David L Date: Tue, 13 Jan 2026 15:26:17 +1000 Subject: [PATCH] Add 3MF multi-component viewer with navigation controls --- client/viewer3d.js | 304 ++++++++++++++++++++++++++++----------------- 1 file changed, 191 insertions(+), 113 deletions(-) diff --git a/client/viewer3d.js b/client/viewer3d.js index b57b224..27ef62a 100644 --- a/client/viewer3d.js +++ b/client/viewer3d.js @@ -239,9 +239,6 @@ class ModelViewer { this.scene.remove(this.model); } - // Create a group to hold all objects - const group = new THREE.Group(); - // Build a map of all objects by ID const objectMap = new Map(); const objectElements = xmlDoc.getElementsByTagName('object'); @@ -283,7 +280,7 @@ class ModelViewer { const componentElements = obj.getElementsByTagName('component'); if (componentElements.length > 0) { - // This is a composite object, add all its components + // 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]; @@ -293,7 +290,8 @@ class ModelViewer { if (compObjectId && objectMap.has(compObjectId)) { objectsToRender.push({ object: objectMap.get(compObjectId), - transform: compTransform + transform: compTransform, + componentName: `Component ${j + 1}` }); } } @@ -301,18 +299,23 @@ class ModelViewer { // Regular mesh object objectsToRender.push({ object: obj, - transform: transform + transform: transform, + componentName: `Model ${i + 1}` }); } } } } else { - // No build section, render all objects with meshes - console.log('No build section found, rendering all objects with meshes'); + // 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 }); + objectsToRender.push({ + object: obj, + transform: null, + componentName: `Model ${i + 1}` + }); } } } @@ -323,23 +326,6 @@ class ModelViewer { if (objectsToRender.length === 0) { console.log('No objects in main model, loading separate object files...'); - // Try to load cut_information.xml for positioning data - let cutInfo = null; - const cutInfoFile = files.find(f => f.includes('cut_information.xml')); - if (cutInfoFile) { - try { - const cutInfoEntry = zip.file(cutInfoFile); - if (cutInfoEntry) { - const cutInfoContent = await cutInfoEntry.async('text'); - const cutInfoDoc = parser.parseFromString(cutInfoContent, 'text/xml'); - cutInfo = cutInfoDoc; - console.log('Loaded cut_information.xml for positioning'); - } - } catch (err) { - console.error('Error loading cut_information.xml:', err); - } - } - // Load all separate object files (like object_10.model, etc.) const objectFiles = modelFiles.filter(f => f.path.includes('/Objects/') && f.path.endsWith('.model') @@ -347,10 +333,6 @@ class ModelViewer { console.log(`Found ${objectFiles.length} separate object files`); - // Auto-arrange objects in a grid - all on same Z plane - const gridSize = Math.ceil(Math.sqrt(objectFiles.length)); - const spacing = 150; // mm spacing between objects - for (let idx = 0; idx < objectFiles.length; idx++) { const objFile = objectFiles[idx]; try { @@ -361,18 +343,10 @@ class ModelViewer { for (let i = 0; i < objElements.length; i++) { const obj = objElements[i]; if (obj.getElementsByTagName('mesh').length > 0) { - // Calculate grid position - spread only on X axis (horizontal) - // Keep all parts at Y=0, Z=0 so they stay on the same plane - const offsetX = (idx - objectFiles.length / 2) * spacing; - - // Create a transform matrix for positioning (only X offset) - // Format: m00 m01 m02 m10 m11 m12 m20 m21 m22 tx ty tz - // This positions parts in a horizontal line on the build plate - const transformMatrix = `1 0 0 0 1 0 0 0 1 ${offsetX} 0 0`; - objectsToRender.push({ object: obj, - transform: transformMatrix + transform: null, + componentName: objFile.path.split('/').pop().replace('.model', '') }); } } @@ -388,80 +362,14 @@ class ModelViewer { throw new Error('No objects to render in 3MF file'); } - // Process each object to render - for (const { object: obj, transform } of objectsToRender) { - // Get the mesh for this object - const meshElements = obj.getElementsByTagName('mesh'); - if (meshElements.length === 0) continue; + // Store all components for later navigation + this.threeMFComponents = objectsToRender; + this.threeMFComponentIndex = 0; + this.threeMFZip = zip; + this.threeMFParser = parser; - 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 object - 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(); + // Render the first component + this.render3MFComponent(0); resolve(); } catch (error) { console.error('Error loading 3MF:', error); @@ -470,6 +378,121 @@ class ModelViewer { }); } + 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'); @@ -687,12 +710,67 @@ async function loadViewerFile(index) { // 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;