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>