import { defineWidget, useNoteContext, useEffect, useRef } from "trilium:preact"; const THREE_JS = "api/notes/__THREE_ID__/download"; const ORBIT_JS = "api/notes/__THREE_ORBIT_ID__/download"; const STL_JS = "api/notes/__THREE_STL_ID__/download"; const OCCT_JS = "api/notes/__OCCT_JS_ID__/download"; const OCCT_WASM = "api/notes/__OCCT_WASM_ID__/download"; const WORKER_URL = "api/notes/__3D_WORKER_ID__/download"; const STEP_PARAMS = { linearDeflection: 0.0002, angularDeflection: 0.1 }; const WORKER_ABS = new URL(WORKER_URL, location.href).href; const OCCT_JS_ABS = new URL(OCCT_JS, location.href).href; const OCCT_WASM_ABS = new URL(OCCT_WASM, location.href).href; 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 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(); }); } let styleInjected = false; function injectStyle() { if (styleInjected) return; styleInjected = true; var st = document.createElement("style"); st.textContent = [ ".model-viewer { position: relative; box-sizing: border-box; width: 100%; margin: 1em 0; background: #1d2021; border: 1px solid #504945; border-radius: 8px; 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; width: 100%; height: min(70vh, 900px); min-height: 420px; }", ".model-viewer-status { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; text-align: center; padding: 48px; box-sizing: border-box; color: #a89984; font-size: 0.9em; }", ".model-viewer-status.error { color: #fb4934; }", ".model-viewer-progress { position: absolute; left: 0; right: 0; bottom: 40px; height: 6px; width: min(60%, 480px); margin: 0 auto; 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; left: 10px; font-size: 0.75em; color: #a89984; z-index: 2; pointer-events: none; }", ".model-viewer-caption { font-size: 0.85em; font-style: italic; color: #a89984; text-align: center; padding: 4px 12px; background: #1d2021; border-top: 1px solid #504945; }", ".model-viewer canvas { display: block; width: 100%; height: 100%; }" ].join("\n"); document.head.appendChild(st); } const cleaners = new Map(); 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); var cap = document.createElement("div"); cap.className = "model-viewer-caption"; cap.textContent = name; box.appendChild(cap); return box; } function initViewer(el, url, ext) { var isStep = /\.(step|stp)$/i.test(ext); var fileSize = null; var raf = 0; var renderer = null; var resizeObs = 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(); 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); 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() { raf = 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(); } 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 () { setStatus("Parsing STEP\u2026 (" + Math.round((Date.now() - t0) / 1000) + "s)"); }, 1000); var worker = new Worker(WORKER_ABS); 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(); clearInterval(parseTimer); 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(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(); showCanvas(); showData(formatCount(tris) + " triangles"); } else if (e.data.type === "error") { settled = true; worker.terminate(); clearInterval(parseTimer); onError(e.data.message || "Could not parse STEP file."); } }; worker.onerror = function (e) { if (!settled) { settled = true; worker.terminate(); clearInterval(parseTimer); 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.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."); }); }); return function cleanup() { cancelAnimationFrame(raf); try { if (resizeObs) resizeObs.disconnect(); } catch (e) {} try { if (renderer) renderer.dispose(); } catch (e) {} }; } function ModelViewer({ note }) { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; injectStyle(); if (cleaners.has(el)) { cleaners.get(el)(); cleaners.delete(el); } el.innerHTML = ""; const m = (note.title || "").match(/\.(stl|step|stp)$/i); if (!m) return; const box = makeViewer(note.title.trim()); el.appendChild(box); cleaners.set(el, initViewer(box, "api/notes/" + note.noteId + "/download", m[0])); }, [note.noteId]); return
; } export default defineWidget({ parent: "note-detail-pane", position: 10, render: () => { const { note } = useNoteContext(); const title = note ? note.title : null; if (!note || note.type !== "file" || !title || !/\.(stl|step|stp)$/i.test(title)) return null; return ; } });