mirror of
https://github.com/CNCKitchen/stlTexturizer.git
synced 2026-04-07 22:11:32 +00:00
feat: support loading multiple model formats (.stl, .obj, .3mf) and update UI accordingly
This commit is contained in:
Binary file not shown.
+7
-6
@@ -31,7 +31,8 @@
|
|||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -67,8 +68,8 @@
|
|||||||
<path d="M2 17L12 22L22 17" stroke="#555" stroke-width="1.2" stroke-linejoin="round"/>
|
<path d="M2 17L12 22L22 17" stroke="#555" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
<path d="M2 12L12 17L22 12" stroke="#555" stroke-width="1.2" stroke-linejoin="round"/>
|
<path d="M2 12L12 17L22 12" stroke="#555" stroke-width="1.2" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
<p data-i18n-html="dropHint.text">Drop an <strong>.stl</strong> file here<br/>or <label for="stl-file-input" class="link-label">click to browse</label></p>
|
<p data-i18n-html="dropHint.text">Drop an <strong>.stl</strong>, <strong>.obj</strong> or <strong>.3mf</strong> file here<br/>or <label for="stl-file-input" class="link-label">click to browse</label></p>
|
||||||
<input type="file" id="stl-file-input" accept=".stl" hidden />
|
<input type="file" id="stl-file-input" accept=".stl,.obj,.3mf" hidden />
|
||||||
</div>
|
</div>
|
||||||
<canvas id="viewport"></canvas>
|
<canvas id="viewport"></canvas>
|
||||||
<div id="brush-cursor"></div>
|
<div id="brush-cursor"></div>
|
||||||
@@ -95,7 +96,7 @@
|
|||||||
<div class="load-stl-row">
|
<div class="load-stl-row">
|
||||||
<label class="upload-btn" for="stl-file-input" style="flex:1;justify-content:center;">
|
<label class="upload-btn" for="stl-file-input" style="flex:1;justify-content:center;">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 2L2 7l10 5 10-5-10-5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 2L2 7l10 5 10-5-10-5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
|
||||||
<span data-i18n="ui.loadStl">Load STL…</span>
|
<span data-i18n="ui.loadStl">Load Model…</span>
|
||||||
</label>
|
</label>
|
||||||
<button id="place-on-face-btn" class="place-on-face-btn" data-i18n-title="ui.placeOnFaceTitle" title="Click a face to orient it downward onto the print bed">
|
<button id="place-on-face-btn" class="place-on-face-btn" data-i18n-title="ui.placeOnFaceTitle" title="Click a face to orient it downward onto the print bed">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 3v14m0 0l-5-5m5 5l5-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 21h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M12 3v14m0 0l-5-5m5 5l5-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M4 21h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
@@ -143,12 +144,12 @@
|
|||||||
<input type="number" class="val" id="seam-blend-val" value="1" min="0" max="1" step="0.01" />
|
<input type="number" class="val" id="seam-blend-val" value="1" min="0" max="1" step="0.01" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row slider-row">
|
<div class="form-row slider-row">
|
||||||
<label for="seam-band-width" title="Width of the blending zone near seam edges. Lower values keep transitions tight to the seam; higher values blend a wider band.">Smoothing ⓘ</label>
|
<label for="seam-band-width" data-i18n="labels.smoothing" data-i18n-title="tooltips.smoothing" title="Width of the blending zone near seam edges. Lower values keep transitions tight to the seam; higher values blend a wider band.">Smoothing ⓘ</label>
|
||||||
<input type="range" id="seam-band-width" min="0" max="1" step="0.01" value="0.5" />
|
<input type="range" id="seam-band-width" min="0" max="1" step="0.01" value="0.5" />
|
||||||
<input type="number" class="val" id="seam-band-width-val" value="0.5" min="0" max="1" step="0.01" />
|
<input type="number" class="val" id="seam-band-width-val" value="0.5" min="0" max="1" step="0.01" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row slider-row" id="cap-angle-row" style="display:none">
|
<div class="form-row slider-row" id="cap-angle-row" style="display:none">
|
||||||
<label for="cap-angle" title="Angle (in degrees) from vertical at which the top/bottom cap projection kicks in. Smaller values limit cap projection to nearly flat faces.">Cap Angle ⓘ</label>
|
<label for="cap-angle" data-i18n="labels.capAngle" data-i18n-title="tooltips.capAngle" title="Angle (in degrees) from vertical at which the top/bottom cap projection kicks in. Smaller values limit cap projection to nearly flat faces.">Cap Angle ⓘ</label>
|
||||||
<input type="range" id="cap-angle" min="1" max="89" step="1" value="20" />
|
<input type="range" id="cap-angle" min="1" max="89" step="1" value="20" />
|
||||||
<input type="number" class="val" id="cap-angle-val" value="20" min="1" max="89" step="1" />
|
<input type="number" class="val" id="cap-angle-val" value="20" min="1" max="89" step="1" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+16
-8
@@ -10,15 +10,15 @@ export const TRANSLATIONS = {
|
|||||||
'theme.toggleAriaLabel': 'Toggle light/dark mode',
|
'theme.toggleAriaLabel': 'Toggle light/dark mode',
|
||||||
|
|
||||||
// Drop zone
|
// Drop zone
|
||||||
'dropHint.text': 'Drop an <strong>.stl</strong> file here<br/>or <label for="stl-file-input" class="link-label">click to browse</label>',
|
'dropHint.text': 'Drop an <strong>.stl</strong>, <strong>.obj</strong> or <strong>.3mf</strong> file here<br/>or <label for="stl-file-input" class="link-label">click to browse</label>',
|
||||||
|
|
||||||
// Viewport footer
|
// Viewport footer
|
||||||
'ui.wireframe': 'Wireframe',
|
'ui.wireframe': 'Wireframe',
|
||||||
'ui.controlsHint': 'Left drag: orbit \u00a0·\u00a0 Right drag: pan \u00a0·\u00a0 Scroll: zoom',
|
'ui.controlsHint': 'Left drag: orbit \u00a0·\u00a0 Right drag: pan \u00a0·\u00a0 Scroll: zoom',
|
||||||
'ui.meshInfo': '{n} triangles · {mb} MB · {sx} × {sy} × {sz} mm',
|
'ui.meshInfo': '{n} triangles · {mb} MB · {sx} × {sy} × {sz} mm',
|
||||||
|
|
||||||
// Load STL button
|
// Load model button
|
||||||
'ui.loadStl': 'Load STL\u2026',
|
'ui.loadStl': 'Load Model\u2026',
|
||||||
|
|
||||||
// Displacement map section
|
// Displacement map section
|
||||||
'sections.displacementMap': 'Displacement Map',
|
'sections.displacementMap': 'Displacement Map',
|
||||||
@@ -53,6 +53,10 @@ export const TRANSLATIONS = {
|
|||||||
// Seam blend
|
// Seam blend
|
||||||
'labels.seamBlend': 'Seam Blend \u24d8',
|
'labels.seamBlend': 'Seam Blend \u24d8',
|
||||||
'tooltips.seamBlend': 'Softens the hard seam where projection faces meet. Effective for Cubic and Cylindrical modes.',
|
'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
|
// Mask angles section
|
||||||
'sections.maskAngles': 'Mask Angles \u24d8',
|
'sections.maskAngles': 'Mask Angles \u24d8',
|
||||||
@@ -150,7 +154,7 @@ export const TRANSLATIONS = {
|
|||||||
'cta.storeDismiss': 'Dismiss',
|
'cta.storeDismiss': 'Dismiss',
|
||||||
|
|
||||||
// Alerts
|
// Alerts
|
||||||
'alerts.loadFailed': 'Could not load STL: {msg}',
|
'alerts.loadFailed': 'Could not load model: {msg}',
|
||||||
'alerts.exportFailed': 'Export failed: {msg}',
|
'alerts.exportFailed': 'Export failed: {msg}',
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -162,15 +166,15 @@ export const TRANSLATIONS = {
|
|||||||
'theme.toggleAriaLabel': 'Hell/Dunkel-Modus wechseln',
|
'theme.toggleAriaLabel': 'Hell/Dunkel-Modus wechseln',
|
||||||
|
|
||||||
// Drop zone
|
// Drop zone
|
||||||
'dropHint.text': '<strong>.stl</strong>-Datei hier ablegen<br/>oder <label for="stl-file-input" class="link-label">zum Durchsuchen klicken</label>',
|
'dropHint.text': '<strong>.stl</strong>-, <strong>.obj</strong>- oder <strong>.3mf</strong>-Datei hier ablegen<br/>oder <label for="stl-file-input" class="link-label">zum Durchsuchen klicken</label>',
|
||||||
|
|
||||||
// Viewport footer
|
// Viewport footer
|
||||||
'ui.wireframe': 'Drahtgitter',
|
'ui.wireframe': 'Drahtgitter',
|
||||||
'ui.controlsHint': 'Linke Maustaste: Drehen \u00a0·\u00a0 Rechte Maustaste: Verschieben \u00a0·\u00a0 Mausrad: Zoomen',
|
'ui.controlsHint': 'Linke Maustaste: Drehen \u00a0·\u00a0 Rechte Maustaste: Verschieben \u00a0·\u00a0 Mausrad: Zoomen',
|
||||||
'ui.meshInfo': '{n} Dreiecke · {mb} MB · {sx} × {sy} × {sz} mm',
|
'ui.meshInfo': '{n} Dreiecke · {mb} MB · {sx} × {sy} × {sz} mm',
|
||||||
|
|
||||||
// Load STL button
|
// Load model button
|
||||||
'ui.loadStl': 'STL laden\u2026',
|
'ui.loadStl': 'Modell laden\u2026',
|
||||||
|
|
||||||
// Displacement map section
|
// Displacement map section
|
||||||
'sections.displacementMap': 'Textur',
|
'sections.displacementMap': 'Textur',
|
||||||
@@ -205,6 +209,10 @@ export const TRANSLATIONS = {
|
|||||||
// Seam blend
|
// Seam blend
|
||||||
'labels.seamBlend': 'Nahtglättung \u24d8',
|
'labels.seamBlend': 'Nahtglättung \u24d8',
|
||||||
'tooltips.seamBlend': 'Glättet den scharfen Übergang zwischen Projektionsflächen. Wirksam für Kubische und Zylindrische Modi.',
|
'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
|
// Winkelmaskierung
|
||||||
'sections.maskAngles': 'Winkel maskieren \u24d8',
|
'sections.maskAngles': 'Winkel maskieren \u24d8',
|
||||||
@@ -302,7 +310,7 @@ export const TRANSLATIONS = {
|
|||||||
'cta.storeDismiss': 'Ausblenden',
|
'cta.storeDismiss': 'Ausblenden',
|
||||||
|
|
||||||
// Alerts
|
// Alerts
|
||||||
'alerts.loadFailed': 'STL konnte nicht geladen werden: {msg}',
|
'alerts.loadFailed': 'Modell konnte nicht geladen werden: {msg}',
|
||||||
'alerts.exportFailed': 'Export fehlgeschlagen: {msg}',
|
'alerts.exportFailed': 'Export fehlgeschlagen: {msg}',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+9
-9
@@ -2,7 +2,7 @@ import * as THREE from 'three';
|
|||||||
import { initViewer, loadGeometry, setMeshMaterial, setMeshGeometry, setWireframe,
|
import { initViewer, loadGeometry, setMeshMaterial, setMeshGeometry, setWireframe,
|
||||||
getControls, getCamera, getCurrentMesh,
|
getControls, getCamera, getCurrentMesh,
|
||||||
setExclusionOverlay, setHoverPreview, setViewerTheme } from './viewer.js';
|
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 { loadPresets, loadCustomTexture } from './presetTextures.js';
|
||||||
import { createPreviewMaterial, updateMaterial } from './previewMaterial.js';
|
import { createPreviewMaterial, updateMaterial } from './previewMaterial.js';
|
||||||
import { subdivide } from './subdivision.js';
|
import { subdivide } from './subdivision.js';
|
||||||
@@ -242,9 +242,9 @@ function wireEvents() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── STL loading ──
|
// ── Model loading ──
|
||||||
stlFileInput.addEventListener('change', (e) => {
|
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
|
// Drag & drop on the viewport section
|
||||||
@@ -256,8 +256,8 @@ function wireEvents() {
|
|||||||
dropZone.addEventListener('drop', (e) => {
|
dropZone.addEventListener('drop', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropZone.classList.remove('drag-over');
|
dropZone.classList.remove('drag-over');
|
||||||
const file = [...e.dataTransfer.files].find(f => f.name.toLowerCase().endsWith('.stl'));
|
const file = [...e.dataTransfer.files].find(f => /\.(stl|obj|3mf)$/i.test(f.name));
|
||||||
if (file) handleSTL(file);
|
if (file) handleModelFile(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Allow clicking the drop zone to open the file picker (except on canvas)
|
// Allow clicking the drop zone to open the file picker (except on canvas)
|
||||||
@@ -1030,12 +1030,12 @@ function loadDefaultCube() {
|
|||||||
updatePreview();
|
updatePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSTL(file) {
|
async function handleModelFile(file) {
|
||||||
try {
|
try {
|
||||||
const { geometry, bounds } = await loadSTLFile(file);
|
const { geometry, bounds } = await loadModelFile(file);
|
||||||
currentGeometry = geometry;
|
currentGeometry = geometry;
|
||||||
currentBounds = bounds;
|
currentBounds = bounds;
|
||||||
currentStlName = file.name.replace(/\.stl$/i, '');
|
currentStlName = file.name.replace(/\.(stl|obj|3mf)$/i, '');
|
||||||
checkAmplitudeWarning();
|
checkAmplitudeWarning();
|
||||||
|
|
||||||
// Dispose old preview material and reset state for the new mesh
|
// Dispose old preview material and reset state for the new mesh
|
||||||
@@ -1113,7 +1113,7 @@ async function handleSTL(file) {
|
|||||||
exportBtn.disabled = (activeMapEntry === null);
|
exportBtn.disabled = (activeMapEntry === null);
|
||||||
updatePreview();
|
updatePreview();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load STL:', err);
|
console.error('Failed to load model:', err);
|
||||||
alert(t('alerts.loadFailed', { msg: err.message }));
|
alert(t('alerts.loadFailed', { msg: err.message }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+283
-2
@@ -1,7 +1,10 @@
|
|||||||
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
|
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';
|
import * as THREE from 'three';
|
||||||
|
|
||||||
const loader = new STLLoader();
|
const stlLoader = new STLLoader();
|
||||||
|
const objLoader = new OBJLoader();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load an STL from a File object.
|
* Load an STL from a File object.
|
||||||
@@ -13,7 +16,7 @@ export function loadSTLFile(file) {
|
|||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
try {
|
try {
|
||||||
const geometry = loader.parse(e.target.result);
|
const geometry = stlLoader.parse(e.target.result);
|
||||||
setupGeometry(geometry);
|
setupGeometry(geometry);
|
||||||
const bounds = computeBounds(geometry);
|
const bounds = computeBounds(geometry);
|
||||||
resolve({ geometry, bounds });
|
resolve({ geometry, bounds });
|
||||||
@@ -64,3 +67,281 @@ export function getTriangleCount(geometry) {
|
|||||||
? geometry.index.count / 3
|
? geometry.index.count / 3
|
||||||
: pos.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 <component> 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 <build> 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user