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;
+}