diff --git a/scripts/3d_model_viewer.js b/scripts/3d_model_viewer.js index d851d7c..665d712 100644 --- a/scripts/3d_model_viewer.js +++ b/scripts/3d_model_viewer.js @@ -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."); }); }); } diff --git a/theme/blog_share_theme.css b/theme/blog_share_theme.css index c279360..d0dd1c8 100644 --- a/theme/blog_share_theme.css +++ b/theme/blog_share_theme.css @@ -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;