3d model viewer
<script>
(function () {
var THREE_JS = "api/notes/wQyNKaanbWgm/download";
var ORBIT_JS = "api/notes/guoFoLQMsGCZ/download";
var STL_JS = "api/notes/HRZXDBx1qUdX/download";
var OCCT_JS = "api/notes/WTKn6k8aj2IE/download";
var OCCT_WASM = "api/notes/f6FAsBlBNG96/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.4, angularDeflection: 0.2 };
var WORKER_SRC = [
'import { OcctKernel } from "' + OCCT_JS_ABS + '";',
"let kernel = null; let initP = null;",
"self.onmessage = async function (e) {",
" var d = e.data;",
" if (d.type !== 'parse') return;",
" try {",
" if (!initP) initP = OcctKernel.init({ wasm: d.wasm });",
" kernel = await initP;",
" var shape = kernel.importStep(d.data);",
" var mesh = kernel.tessellate(shape, d.params);",
" kernel.release(shape);",
" self.postMessage({ type: 'done', positions: mesh.positions, normals: mesh.normals, indices: mesh.indices }, [mesh.positions.buffer, mesh.normals.buffer, mesh.indices.buffer]);",
" } 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 fixWinding(geo) {
if (geo.index) return;
var pos = geo.attributes.position;
var count = pos.count;
geo.computeBoundingBox();
var center = geo.boundingBox.getCenter(new THREE.Vector3());
var vA = new THREE.Vector3(), vB = new THREE.Vector3(), vC = new THREE.Vector3();
var e1 = new THREE.Vector3(), e2 = new THREE.Vector3(), n = new THREE.Vector3(), toC = new THREE.Vector3();
var p = pos.array;
for (var i = 0; i < count; i += 3) {
vA.fromBufferAttribute(pos, i); vB.fromBufferAttribute(pos, i + 1); vC.fromBufferAttribute(pos, i + 2);
e1.subVectors(vB, vA); e2.subVectors(vC, vA); n.crossVectors(e1, e2);
toC.subVectors(center, vA);
if (n.dot(toC) > 0) {
var x = (i + 1) * 3, y = (i + 2) * 3;
var t;
t = p[x]; p[x] = p[y]; p[y] = t;
t = p[x + 1]; p[x + 1] = p[y + 1]; p[y + 1] = t;
t = p[x + 2]; p[x + 2] = p[y + 2]; p[y + 2] = t;
}
}
pos.needsUpdate = true;
}
function chunkGeometry(geo, group, mat) {
var pos = geo.attributes.position;
var idx = geo.index;
var maxVerts = 65000;
if (!idx || pos.count <= maxVerts) {
geo.computeVertexNormals();
group.add(new THREE.Mesh(geo, mat));
return;
}
var triCount = idx.count / 3;
var t = 0;
while (t < triCount) {
var vmap = new Map();
var vpos = [];
var iarr = [];
while (t < triCount && vmap.size < maxVerts - 3) {
var a = idx.getX(t * 3), b = idx.getX(t * 3 + 1), c = idx.getX(t * 3 + 2);
var ids = [a, b, c];
for (var k = 0; k < 3; k++) {
var id = ids[k];
var loc = vmap.get(id);
if (loc === undefined) {
loc = vpos.length / 3;
vmap.set(id, loc);
vpos.push(pos.getX(id), pos.getY(id), pos.getZ(id));
}
iarr.push(loc);
}
t++;
}
var g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.Float32BufferAttribute(vpos, 3));
g.setIndex(new THREE.BufferAttribute(new Uint16Array(iarr), 1));
g.computeVertexNormals();
group.add(new THREE.Mesh(g, mat));
}
}
function makeViewer(name) {
var box = document.createElement("div");
box.className = "model-viewer";
var wrap = document.createElement("div");
wrap.className = "model-viewer-canvas";
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");
cap.className = "model-viewer-caption";
cap.textContent = name;
box.appendChild(cap);
}
return box;
}
function loadScript(src, cb) {
var s = document.createElement("script");
s.src = src;
s.onload = cb;
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) {
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"; }
}
var cap = el.querySelector(".model-viewer-caption");
if (cap && extra) {
var stats = [];
if (fileSize) stats.push(formatBytes(fileSize));
stats.push(extra);
var name = cap.getAttribute("data-model-name");
if (!name) { name = (cap.textContent || "").trim(); cap.setAttribute("data-model-name", name); }
cap.textContent = (name ? name + " \u00b7 " : "") + stats.join(" \u00b7 ");
}
}
function loadThree(cb) {
if (window.THREE && window.THREE.OrbitControls) { cb(); return; }
loadScript(THREE_JS, function () {
loadScript(ORBIT_JS, function () {
if (isStep) { cb(); return; }
if (window.THREE.STLLoader) { cb(); return; }
loadScript(STL_JS, cb);
});
});
}
setProgress(null);
setStatus("Loading 3D libraries\u2026");
loadThree(function () {
var wrap = el.querySelector(".model-viewer-canvas") || el;
function canvasSize() {
return { w: wrap.clientWidth || el.clientWidth || 400, h: wrap.clientHeight || 420 };
}
var initSize = canvasSize();
var renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(initSize.w, initSize.h);
renderer.setClearColor(0x1d2021, 1);
var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(45, initSize.w / initSize.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 controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
var group = new THREE.Group();
scene.add(group);
var resizeObs = new ResizeObserver(function () {
var s = canvasSize();
if (!s.w || !s.h) return;
renderer.setSize(s.w, s.h);
camera.aspect = s.w / s.h;
camera.updateProjectionMatrix();
});
resizeObs.observe(wrap);
function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); }
animate();
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();
camera.near = d * 0.05;
camera.far = d * 10;
camera.updateProjectionMatrix();
}
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);
}
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 t0 = Date.now();
var parseTimer = setInterval(function () {
var el2 = Date.now() - t0;
setStatus("Parsing STEP\u2026 (" + Math.round(el2 / 1000) + "s)");
if (el2 > 240000 && !settled) { worker.terminate(); URL.revokeObjectURL(wurl); clearInterval(parseTimer); onError("STEP parse timed out."); }
}, 1000);
var blob = new Blob([WORKER_SRC], { type: "application/javascript" });
var wurl = URL.createObjectURL(blob);
var worker = new Worker(wurl, { type: "module" });
var settled = false;
worker.onmessage = function (e) {
if (e.data.type === "done") {
settled = true; worker.terminate(); URL.revokeObjectURL(wurl); clearInterval(parseTimer);
try {
var geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(e.data.positions, 3));
if (e.data.indices) geo.setIndex(new THREE.BufferAttribute(e.data.indices, 1));
fixWinding(geo);
var mat = new THREE.MeshStandardMaterial({ color: 0xb8bb26, metalness: 0.2, roughness: 0.6, side: THREE.DoubleSide, flatShading: false });
chunkGeometry(geo, group, mat);
var tris = (e.data.indices && e.data.indices.length) ? e.data.indices.length / 3 : e.data.positions.length / 9;
fit();
showCanvas();
showData(formatCount(tris) + " triangles");
} catch (err) {
onError("build: " + (err && err.message || err));
}
} else if (e.data.type === "error") {
settled = true; worker.terminate(); URL.revokeObjectURL(wurl); clearInterval(parseTimer);
onError(e.data.message || "Could not parse STEP file.");
}
};
worker.onerror = function (e) {
if (!settled) { settled = true; worker.terminate(); URL.revokeObjectURL(wurl); clearInterval(parseTimer); onError("STEP worker error: " + (e && e.message || "unknown")); }
};
worker.postMessage({ type: "parse", data: data, params: STEP_PARAMS, wasm: new Uint8Array(wasmBuf) }, [data.buffer, wasmBuf]);
}).catch(function () { onError("Could not download STEP parser."); });
} else {
var geo = new THREE.STLLoader().parse(data.buffer);
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."); });
});
}
var type = (document.body.className.match(/type-([\w-]+)/) || [])[1];
if (type === "file") {
var title = ((document.getElementById("title") || {}).textContent || document.title || "").trim();
var m = title.match(MODEL_RE);
if (m) {
var content = document.getElementById("content");
if (content) {
var noteId = document.body.getAttribute("data-note-id");
var el = makeViewer(title.trim());
content.innerHTML = "";
content.appendChild(el);
initViewer(el, "api/notes/" + noteId + "/download", m[0]);
}
}
}
document.querySelectorAll("#content a[href*='api/attachments/']").forEach(function (a) {
var name = (a.textContent || "").trim();
var m = name.match(MODEL_RE);
if (!m) return;
var href = a.getAttribute("href");
var el = makeViewer(name);
el.classList.add("model-viewer-inline");
a.replaceWith(el);
initViewer(el, href, m[0]);
});
var blog = window.blogData;
var byAlias = {};
if (blog && blog.articles) {
blog.articles.forEach(function (a) {
var al = String(a.url || "").split("/").pop();
if (al) byAlias[al] = a;
});
}
document.querySelectorAll("#content a[href]").forEach(function (a) {
var m = String(a.getAttribute("href") || "").match(/\.\/([^/?#]+)$/);
if (!m) return;
var art = byAlias[m[1]];
if (!art) return;
var t = String(art.title || "").trim();
var mm = t.match(MODEL_RE);
if (!mm) return;
var el = makeViewer(t);
el.classList.add("model-viewer-inline");
a.replaceWith(el);
initViewer(el, "api/notes/" + art.id + "/download", mm[0]);
});
})();
</script>