makerstash/client/viewer3d.js

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()">&times;</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();
}
});