Add 3MF multi-component viewer with navigation controls

This commit is contained in:
David L 2026-01-13 15:26:17 +10:00
parent 6be41cb4d3
commit 88f133bf6c

View file

@ -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,11 +362,44 @@ class ModelViewer {
throw new Error('No objects to render in 3MF file');
}
// Process each object to render
for (const { object: obj, transform } of objectsToRender) {
// 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) continue;
if (meshElements.length === 0) {
this.model = group;
return;
}
const mesh = meshElements[0];
@ -420,7 +427,7 @@ class ModelViewer {
);
}
// Create geometry for this object
// 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));
@ -455,19 +462,35 @@ class ModelViewer {
group.add(meshObj);
}
}
// Add group to scene
this.model = group;
this.scene.add(group);
this.centerAndScaleModel();
resolve();
} catch (error) {
console.error('Error loading 3MF:', error);
reject(error);
// 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() {
@ -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 = `
<button class="btn btn-sm btn-secondary" onclick="cycleViewerComponent(-1)" title="Previous component">
<i class="fas fa-chevron-left"></i> Prev
</button>
<span id="componentCounter" style="color: var(--text-secondary); font-size: 0.9rem;"></span>
<button class="btn btn-sm btn-secondary" onclick="cycleViewerComponent(1)" title="Next component">
Next <i class="fas fa-chevron-right"></i>
</button>
`;
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;