807 lines
25 KiB
JavaScript
807 lines
25 KiB
JavaScript
// 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 = `
|
|
<div class="modal-content modal-large" style="max-width: 90%; height: 80vh;">
|
|
<span class="close" onclick="close3DViewer()">×</span>
|
|
<div style="margin-bottom: 1rem; display: flex; justify-content: space-between; align-items: center;">
|
|
<div>
|
|
<h2>${escapeHtml(model.name)}</h2>
|
|
<p id="currentFileName" style="color: var(--text-secondary); margin-top: 0.5rem;"></p>
|
|
</div>
|
|
${viewableFiles.length > 1 ? `
|
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
|
<button class="btn btn-secondary" onclick="cycleViewerFile(-1)" ${viewableFiles.length <= 1 ? 'disabled' : ''}>
|
|
<i class="fas fa-chevron-left"></i> Previous
|
|
</button>
|
|
<span id="fileCounter" style="color: var(--text-secondary);"></span>
|
|
<button class="btn btn-secondary" onclick="cycleViewerFile(1)" ${viewableFiles.length <= 1 ? 'disabled' : ''}>
|
|
Next <i class="fas fa-chevron-right"></i>
|
|
</button>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
<div id="viewer3dContainer" style="width: 100%; height: calc(100% - 120px); background: #2d2d2d; border-radius: 4px;"></div>
|
|
</div>
|
|
`;
|
|
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 = `
|
|
<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;
|
|
|
|
// 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();
|
|
}
|
|
});
|