diff --git a/chicken_easter_80mm_noams.3mf b/chicken_easter_80mm_noams.3mf new file mode 100644 index 0000000..dac05f9 Binary files /dev/null and b/chicken_easter_80mm_noams.3mf differ diff --git a/index.html b/index.html index ffee765..28e4cbf 100644 --- a/index.html +++ b/index.html @@ -31,7 +31,8 @@ { "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js", - "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/" + "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/", + "fflate": "https://cdn.jsdelivr.net/npm/fflate@0.8.2/esm/browser.js" } } @@ -67,8 +68,8 @@ -

Drop an .stl file here
or

- +

Drop an .stl, .obj or .3mf file here
or

+
@@ -95,7 +96,7 @@
- +
diff --git a/js/i18n.js b/js/i18n.js index e995d8d..a7a2184 100644 --- a/js/i18n.js +++ b/js/i18n.js @@ -10,15 +10,15 @@ export const TRANSLATIONS = { 'theme.toggleAriaLabel': 'Toggle light/dark mode', // Drop zone - 'dropHint.text': 'Drop an .stl file here
or ', + 'dropHint.text': 'Drop an .stl, .obj or .3mf file here
or ', // Viewport footer 'ui.wireframe': 'Wireframe', 'ui.controlsHint': 'Left drag: orbit \u00a0·\u00a0 Right drag: pan \u00a0·\u00a0 Scroll: zoom', 'ui.meshInfo': '{n} triangles · {mb} MB · {sx} × {sy} × {sz} mm', - // Load STL button - 'ui.loadStl': 'Load STL\u2026', + // Load model button + 'ui.loadStl': 'Load Model\u2026', // Displacement map section 'sections.displacementMap': 'Displacement Map', @@ -53,6 +53,10 @@ export const TRANSLATIONS = { // Seam blend 'labels.seamBlend': 'Seam Blend \u24d8', 'tooltips.seamBlend': 'Softens the hard seam where projection faces meet. Effective for Cubic and Cylindrical modes.', + 'labels.smoothing': 'Smoothing \u24d8', + 'tooltips.smoothing': 'Width of the blending zone near seam edges. Lower values keep transitions tight to the seam; higher values blend a wider band.', + 'labels.capAngle': 'Cap Angle \u24d8', + 'tooltips.capAngle': 'Angle (in degrees) from vertical at which the top/bottom cap projection kicks in. Smaller values limit cap projection to nearly flat faces.', // Mask angles section 'sections.maskAngles': 'Mask Angles \u24d8', @@ -150,7 +154,7 @@ export const TRANSLATIONS = { 'cta.storeDismiss': 'Dismiss', // Alerts - 'alerts.loadFailed': 'Could not load STL: {msg}', + 'alerts.loadFailed': 'Could not load model: {msg}', 'alerts.exportFailed': 'Export failed: {msg}', }, @@ -162,15 +166,15 @@ export const TRANSLATIONS = { 'theme.toggleAriaLabel': 'Hell/Dunkel-Modus wechseln', // Drop zone - 'dropHint.text': '.stl-Datei hier ablegen
oder ', + 'dropHint.text': '.stl-, .obj- oder .3mf-Datei hier ablegen
oder ', // Viewport footer 'ui.wireframe': 'Drahtgitter', 'ui.controlsHint': 'Linke Maustaste: Drehen \u00a0·\u00a0 Rechte Maustaste: Verschieben \u00a0·\u00a0 Mausrad: Zoomen', 'ui.meshInfo': '{n} Dreiecke · {mb} MB · {sx} × {sy} × {sz} mm', - // Load STL button - 'ui.loadStl': 'STL laden\u2026', + // Load model button + 'ui.loadStl': 'Modell laden\u2026', // Displacement map section 'sections.displacementMap': 'Textur', @@ -205,6 +209,10 @@ export const TRANSLATIONS = { // Seam blend 'labels.seamBlend': 'Nahtglättung \u24d8', 'tooltips.seamBlend': 'Glättet den scharfen Übergang zwischen Projektionsflächen. Wirksam für Kubische und Zylindrische Modi.', + 'labels.smoothing': 'Glättung \u24d8', + 'tooltips.smoothing': 'Breite der Übergangszone an Nahtkanten. Niedrige Werte halten den Übergang nah an der Naht; höhere Werte glätten einen breiteren Bereich.', + 'labels.capAngle': 'Übergangswinkel \u24d8', + 'tooltips.capAngle': 'Winkel (in Grad) ab dem die Deckel-/Bodenprojektion einsetzt. Kleinere Werte beschränken die Deckelprojektion auf nahezu flache Flächen.', // Winkelmaskierung 'sections.maskAngles': 'Winkel maskieren \u24d8', @@ -302,7 +310,7 @@ export const TRANSLATIONS = { 'cta.storeDismiss': 'Ausblenden', // Alerts - 'alerts.loadFailed': 'STL konnte nicht geladen werden: {msg}', + 'alerts.loadFailed': 'Modell konnte nicht geladen werden: {msg}', 'alerts.exportFailed': 'Export fehlgeschlagen: {msg}', }, }; diff --git a/js/main.js b/js/main.js index f9f2916..12492d3 100644 --- a/js/main.js +++ b/js/main.js @@ -2,7 +2,7 @@ import * as THREE from 'three'; import { initViewer, loadGeometry, setMeshMaterial, setMeshGeometry, setWireframe, getControls, getCamera, getCurrentMesh, setExclusionOverlay, setHoverPreview, setViewerTheme } from './viewer.js'; -import { loadSTLFile, computeBounds, getTriangleCount } from './stlLoader.js'; +import { loadModelFile, computeBounds, getTriangleCount } from './stlLoader.js'; import { loadPresets, loadCustomTexture } from './presetTextures.js'; import { createPreviewMaterial, updateMaterial } from './previewMaterial.js'; import { subdivide } from './subdivision.js'; @@ -242,9 +242,9 @@ function wireEvents() { }); }); - // ── STL loading ── + // ── Model loading ── stlFileInput.addEventListener('change', (e) => { - if (e.target.files[0]) handleSTL(e.target.files[0]); + if (e.target.files[0]) handleModelFile(e.target.files[0]); }); // Drag & drop on the viewport section @@ -256,8 +256,8 @@ function wireEvents() { dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('drag-over'); - const file = [...e.dataTransfer.files].find(f => f.name.toLowerCase().endsWith('.stl')); - if (file) handleSTL(file); + const file = [...e.dataTransfer.files].find(f => /\.(stl|obj|3mf)$/i.test(f.name)); + if (file) handleModelFile(file); }); // Allow clicking the drop zone to open the file picker (except on canvas) @@ -1030,12 +1030,12 @@ function loadDefaultCube() { updatePreview(); } -async function handleSTL(file) { +async function handleModelFile(file) { try { - const { geometry, bounds } = await loadSTLFile(file); + const { geometry, bounds } = await loadModelFile(file); currentGeometry = geometry; currentBounds = bounds; - currentStlName = file.name.replace(/\.stl$/i, ''); + currentStlName = file.name.replace(/\.(stl|obj|3mf)$/i, ''); checkAmplitudeWarning(); // Dispose old preview material and reset state for the new mesh @@ -1113,7 +1113,7 @@ async function handleSTL(file) { exportBtn.disabled = (activeMapEntry === null); updatePreview(); } catch (err) { - console.error('Failed to load STL:', err); + console.error('Failed to load model:', err); alert(t('alerts.loadFailed', { msg: err.message })); } } diff --git a/js/stlLoader.js b/js/stlLoader.js index e5d50c2..3dc6950 100644 --- a/js/stlLoader.js +++ b/js/stlLoader.js @@ -1,7 +1,10 @@ import { STLLoader } from 'three/addons/loaders/STLLoader.js'; +import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'; +import { unzipSync } from 'fflate'; import * as THREE from 'three'; -const loader = new STLLoader(); +const stlLoader = new STLLoader(); +const objLoader = new OBJLoader(); /** * Load an STL from a File object. @@ -13,7 +16,7 @@ export function loadSTLFile(file) { const reader = new FileReader(); reader.onload = (e) => { try { - const geometry = loader.parse(e.target.result); + const geometry = stlLoader.parse(e.target.result); setupGeometry(geometry); const bounds = computeBounds(geometry); resolve({ geometry, bounds }); @@ -64,3 +67,281 @@ export function getTriangleCount(geometry) { ? geometry.index.count / 3 : pos.count / 3; } + +/** + * Load an OBJ from a File object. + * Returns { geometry, bounds }. + */ +export function loadOBJFile(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const group = objLoader.parse(e.target.result); + const geometry = mergeGroupGeometries(group); + setupGeometry(geometry); + const bounds = computeBounds(geometry); + resolve({ geometry, bounds }); + } catch (err) { + reject(err); + } + }; + reader.onerror = () => reject(new Error('Could not read file')); + reader.readAsText(file); + }); +} + +/** + * Load a 3MF from a File object. + * Custom parser that handles Bambu Studio / PrusaSlicer multi-file 3MF + * where meshes live in 3D/Objects/ subfiles referenced via the production + * extension (p:path on elements). + * Returns { geometry, bounds }. + */ +export function load3MFFile(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const geometry = parse3MF(new Uint8Array(e.target.result)); + setupGeometry(geometry); + const bounds = computeBounds(geometry); + resolve({ geometry, bounds }); + } catch (err) { + reject(err); + } + }; + reader.onerror = () => reject(new Error('Could not read file')); + reader.readAsArrayBuffer(file); + }); +} + +// ── Custom 3MF parser ──────────────────────────────────────────────────────── + +function parse3MF(data) { + const files = unzipSync(data); + const decoder = new TextDecoder(); + const parser = new DOMParser(); + + // Helper: read a file from the zip (keys may have or lack leading slash) + function readXML(path) { + const clean = path.replace(/^\//, ''); + const bytes = files[clean] || files['/' + clean]; + if (!bytes) return null; + return parser.parseFromString(decoder.decode(bytes), 'application/xml'); + } + + // Namespace-aware element queries + const NS_CORE = 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02'; + const NS_PROD = 'http://schemas.microsoft.com/3dmanufacturing/production/2015/06'; + + function getAttr(el, ns, name) { + return el.getAttributeNS(ns, name) || el.getAttribute(ns.split('/').pop() + ':' + name); + } + + // Parse all model files and collect objects by (filePath, id) + // objectMap: "path#id" → { vertices: Float32Array, triangles: Uint32Array } + const objectMap = new Map(); + + // Find all .model files in the zip + const modelPaths = Object.keys(files).filter(f => f.endsWith('.model')); + + for (const path of modelPaths) { + const doc = readXML(path); + if (!doc) continue; + const objects = doc.getElementsByTagNameNS(NS_CORE, 'object'); + for (const obj of objects) { + const id = obj.getAttribute('id'); + const meshEl = obj.getElementsByTagNameNS(NS_CORE, 'mesh')[0]; + if (!meshEl) continue; // component-only object, no inline mesh + + const vertEls = meshEl.getElementsByTagNameNS(NS_CORE, 'vertex'); + const triEls = meshEl.getElementsByTagNameNS(NS_CORE, 'triangle'); + const vertices = new Float32Array(vertEls.length * 3); + for (let i = 0; i < vertEls.length; i++) { + vertices[i * 3] = parseFloat(vertEls[i].getAttribute('x')); + vertices[i * 3 + 1] = parseFloat(vertEls[i].getAttribute('y')); + vertices[i * 3 + 2] = parseFloat(vertEls[i].getAttribute('z')); + } + const triangles = new Uint32Array(triEls.length * 3); + for (let i = 0; i < triEls.length; i++) { + triangles[i * 3] = parseInt(triEls[i].getAttribute('v1'), 10); + triangles[i * 3 + 1] = parseInt(triEls[i].getAttribute('v2'), 10); + triangles[i * 3 + 2] = parseInt(triEls[i].getAttribute('v3'), 10); + } + + // Normalise path for lookup (strip leading slash, use forward slashes) + const normPath = path.replace(/^\//, '').replace(/\\/g, '/'); + objectMap.set(normPath + '#' + id, { vertices, triangles }); + } + } + + if (objectMap.size === 0) throw new Error('No mesh data found in 3MF file'); + + // Resolve the root model's build items → collect (objectRef, transform) pairs + // Then recursively expand components to get final (meshRef, worldTransform) list. + const rootPath = modelPaths.find(p => /^3D\/3dmodel\.model$/i.test(p.replace(/^\//, ''))) + || modelPaths[0]; + const rootDoc = readXML(rootPath); + const rootNorm = rootPath.replace(/^\//, '').replace(/\\/g, '/'); + + // Collect final mesh instances: { meshKey, matrix } + const instances = []; + + function parseTransform(str) { + if (!str) return new THREE.Matrix4(); + const v = str.trim().split(/\s+/).map(Number); + if (v.length === 12) { + // 3MF row-major 3×4: m00 m01 m02 m10 m11 m12 m20 m21 m22 tx ty tz + return new THREE.Matrix4().set( + v[0], v[3], v[6], v[9], + v[1], v[4], v[7], v[10], + v[2], v[5], v[8], v[11], + 0, 0, 0, 1, + ); + } + return new THREE.Matrix4(); + } + + function resolveObject(filePath, objectId, parentMatrix) { + const normFile = filePath.replace(/^\//, '').replace(/\\/g, '/'); + const key = normFile + '#' + objectId; + + // If this object has a mesh, emit an instance + if (objectMap.has(key)) { + instances.push({ meshKey: key, matrix: parentMatrix.clone() }); + } + + // Also check for components (the object may have both mesh + components, + // or only components referencing other objects) + const doc = readXML(filePath); + if (!doc) return; + const objects = doc.getElementsByTagNameNS(NS_CORE, 'object'); + for (const obj of objects) { + if (obj.getAttribute('id') !== objectId) continue; + const components = obj.getElementsByTagNameNS(NS_CORE, 'component'); + for (const comp of components) { + const compObjId = comp.getAttribute('objectid'); + // p:path attribute tells us which file the referenced object lives in + let compPath = comp.getAttributeNS(NS_PROD, 'path') + || comp.getAttribute('p:path') + || filePath; + if (!compPath.startsWith('/') && !compPath.startsWith('3D')) { + compPath = '/' + compPath; + } + const compTransform = parseTransform(comp.getAttribute('transform')); + const combined = parentMatrix.clone().multiply(compTransform); + resolveObject(compPath, compObjId, combined); + } + } + } + + // Start from items in root model + const buildItems = rootDoc.getElementsByTagNameNS(NS_CORE, 'item'); + if (buildItems.length > 0) { + for (const item of buildItems) { + const objId = item.getAttribute('objectid'); + const itemTransform = parseTransform(item.getAttribute('transform')); + resolveObject(rootPath, objId, itemTransform); + } + } else { + // No build section — just use all meshes directly + for (const [key] of objectMap) { + instances.push({ meshKey: key, matrix: new THREE.Matrix4() }); + } + } + + if (instances.length === 0) { + // Fallback: use all parsed meshes + for (const [key] of objectMap) { + instances.push({ meshKey: key, matrix: new THREE.Matrix4() }); + } + } + + // Build non-indexed BufferGeometry from all instances + let totalTris = 0; + for (const inst of instances) { + const mesh = objectMap.get(inst.meshKey); + if (mesh) totalTris += mesh.triangles.length / 3; + } + + const positions = new Float32Array(totalTris * 9); + let writeOffset = 0; + const tmpV = new THREE.Vector3(); + + for (const inst of instances) { + const mesh = objectMap.get(inst.meshKey); + if (!mesh) continue; + const { vertices, triangles } = mesh; + for (let t = 0; t < triangles.length; t += 3) { + for (let v = 0; v < 3; v++) { + const vi = triangles[t + v]; + tmpV.set(vertices[vi * 3], vertices[vi * 3 + 1], vertices[vi * 3 + 2]); + tmpV.applyMatrix4(inst.matrix); + positions[writeOffset++] = tmpV.x; + positions[writeOffset++] = tmpV.y; + positions[writeOffset++] = tmpV.z; + } + } + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + return geometry; +} + +/** + * Unified loader: dispatches to the right parser based on file extension. + */ +export function loadModelFile(file) { + const ext = file.name.split('.').pop().toLowerCase(); + if (ext === 'obj') return loadOBJFile(file); + if (ext === '3mf') return load3MFFile(file); + return loadSTLFile(file); +} + +/** + * Extract and merge all mesh geometries from a Group (OBJ/3MF) into a single + * non-indexed BufferGeometry suitable for the texturizer pipeline. + */ +function mergeGroupGeometries(group) { + const geometries = []; + group.traverse((child) => { + if (child.isMesh && child.geometry) { + // Apply the mesh's world transform to the geometry + const geo = child.geometry.clone(); + child.updateWorldMatrix(true, false); + geo.applyMatrix4(child.matrixWorld); + // Convert indexed → non-indexed so vertex layout matches our pipeline + if (geo.index) { + geometries.push(geo.toNonIndexed()); + geo.dispose(); + } else { + geometries.push(geo); + } + } + }); + if (geometries.length === 0) throw new Error('No mesh data found in file'); + if (geometries.length === 1) return geometries[0]; + + // Merge multiple geometries into one + const totalVerts = geometries.reduce((sum, g) => sum + g.attributes.position.count, 0); + const mergedPos = new Float32Array(totalVerts * 3); + let mergedNrm = null; + const hasNormals = geometries.every(g => g.attributes.normal); + if (hasNormals) mergedNrm = new Float32Array(totalVerts * 3); + let offset = 0; + for (const g of geometries) { + const posArr = g.attributes.position.array; + mergedPos.set(posArr, offset * 3); + if (hasNormals && mergedNrm) { + mergedNrm.set(g.attributes.normal.array, offset * 3); + } + offset += g.attributes.position.count; + g.dispose(); + } + const merged = new THREE.BufferGeometry(); + merged.setAttribute('position', new THREE.BufferAttribute(mergedPos, 3)); + if (mergedNrm) merged.setAttribute('normal', new THREE.BufferAttribute(mergedNrm, 3)); + return merged; +}