3D viewer: non-blocking STEP parse in Web Worker, loading progress bar, data overlay (file size + triangle count), finer tessellation + flat shading

This commit is contained in:
trilium-share-gruvbox
2026-08-28 17:20:27 +02:00
parent 71d6311e49
commit 3968011e3c
2 changed files with 244 additions and 87 deletions
+200 -87
View File
@@ -6,8 +6,67 @@
var OCCT_JS = "api/notes/__OCCT_JS_ID__/download";
var OCCT_WASM = "api/notes/__OCCT_WASM_ID__/download";
var OCCT_JS_ABS = new URL(OCCT_JS, location.href).href;
var OCCT_WASM_ABS = new URL(OCCT_WASM, location.href).href;
var MODEL_RE = /\.(stl|step|stp)$/i;
var STEP_PARAMS = { linearDeflection: 0.0005, angularDeflection: 0.2 };
var WORKER_SRC = [
"self.onmessage = function (e) {",
" var d = e.data;",
" if (d.type === 'loadLib') {",
" try { importScripts(d.libUrl); } catch (err) {",
" self.postMessage({ type: 'error', message: 'importScripts: ' + (err && err.message || err) }); return;",
" }",
" if (typeof self.occtimportjs !== 'function') {",
" self.postMessage({ type: 'error', message: 'STEP parser not found.' }); return;",
" }",
" self.occtimportjs({ locateFile: function () { return 'occt.wasm'; }, wasmBinary: d.wasm }).then(function (occt) {",
" self.occt = occt;",
" self.postMessage({ type: 'ready' });",
" }).catch(function (err) {",
" self.postMessage({ type: 'error', message: 'STEP init: ' + (err && err.message || err) });",
" });",
" return;",
" }",
" if (d.type === 'parse' && self.occt) {",
" try {",
" var result = self.occt.ReadStepFile(d.data, d.params);",
" var meshes = [];",
" (result.meshes || []).forEach(function (m) {",
" if (!m.attributes || !m.attributes.position) return;",
" var out = { position: m.attributes.position.array, color: m.color };",
" if (m.attributes.index && m.attributes.index.array) out.index = m.attributes.index.array;",
" meshes.push(out);",
" });",
" var transfer = [];",
" meshes.forEach(function (m) {",
" if (m.position && m.position.buffer) transfer.push(m.position.buffer);",
" if (m.index && m.index.buffer) transfer.push(m.index.buffer);",
" });",
" self.postMessage({ type: 'done', success: !!result.success, meshes: meshes }, transfer);",
" } catch (err) {",
" self.postMessage({ type: 'error', message: 'parse: ' + (err && err.message || err) });",
" }",
" }",
"};"
].join("\n");
function formatBytes(n) {
if (n >= 1048576) return (n / 1048576).toFixed(1) + " MB";
if (n >= 1024) return (n / 1024).toFixed(1) + " KB";
return n + " B";
}
function formatCount(n) {
n = Math.round(n);
if (n >= 1000000) return (n / 1000000).toFixed(2) + "M";
if (n >= 1000) return (n / 1000).toFixed(1) + "k";
return String(n);
}
function makeViewer(name) {
var box = document.createElement("div");
box.className = "model-viewer";
@@ -16,7 +75,16 @@
var status = document.createElement("div");
status.className = "model-viewer-status";
status.textContent = "Loading 3D model\u2026";
var progress = document.createElement("div");
progress.className = "model-viewer-progress";
var fill = document.createElement("div");
fill.className = "model-viewer-progress-fill";
progress.appendChild(fill);
var data = document.createElement("div");
data.className = "model-viewer-data";
wrap.appendChild(status);
wrap.appendChild(progress);
box.appendChild(data);
box.appendChild(wrap);
if (name) {
var cap = document.createElement("div");
@@ -34,8 +102,63 @@
document.head.appendChild(s);
}
function fetchProgress(url, onProgress) {
return fetch(url).then(function (r) {
if (!r.ok) throw new Error("fetch " + r.status);
var total = parseInt(r.headers.get("Content-Length") || "0", 10);
var reader = r.body.getReader();
var chunks = [];
var received = 0;
function pump() {
return reader.read().then(function (res) {
if (res.done) {
var buf = new Uint8Array(received);
var off = 0;
chunks.forEach(function (c) { buf.set(c, off); off += c.length; });
return buf.buffer;
}
chunks.push(res.value);
received += res.value.length;
if (total) onProgress(received / total);
return pump();
});
}
return pump();
});
}
function initViewer(el, url, ext) {
var isStep = /\.(step|stp)$/i.test(ext);
var fileSize = null;
function setStatus(text) {
var s = el.querySelector(".model-viewer-status");
if (s) s.textContent = text;
}
function setProgress(percent) {
var bar = el.querySelector(".model-viewer-progress-fill");
if (!bar) return;
if (typeof percent === "number") {
bar.classList.remove("indeterminate");
bar.style.width = Math.round(percent * 100) + "%";
} else {
bar.classList.add("indeterminate");
bar.style.width = "";
}
}
function onError(msg) {
var s = el.querySelector(".model-viewer-status");
if (s) { s.textContent = msg || "Could not load 3D model."; s.classList.add("error"); }
setProgress(0);
}
function showData(extra) {
var d = el.querySelector(".model-viewer-data");
if (!d) return;
var parts = [];
if (fileSize) parts.push(formatBytes(fileSize));
if (extra) parts.push(extra);
if (parts.length) { d.textContent = parts.join(" \u00b7 "); d.style.display = "block"; }
}
function loadThree(cb) {
if (window.THREE && window.THREE.OrbitControls) { cb(); return; }
@@ -48,115 +171,105 @@
});
}
function status(text) {
var s = el.querySelector(".model-viewer-status");
if (s) s.textContent = text;
}
setProgress(null);
setStatus("Loading 3D libraries\u2026");
function fit(group) {
var box = new THREE.Box3().setFromObject(group);
if (box.isEmpty()) return;
var size = box.getSize(new THREE.Vector3()).length() || 1;
var center = box.getCenter(new THREE.Vector3());
var d = size * 2.4;
camera.position.set(center.x + d * 0.9, center.y + d * 0.6, center.z + d);
controls.target.copy(center);
controls.update();
}
function buildScene(cb) {
var statusEl = el.querySelector(".model-viewer-status");
loadThree(function () {
var wrap = el.querySelector(".model-viewer-canvas") || el;
var w = wrap.clientWidth || el.clientWidth || 400;
var h = Math.min(520, Math.max(320, Math.round(w * 0.62)));
var renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(w, h);
renderer.setClearColor(0x1d2021, 1);
wrap.innerHTML = "";
wrap.appendChild(renderer.domElement);
var hint = document.createElement("div");
hint.className = "model-viewer-hint";
hint.textContent = "Drag to rotate \u00b7 scroll to zoom";
wrap.appendChild(hint);
var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 1000000);
camera.position.set(300, 220, 320);
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
var dir = new THREE.DirectionalLight(0xffffff, 1.0);
dir.position.set(1, 1.5, 1);
scene.add(dir);
var back = new THREE.DirectionalLight(0xffffff, 0.35);
back.position.set(-1, -0.5, -1);
scene.add(back);
var dir = new THREE.DirectionalLight(0xffffff, 1.0); dir.position.set(1, 1.5, 1); scene.add(dir);
var back = new THREE.DirectionalLight(0xffffff, 0.35); back.position.set(-1, -0.5, -1); scene.add(back);
var controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
var group = new THREE.Group();
scene.add(group);
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); }
animate();
cb({ group: group, scene: scene, camera: camera, controls: controls, statusEl: statusEl });
}
function fit() {
var box = new THREE.Box3().setFromObject(group);
if (box.isEmpty()) return;
var size = box.getSize(new THREE.Vector3()).length() || 1;
var center = box.getCenter(new THREE.Vector3());
var d = size * 2.4;
camera.position.set(center.x + d * 0.9, center.y + d * 0.6, center.z + d);
controls.target.copy(center);
controls.update();
}
function showCanvas() {
wrap.innerHTML = "";
wrap.appendChild(renderer.domElement);
var hint = document.createElement("div");
hint.className = "model-viewer-hint";
hint.textContent = "Drag to rotate \u00b7 scroll to zoom";
wrap.appendChild(hint);
}
function onError(msg) {
var s = el.querySelector(".model-viewer-status");
if (s) { s.textContent = msg || "Could not load 3D model."; s.classList.add("error"); }
}
loadThree(function () {
buildScene(function (ctx) {
fetch(url).then(function (r) {
if (!r.ok) throw new Error("fetch " + r.status);
return r.arrayBuffer();
}).then(function (buf) {
var data = new Uint8Array(buf);
if (isStep) {
ctx.statusEl.textContent = "Loading STEP parser\u2026";
function runOcct() {
ctx.statusEl.textContent = "Parsing STEP\u2026";
window.occtimportjs({ locateFile: function () { return OCCT_WASM; } }).then(function (occt) {
var result = occt.ReadStepFile(data, null);
if (!result || !result.success) { onError("Could not parse STEP file."); return; }
(result.meshes || []).forEach(function (mesh) {
if (!mesh.attributes || !mesh.attributes.position) return;
setStatus("Downloading model\u2026");
fetchProgress(url, function (p) { setProgress(p); }).then(function (buf) {
fileSize = buf.byteLength;
showData();
var data = new Uint8Array(buf);
if (isStep) {
setStatus("Downloading STEP parser\u2026");
setProgress(null);
fetchProgress(OCCT_WASM_ABS, function () {}).then(function (wasmBuf) {
setStatus("Parsing STEP\u2026");
setProgress(null);
var blob = new Blob([WORKER_SRC], { type: "application/javascript" });
var wurl = URL.createObjectURL(blob);
var worker = new Worker(wurl);
var settled = false;
worker.onmessage = function (e) {
if (e.data.type === "ready") {
worker.postMessage({ type: "parse", data: data, params: STEP_PARAMS }, [data.buffer]);
} else if (e.data.type === "done") {
settled = true; worker.terminate(); URL.revokeObjectURL(wurl);
if (!e.data.success) { onError("Could not parse STEP file."); return; }
var tris = 0;
(e.data.meshes || []).forEach(function (m) {
var geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(mesh.attributes.position.array, 3));
if (mesh.attributes.normal && mesh.attributes.normal.array) {
geo.setAttribute("normal", new THREE.Float32BufferAttribute(mesh.attributes.normal.array, 3));
}
if (mesh.attributes.index && mesh.attributes.index.array) {
geo.setIndex(mesh.attributes.index.array);
}
var color = mesh.color && mesh.color.length === 3
? new THREE.Color(mesh.color[0], mesh.color[1], mesh.color[2]) : null;
var mat = new THREE.MeshStandardMaterial({
color: color || 0xb8bb26,
metalness: 0.25,
roughness: 0.55,
side: THREE.DoubleSide
});
ctx.group.add(new THREE.Mesh(geo, mat));
geo.setAttribute("position", new THREE.Float32BufferAttribute(m.position, 3));
if (m.index) geo.setIndex(m.index);
geo.computeVertexNormals();
var color = m.color && m.color.length === 3 ? new THREE.Color(m.color[0], m.color[1], m.color[2]) : null;
var mat = new THREE.MeshStandardMaterial({ color: color || 0xb8bb26, metalness: 0.2, roughness: 0.6, side: THREE.DoubleSide, flatShading: true });
group.add(new THREE.Mesh(geo, mat));
tris += geo.index ? geo.index.count / 3 : geo.attributes.position.count / 3;
});
fit(ctx.group);
}).catch(function (e) { onError("Could not load STEP parser."); });
}
if (window.occtimportjs) { runOcct(); } else { loadScript(OCCT_JS, runOcct); }
} else {
var geo = new THREE.STLLoader().parse(data);
geo.computeVertexNormals();
var mat = new THREE.MeshStandardMaterial({ color: 0xb8bb26, metalness: 0.25, roughness: 0.55 });
ctx.group.add(new THREE.Mesh(geo, mat));
fit(ctx.group);
}
}).catch(function (e) { onError("Could not load 3D model."); });
});
fit();
showCanvas();
showData(formatCount(tris) + " triangles");
} else if (e.data.type === "error") {
settled = true; worker.terminate(); URL.revokeObjectURL(wurl);
onError(e.data.message || "Could not parse STEP file.");
}
};
worker.onerror = function (e) {
if (!settled) { settled = true; worker.terminate(); URL.revokeObjectURL(wurl); onError("STEP worker error: " + (e && e.message || "unknown")); }
};
worker.postMessage({ type: "loadLib", libUrl: OCCT_JS_ABS, wasm: new Uint8Array(wasmBuf) }, [wasmBuf]);
}).catch(function () { onError("Could not download STEP parser."); });
} else {
var geo = new THREE.STLLoader().parse(data);
geo.computeVertexNormals();
var mat = new THREE.MeshStandardMaterial({ color: 0xb8bb26, metalness: 0.25, roughness: 0.55, flatShading: true });
group.add(new THREE.Mesh(geo, mat));
fit();
showCanvas();
showData(formatCount(geo.index ? geo.index.count / 3 : geo.attributes.position.count / 3) + " triangles");
}
}).catch(function () { onError("Could not load 3D model."); });
});
}
+44
View File
@@ -810,6 +810,7 @@ html.theme-light #content code {
/* ---------- 3D model viewer (STL / STEP) ---------- */
.model-viewer {
position: relative;
margin: 1em 0;
background: #1d2021;
border: 1px solid var(--background-highlight);
@@ -817,6 +818,22 @@ html.theme-light #content code {
overflow: hidden;
}
.model-viewer-data {
position: absolute;
top: 8px;
right: 10px;
z-index: 3;
display: none;
font-size: 0.75em;
color: #a89984;
background: rgba(40, 40, 40, 0.8);
border: 1px solid #504945;
border-radius: 6px;
padding: 2px 10px;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.model-viewer-canvas {
position: relative;
}
@@ -832,6 +849,33 @@ html.theme-light #content code {
color: #fb4934;
}
.model-viewer-progress {
position: relative;
height: 6px;
margin: 0 48px 22px;
background: #3c3836;
border-radius: 3px;
overflow: hidden;
}
.model-viewer-progress-fill {
height: 100%;
width: 0;
background: #b8bb26;
border-radius: 3px;
transition: width 150ms ease;
}
.model-viewer-progress-fill.indeterminate {
width: 40%;
animation: model-progress 1.1s ease-in-out infinite;
}
@keyframes model-progress {
0% { transform: translateX(-100%); }
100% { transform: translateX(250%); }
}
.model-viewer-hint {
position: absolute;
top: 8px;