3D viewer: switch STEP engine to occt-wasm (OCCT V8, module worker); fix mobile rendering (Uint16 chunking, index wrapping, tight near/far); state-coloured box + debug line; app widget parity; licenses/docs

This commit is contained in:
trilium-share-gruvbox
2026-08-28 20:02:11 +02:00
parent 41ec295954
commit 18f8afaf69
10 changed files with 7157 additions and 129 deletions
+15 -6
View File
@@ -95,16 +95,25 @@ set the other labels, and set `publish=true` **last**.
- **3D models** (`scripts/3d_model_viewer.js`): renders `.step`/`.stp`/`.stl` as file notes - **3D models** (`scripts/3d_model_viewer.js`): renders `.step`/`.stp`/`.stl` as file notes
(title ends in the extension), inline attachment links, or links to published model file (title ends in the extension), inline attachment links, or links to published model file
notes from text notes (resolved via `window.blogData`). The viewer is **full-bleed** notes from text notes (resolved via `window.blogData`). The viewer is **full-bleed**
(`~100vw`, `height: min(70vh, 900px)`, ResizeObserver), with a loading progress bar and a (`~100vw`, `height: min(70vh, 900px)`, ResizeObserver), with a loading progress bar, a
caption that always shows `name · size · triangle count`. STEP is parsed by occt-import-js caption that shows `name · size · triangle count`, and a **debug line** under the box that
(OpenCascade WASM) in a **Web Worker** (non-blocking) with logs the step (`loading`/`parsing`/`done`/`error`) for on-device diagnosis. STEP is parsed
`linearDeflection: 0.0002, angularDeflection: 0.1`. Libraries hosted as local notes via by **occt-wasm** (OpenCASCADE **V8**, MIT/Apache-2.0) in a **module worker**
(`new Worker(url, {type:"module"})`, non-blocking) with
`linearDeflection: 0.4, angularDeflection: 0.2`. Libraries hosted as local notes via
`__THREE_ID__`/`__THREE_ORBIT_ID__`/`__THREE_STL_ID__`/`__OCCT_JS_ID__`/`__OCCT_WASM_ID__`. `__THREE_ID__`/`__THREE_ORBIT_ID__`/`__THREE_STL_ID__`/`__OCCT_JS_ID__`/`__OCCT_WASM_ID__`.
User guide: `docs/embed-3d-model.md`. User guide: `docs/embed-3d-model.md`.
- **Mobile gotchas (both were real bugs)**: (1) mesh >65535 vertices must be split into
chunks with `Uint16` indices (`chunkGeometry`) or mobile GPUs without
`OES_element_index_uint` render nothing; (2) `geometry.setIndex(rawTypedArray)` in
three r128 leaves `geometry.index` WITHOUT `.count` → always wrap:
`setIndex(new THREE.BufferAttribute(index, 1))`; (3) tighten camera `near`/`far` from the
model size after `fit()` (`near=d·0.05, far=d·10`) for 16-bit depth buffers.
- **3D viewer in the Trilium app** (independent of the blog): `app/3d-model-viewer-app.jsx` - **3D viewer in the Trilium app** (independent of the blog): `app/3d-model-viewer-app.jsx`
(widget, `#widget=3dModelViewer`, `parent: note-detail-pane`) + `app/3d-worker.js` (STEP (widget, `#widget=3dModelViewer`, `parent: note-detail-pane`) + `app/3d-worker.js` (STEP
worker, loaded from a code note instead of a Blob so the app CSP allows it). The widget module worker that imports the occt-wasm bundle from a code note, so it works under the
renders the viewer for file notes whose title ends in `.step`/`.stp`/`.stl`. app's CSP). The widget renders the viewer for file notes whose title ends in
`.step`/`.stp`/`.stl`.
- Images in the share are served full-size only (no resize endpoint) → keep the - Images in the share are served full-size only (no resize endpoint) → keep the
`_thumb` child-note convention for small thumbnails. `_thumb` child-note convention for small thumbnails.
- Built-in share footer navigation and book children list are hidden - Built-in share footer navigation and book children list are hidden
+7 -7
View File
@@ -46,12 +46,12 @@ no external dependencies (except `highlight.js`, vendored in `lib/`).
- **Widget rendering** for Mermaid, Excalidraw (canvas) and MindElixir (mind map) notes, with a - **Widget rendering** for Mermaid, Excalidraw (canvas) and MindElixir (mind map) notes, with a
collapsible, highlighted "Source" block and copy button. collapsible, highlighted "Source" block and copy button.
- **3D models**: `.step` / `.stp` / `.stl` files (as file notes, as inline attachments, or linked - **3D models**: `.step` / `.stp` / `.stl` files (as file notes, as inline attachments, or linked
from text notes) render in an interactive three.js viewer (STEP via occt-import-js, OpenCascade from text notes) render in an interactive three.js viewer (STEP via **occt-wasm**, OpenCASCADE
WASM, parsed in a **Web Worker** so the page stays responsive). The viewer is **full-bleed** V8, parsed in a non-blocking **module worker**). The viewer is **full-bleed** (~`100vw`) with a
(~`100vw`) with a viewport-based height, drag to rotate / scroll to zoom, rounded 8px box, a viewport-based height, drag to rotate / scroll to zoom, rounded 8px box, a loading progress bar,
loading progress bar and a caption that always shows `name · size · triangle count`. a caption showing `name · size · triangle count`, and a small debug line. Large meshes are
STEP tessellation is dense (≈0.11 M+ triangles for assemblies). See split into `Uint16` chunks so mobile GPUs render them too (≈480k triangles for the demo
[docs/embed-3d-model.md](docs/embed-3d-model.md). battery module). See [docs/embed-3d-model.md](docs/embed-3d-model.md).
- **3D viewer inside Trilium (app widget)**: the same viewer runs in the Trilium app itself - **3D viewer inside Trilium (app widget)**: the same viewer runs in the Trilium app itself
(independent of the blog) via a `note-detail-pane` widget see `app/`. (independent of the blog) via a `note-detail-pane` widget see `app/`.
- Code highlighting (highlight.js + line numbers) and copy buttons on all code boxes. - Code highlighting (highlight.js + line numbers) and copy buttons on all code boxes.
@@ -69,7 +69,7 @@ no external dependencies (except `highlight.js`, vendored in `lib/`).
| `theme/favicon.svg` | default site favicon | code (SVG) | | `theme/favicon.svg` | default site favicon | code (SVG) |
| `theme/cookie.woff2` | "Cookie" font for the Buy me a coffee button | file, `font/woff2` | | `theme/cookie.woff2` | "Cookie" font for the Buy me a coffee button | file, `font/woff2` |
| `lib/highlight.js` | vendored highlight.js | code, `application/javascript` | | `lib/highlight.js` | vendored highlight.js | code, `application/javascript` |
| `lib/3d/*` | three.js + OrbitControls + STLLoader + occt-import-js (WASM) for the 3D viewer | code / file | | `lib/3d/*` | three.js + OrbitControls + STLLoader + occt-wasm (OCCT V8 bundle + WASM) for the 3D viewer | code / file |
| `lib/hljs_line_numbers.js` | highlight.js line-numbers plugin | code | | `lib/hljs_line_numbers.js` | highlight.js line-numbers plugin | code |
| `scripts/*.js` | frontend share snippets (content is pasted **verbatim** into a code note, including the `<script>`/`<style>` wrapper) | code | | `scripts/*.js` | frontend share snippets (content is pasted **verbatim** into a code note, including the `<script>`/`<style>` wrapper) | code |
| `app/3d-model-viewer-app.jsx` | JSX widget that renders the 3D viewer for model file notes inside the Trilium app (label `#widget=3dModelViewer`) | code, `text/jsx` | | `app/3d-model-viewer-app.jsx` | JSX widget that renders the 3D viewer for model file notes inside the Trilium app (label `#widget=3dModelViewer`) | code, `text/jsx` |
+5
View File
@@ -11,6 +11,7 @@ The following files in this repository are **not** covered by the GPL-3.0 and ke
| `assets/palette.png` | Gruvbox palette image from [morhetz/gruvbox](https://github.com/morhetz/gruvbox) (repo: MIT) | [MIT](#mit-license) | | `assets/palette.png` | Gruvbox palette image from [morhetz/gruvbox](https://github.com/morhetz/gruvbox) (repo: MIT) | [MIT](#mit-license) |
| `lib/3d/three.min.js`, `lib/3d/OrbitControls.js`, `lib/3d/STLLoader.js` | [three.js](https://github.com/mrdoob/three.js) r128 | [MIT](#mit-license) | | `lib/3d/three.min.js`, `lib/3d/OrbitControls.js`, `lib/3d/STLLoader.js` | [three.js](https://github.com/mrdoob/three.js) r128 | [MIT](#mit-license) |
| `lib/3d/occt-import-js.js`, `lib/3d/occt-import-js.wasm` | [occt-import-js](https://github.com/kovacsv/occt-import-js) (OpenCASCADE, WASM) | [LGPL-2.1](#lgpl-21) | | `lib/3d/occt-import-js.js`, `lib/3d/occt-import-js.wasm` | [occt-import-js](https://github.com/kovacsv/occt-import-js) (OpenCASCADE, WASM) | [LGPL-2.1](#lgpl-21) |
| `lib/3d/occt-wasm-bundle.js`, `lib/3d/occt-wasm.wasm` | [occt-wasm](https://github.com/andymai/occt-wasm) (OpenCASCADE **OCCT V8**, WebAssembly, esbuild-bundled `OcctKernel`) | [MIT OR Apache-2.0](#mit-license) |
| `scripts/bmc_button.js` | embeds the Trilium logo (SVG) from [Trilium/TriliumNext](https://github.com/TriliumNext/Trilium) | [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) | | `scripts/bmc_button.js` | embeds the Trilium logo (SVG) from [Trilium/TriliumNext](https://github.com/TriliumNext/Trilium) | [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) |
## BSD-3-Clause ## BSD-3-Clause
@@ -84,6 +85,10 @@ Copyright (c) 2011 by Ania Kruk, with Reserved Font Name "Cookie".
(<https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html>). OpenCASCADE is LGPL-2.1 as well; (<https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html>). OpenCASCADE is LGPL-2.1 as well;
the WASM blob is loaded at runtime by `scripts/3d_model_viewer.js` and is not statically linked. the WASM blob is loaded at runtime by `scripts/3d_model_viewer.js` and is not statically linked.
> Note: the 3D viewer now uses **`occt-wasm`** (OCCT V8, MIT/Apache-2.0) instead of
> `occt-import-js` as its STEP engine. The `occt-import-js` files are kept for reference /
> fallback only.
## AGPL-3.0 ## AGPL-3.0
The Trilium logo embedded in `scripts/bmc_button.js` belongs to the Trilium / The Trilium logo embedded in `scripts/bmc_button.js` belongs to the Trilium /
+79 -19
View File
@@ -7,7 +7,7 @@ const OCCT_JS = "api/notes/__OCCT_JS_ID__/download";
const OCCT_WASM = "api/notes/__OCCT_WASM_ID__/download"; const OCCT_WASM = "api/notes/__OCCT_WASM_ID__/download";
const WORKER_URL = "api/notes/__3D_WORKER_ID__/download"; const WORKER_URL = "api/notes/__3D_WORKER_ID__/download";
const STEP_PARAMS = { linearDeflection: 0.0002, angularDeflection: 0.1 }; const STEP_PARAMS = { linearDeflection: 0.4, angularDeflection: 0.2 };
const WORKER_ABS = new URL(WORKER_URL, location.href).href; const WORKER_ABS = new URL(WORKER_URL, location.href).href;
const OCCT_JS_ABS = new URL(OCCT_JS, location.href).href; const OCCT_JS_ABS = new URL(OCCT_JS, location.href).href;
@@ -24,6 +24,66 @@ function formatCount(n) {
if (n >= 1000) return (n / 1000).toFixed(1) + "k"; if (n >= 1000) return (n / 1000).toFixed(1) + "k";
return String(n); 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 loadScript(src, cb) { function loadScript(src, cb) {
var s = document.createElement("script"); var s = document.createElement("script");
s.src = src; s.src = src;
@@ -203,6 +263,9 @@ function initViewer(el, url, ext) {
camera.position.set(center.x + d * 0.9, center.y + d * 0.6, center.z + d); camera.position.set(center.x + d * 0.9, center.y + d * 0.6, center.z + d);
controls.target.copy(center); controls.target.copy(center);
controls.update(); controls.update();
camera.near = d * 0.05;
camera.far = d * 10;
camera.updateProjectionMatrix();
} }
function showCanvas() { function showCanvas() {
wrap.innerHTML = ""; wrap.innerHTML = "";
@@ -225,26 +288,23 @@ function initViewer(el, url, ext) {
setStatus("Parsing STEP\u2026"); setStatus("Parsing STEP\u2026");
setProgress(null); setProgress(null);
var t0 = Date.now(); var t0 = Date.now();
var parseTimer = setInterval(function () { setStatus("Parsing STEP\u2026 (" + Math.round((Date.now() - t0) / 1000) + "s)"); }, 1000); var parseTimer = setInterval(function () {
var worker = new Worker(WORKER_ABS); var el = Date.now() - t0;
setStatus("Parsing STEP\u2026 (" + Math.round(el / 1000) + "s)");
if (el > 240000 && !settled) { worker.terminate(); clearInterval(parseTimer); onError("STEP parse timed out."); }
}, 1000);
var worker = new Worker(WORKER_ABS, { type: "module" });
var settled = false; var settled = false;
worker.onmessage = function (e) { worker.onmessage = function (e) {
if (e.data.type === "ready") { if (e.data.type === "done") {
worker.postMessage({ type: "parse", data: data, params: STEP_PARAMS }, [data.buffer]);
} else if (e.data.type === "done") {
settled = true; worker.terminate(); clearInterval(parseTimer); settled = true; worker.terminate(); clearInterval(parseTimer);
if (!e.data.success) { onError("Could not parse STEP file."); return; } var geo = new THREE.BufferGeometry();
var tris = 0; geo.setAttribute("position", new THREE.Float32BufferAttribute(e.data.positions, 3));
(e.data.meshes || []).forEach(function (m) { if (e.data.indices) geo.setIndex(new THREE.BufferAttribute(e.data.indices, 1));
var geo = new THREE.BufferGeometry(); fixWinding(geo);
geo.setAttribute("position", new THREE.Float32BufferAttribute(m.position, 3)); var mat = new THREE.MeshStandardMaterial({ color: 0xb8bb26, metalness: 0.2, roughness: 0.6, side: THREE.DoubleSide, flatShading: true });
if (m.index) geo.setIndex(m.index); chunkGeometry(geo, group, mat);
geo.computeVertexNormals(); var tris = (e.data.indices && e.data.indices.length) ? e.data.indices.length / 3 : e.data.positions.length / 9;
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(); fit();
showCanvas(); showCanvas();
showData(formatCount(tris) + " triangles"); showData(formatCount(tris) + " triangles");
@@ -256,7 +316,7 @@ function initViewer(el, url, ext) {
worker.onerror = function (e) { worker.onerror = function (e) {
if (!settled) { settled = true; worker.terminate(); clearInterval(parseTimer); onError("STEP worker error: " + (e && e.message || "unknown")); } 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]); worker.postMessage({ type: "parse", data: data, params: STEP_PARAMS, wasm: new Uint8Array(wasmBuf) }, [data.buffer, wasmBuf]);
}).catch(function () { onError("Could not download STEP parser."); }); }).catch(function () { onError("Could not download STEP parser."); });
} else { } else {
var geo = new THREE.STLLoader().parse(data.buffer); var geo = new THREE.STLLoader().parse(data.buffer);
+14 -35
View File
@@ -1,38 +1,17 @@
self.onmessage = function (e) { import { OcctKernel } from "https://YOUR-INSTANCE.example.com/api/notes/__OCCT_JS_ID__/download";
let kernel = null;
let initP = null;
self.onmessage = async function (e) {
var d = e.data; var d = e.data;
if (d.type === 'loadLib') { if (d.type !== "parse") return;
try { importScripts(d.libUrl); } catch (err) { try {
self.postMessage({ type: 'error', message: 'importScripts: ' + (err && err.message || err) }); return; if (!initP) initP = OcctKernel.init({ wasm: d.wasm });
} kernel = await initP;
if (typeof self.occtimportjs !== 'function') { var shape = kernel.importStep(d.data);
self.postMessage({ type: 'error', message: 'STEP parser not found.' }); return; var mesh = kernel.tessellate(shape, d.params);
} kernel.release(shape);
self.occtimportjs({ locateFile: function () { return 'occt.wasm'; }, wasmBinary: d.wasm }).then(function (occt) { self.postMessage({ type: "done", positions: mesh.positions, normals: mesh.normals, indices: mesh.indices }, [mesh.positions.buffer, mesh.normals.buffer, mesh.indices.buffer]);
self.occt = occt; } catch (err) {
self.postMessage({ type: 'ready' }); self.postMessage({ type: "error", message: "parse: " + (err && err.message || err) });
}).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) });
}
} }
}; };
+14 -6
View File
@@ -40,10 +40,11 @@ exists as its own (published) file note — the model stays in one place.
You can embed several models per post this way. You can embed several models per post this way.
> Tip: STL (plain triangle meshes) is rendered with three.js only; STEP (CAD B-rep) is > Tip: STL (plain triangle meshes) is rendered with three.js only; STEP (CAD B-rep) is
> parsed by occt-import-js (OpenCASCADE WASM, LGPL-2.1) in a **Web Worker** (non-blocking). > parsed by **occt-wasm** (OpenCASCADE **V8**, WASM, MIT/Apache-2.0) in a **Web Worker**
> The first STEP view downloads the ~7.6 MB WASM once and then caches it in the browser. > (module worker, non-blocking). The first STEP view downloads the ~22 MB WASM once and
> STEP tessellation is set to `linearDeflection: 0.0002, angularDeflection: 0.1` > then caches it in the browser.
> (dense meshes, typically a few hundred k to >1 M triangles for assemblies). > STEP tessellation is set to `linearDeflection: 0.4, angularDeflection: 0.2`
> (≈480k triangles for the ignis battery module; tuned for mobile).
## Requirements ## Requirements
@@ -51,8 +52,15 @@ exists as its own (published) file note — the model stays in one place.
(inheritable) on the blog root, `shareHtmlLocation=body:end`. (inheritable) on the blog root, `shareHtmlLocation=body:end`.
- The five libraries must exist as notes and be referenced by the script via the - The five libraries must exist as notes and be referenced by the script via the
placeholder tokens in `scripts/3d_model_viewer.js`: placeholder tokens in `scripts/3d_model_viewer.js`:
`__THREE_ID__`, `__THREE_ORBIT_ID__`, `__THREE_STL_ID__`, `__OCCT_JS_ID__`, `__THREE_ID__`, `__THREE_ORBIT_ID__`, `__THREE_STL_ID__`, `__OCCT_JS_ID__`
`__OCCT_WASM_ID__` (see the README installation for the note titles). (the occt-wasm **bundle**), `__OCCT_WASM_ID__` (occt-wasm.wasm) see the README
installation for the note titles. The STEP worker is a **module worker** (`type: "module"`),
so the browser must support ES modules in workers (all modern browsers).
- **Mobile compatibility**: the mesh is split into chunks of ≤65000 vertices
(`chunkGeometry`, `Uint16` indices) so it renders on GPUs without
`OES_element_index_uint`; the camera near/far is tightened after `fit()` for 16-bit
depth buffers. The caption always shows a small **debug line** with the current step
(`loading` / `parsing` / `done` / `error`) useful while diagnosing.
- For **inline embedding from a file note (Option C)** the `blog_data` note must be - For **inline embedding from a file note (Option C)** the `blog_data` note must be
present and the target model must be a published article (the viewer resolves present and the target model must be a published article (the viewer resolves
`./<alias>` links against `window.blogData`). `./<alias>` links against `window.blogData`).
File diff suppressed because it is too large Load Diff
Binary file not shown.
+116 -56
View File
@@ -11,45 +11,23 @@
var MODEL_RE = /\.(stl|step|stp)$/i; var MODEL_RE = /\.(stl|step|stp)$/i;
var STEP_PARAMS = { linearDeflection: 0.0002, angularDeflection: 0.1 }; var STEP_PARAMS = { linearDeflection: 0.4, angularDeflection: 0.2 };
var WORKER_SRC = [ var WORKER_SRC = [
"self.onmessage = function (e) {", 'import { OcctKernel } from "' + OCCT_JS_ABS + '";',
"let kernel = null; let initP = null;",
"self.onmessage = async function (e) {",
" var d = e.data;", " var d = e.data;",
" if (d.type === 'loadLib') {", " if (d.type !== 'parse') return;",
" try { importScripts(d.libUrl); } catch (err) {", " try {",
" self.postMessage({ type: 'error', message: 'importScripts: ' + (err && err.message || err) }); return;", " if (!initP) initP = OcctKernel.init({ wasm: d.wasm });",
" }", " kernel = await initP;",
" if (typeof self.occtimportjs !== 'function') {", " var shape = kernel.importStep(d.data);",
" self.postMessage({ type: 'error', message: 'STEP parser not found.' }); return;", " var mesh = kernel.tessellate(shape, d.params);",
" }", " kernel.release(shape);",
" self.occtimportjs({ locateFile: function () { return 'occt.wasm'; }, wasmBinary: d.wasm }).then(function (occt) {", " self.postMessage({ type: 'done', positions: mesh.positions, normals: mesh.normals, indices: mesh.indices }, [mesh.positions.buffer, mesh.normals.buffer, mesh.indices.buffer]);",
" self.occt = occt;", " } catch (err) {",
" self.postMessage({ type: 'ready' });", " self.postMessage({ type: 'error', message: 'parse: ' + (err && err.message || err) });",
" }).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"); ].join("\n");
@@ -67,6 +45,68 @@
return String(n); 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) { function makeViewer(name) {
var box = document.createElement("div"); var box = document.createElement("div");
box.className = "model-viewer"; box.className = "model-viewer";
@@ -92,6 +132,9 @@
cap.textContent = name; cap.textContent = name;
box.appendChild(cap); box.appendChild(cap);
} }
var dbg = document.createElement("div");
dbg.className = "model-viewer-debug";
box.appendChild(dbg);
return box; return box;
} }
@@ -135,6 +178,11 @@
var s = el.querySelector(".model-viewer-status"); var s = el.querySelector(".model-viewer-status");
if (s) s.textContent = text; if (s) s.textContent = text;
} }
function setStep(state, msg) {
el.className = "model-viewer model-" + state;
var dbg = el.querySelector(".model-viewer-debug");
if (dbg) dbg.textContent = new Date().toLocaleTimeString() + " [" + state + "] " + msg;
}
function setProgress(percent) { function setProgress(percent) {
var bar = el.querySelector(".model-viewer-progress-fill"); var bar = el.querySelector(".model-viewer-progress-fill");
if (!bar) return; if (!bar) return;
@@ -150,6 +198,7 @@
var s = el.querySelector(".model-viewer-status"); var s = el.querySelector(".model-viewer-status");
if (s) { s.textContent = msg || "Could not load 3D model."; s.classList.add("error"); } if (s) { s.textContent = msg || "Could not load 3D model."; s.classList.add("error"); }
setProgress(0); setProgress(0);
setStep("error", msg || "Could not load 3D model.");
} }
function showData(extra) { function showData(extra) {
var d = el.querySelector(".model-viewer-data"); var d = el.querySelector(".model-viewer-data");
@@ -183,6 +232,7 @@
setProgress(null); setProgress(null);
setStatus("Loading 3D libraries\u2026"); setStatus("Loading 3D libraries\u2026");
setStep("loading", "Loading 3D libraries");
loadThree(function () { loadThree(function () {
var wrap = el.querySelector(".model-viewer-canvas") || el; var wrap = el.querySelector(".model-viewer-canvas") || el;
@@ -226,6 +276,9 @@
camera.position.set(center.x + d * 0.9, center.y + d * 0.6, center.z + d); camera.position.set(center.x + d * 0.9, center.y + d * 0.6, center.z + d);
controls.target.copy(center); controls.target.copy(center);
controls.update(); controls.update();
camera.near = d * 0.05;
camera.far = d * 10;
camera.updateProjectionMatrix();
} }
function showCanvas() { function showCanvas() {
wrap.innerHTML = ""; wrap.innerHTML = "";
@@ -240,6 +293,7 @@
fetchProgress(url, function (p) { setProgress(p); }).then(function (buf) { fetchProgress(url, function (p) { setProgress(p); }).then(function (buf) {
fileSize = buf.byteLength; fileSize = buf.byteLength;
showData(); showData();
setStep("loading", "Downloaded model " + formatBytes(fileSize));
var data = new Uint8Array(buf); var data = new Uint8Array(buf);
if (isStep) { if (isStep) {
setStatus("Downloading STEP parser\u2026"); setStatus("Downloading STEP parser\u2026");
@@ -247,32 +301,38 @@
fetchProgress(OCCT_WASM_ABS, function () {}).then(function (wasmBuf) { fetchProgress(OCCT_WASM_ABS, function () {}).then(function (wasmBuf) {
setStatus("Parsing STEP\u2026"); setStatus("Parsing STEP\u2026");
setProgress(null); setProgress(null);
setStep("parsing", "STEP parser ready, parsing");
var t0 = Date.now(); var t0 = Date.now();
var parseTimer = setInterval(function () { setStatus("Parsing STEP\u2026 (" + Math.round((Date.now() - t0) / 1000) + "s)"); }, 1000); var parseTimer = setInterval(function () {
var el2 = Date.now() - t0;
setStatus("Parsing STEP\u2026 (" + Math.round(el2 / 1000) + "s)");
setStep("parsing", "Parsing STEP " + 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 blob = new Blob([WORKER_SRC], { type: "application/javascript" });
var wurl = URL.createObjectURL(blob); var wurl = URL.createObjectURL(blob);
var worker = new Worker(wurl); var worker = new Worker(wurl, { type: "module" });
var settled = false; var settled = false;
worker.onmessage = function (e) { worker.onmessage = function (e) {
if (e.data.type === "ready") { if (e.data.type === "done") {
worker.postMessage({ type: "parse", data: data, params: STEP_PARAMS }, [data.buffer]);
} else if (e.data.type === "done") {
settled = true; worker.terminate(); URL.revokeObjectURL(wurl); clearInterval(parseTimer); settled = true; worker.terminate(); URL.revokeObjectURL(wurl); clearInterval(parseTimer);
if (!e.data.success) { onError("Could not parse STEP file."); return; } try {
var tris = 0;
(e.data.meshes || []).forEach(function (m) {
var geo = new THREE.BufferGeometry(); var geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(m.position, 3)); geo.setAttribute("position", new THREE.Float32BufferAttribute(e.data.positions, 3));
if (m.index) geo.setIndex(m.index); if (e.data.indices) geo.setIndex(new THREE.BufferAttribute(e.data.indices, 1));
geo.computeVertexNormals(); var geoStats = "posV=" + geo.attributes.position.count + " idx=" + (geo.index ? geo.index.count : 0);
var color = m.color && m.color.length === 3 ? new THREE.Color(m.color[0], m.color[1], m.color[2]) : null; fixWinding(geo);
var mat = new THREE.MeshStandardMaterial({ color: color || 0xb8bb26, metalness: 0.2, roughness: 0.6, side: THREE.DoubleSide, flatShading: true }); var mat = new THREE.MeshStandardMaterial({ color: 0xb8bb26, metalness: 0.2, roughness: 0.6, side: THREE.DoubleSide, flatShading: false });
group.add(new THREE.Mesh(geo, mat)); chunkGeometry(geo, group, mat);
tris += geo.index ? geo.index.count / 3 : geo.attributes.position.count / 3; var tris = (e.data.indices && e.data.indices.length) ? e.data.indices.length / 3 : e.data.positions.length / 9;
}); fit();
fit(); showCanvas();
showCanvas(); showData(formatCount(tris) + " triangles");
showData(formatCount(tris) + " triangles"); var capGL = renderer.capabilities ? (renderer.capabilities.isWebGL2 ? "WebGL2" : "WebGL1") : "?";
setStep("done", "Rendered " + formatCount(tris) + " tris; meshes=" + group.children.length + " gl=" + capGL + " " + geoStats);
} catch (err) {
onError("build: " + (err && err.message || err));
}
} else if (e.data.type === "error") { } else if (e.data.type === "error") {
settled = true; worker.terminate(); URL.revokeObjectURL(wurl); clearInterval(parseTimer); settled = true; worker.terminate(); URL.revokeObjectURL(wurl); clearInterval(parseTimer);
onError(e.data.message || "Could not parse STEP file."); onError(e.data.message || "Could not parse STEP file.");
@@ -281,7 +341,7 @@
worker.onerror = function (e) { worker.onerror = function (e) {
if (!settled) { settled = true; worker.terminate(); URL.revokeObjectURL(wurl); clearInterval(parseTimer); onError("STEP worker error: " + (e && e.message || "unknown")); } if (!settled) { settled = true; worker.terminate(); URL.revokeObjectURL(wurl); clearInterval(parseTimer); onError("STEP worker error: " + (e && e.message || "unknown")); }
}; };
worker.postMessage({ type: "loadLib", libUrl: OCCT_JS_ABS, wasm: new Uint8Array(wasmBuf) }, [wasmBuf]); worker.postMessage({ type: "parse", data: data, params: STEP_PARAMS, wasm: new Uint8Array(wasmBuf) }, [data.buffer, wasmBuf]);
}).catch(function () { onError("Could not download STEP parser."); }); }).catch(function () { onError("Could not download STEP parser."); });
} else { } else {
var geo = new THREE.STLLoader().parse(data.buffer); var geo = new THREE.STLLoader().parse(data.buffer);
+15
View File
@@ -918,6 +918,21 @@ html.theme-light #content code {
height: 100%; height: 100%;
} }
.model-viewer.model-loading { background: #282828; }
.model-viewer.model-parsing { background: #504945; }
.model-viewer.model-done { background: #1d2021; }
.model-viewer.model-error { background: #3c3836; }
.model-viewer-debug {
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.72em;
color: #928374;
text-align: center;
padding: 4px 12px;
background: #1d2021;
border-top: 1px solid var(--background-highlight);
}
/* ---------- Bildunterschriften (Gruvbox) ---------- */ /* ---------- Bildunterschriften (Gruvbox) ---------- */
#content figcaption { #content figcaption {
font-size: 0.85em; font-size: 0.85em;