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:
@@ -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
|
||||||
|
|||||||
@@ -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.1–1 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` |
|
||||||
|
|||||||
@@ -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 /
|
||||||
|
|||||||
+78
-18
@@ -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 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();
|
fixWinding(geo);
|
||||||
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: 0xb8bb26, metalness: 0.2, roughness: 0.6, side: THREE.DoubleSide, flatShading: true });
|
||||||
var mat = new THREE.MeshStandardMaterial({ color: color || 0xb8bb26, metalness: 0.2, roughness: 0.6, side: THREE.DoubleSide, flatShading: true });
|
chunkGeometry(geo, group, mat);
|
||||||
group.add(new THREE.Mesh(geo, mat));
|
var tris = (e.data.indices && e.data.indices.length) ? e.data.indices.length / 3 : e.data.positions.length / 9;
|
||||||
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);
|
||||||
|
|||||||
+12
-33
@@ -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) {
|
|
||||||
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 {
|
try {
|
||||||
var result = self.occt.ReadStepFile(d.data, d.params);
|
if (!initP) initP = OcctKernel.init({ wasm: d.wasm });
|
||||||
var meshes = [];
|
kernel = await initP;
|
||||||
(result.meshes || []).forEach(function (m) {
|
var shape = kernel.importStep(d.data);
|
||||||
if (!m.attributes || !m.attributes.position) return;
|
var mesh = kernel.tessellate(shape, d.params);
|
||||||
var out = { position: m.attributes.position.array, color: m.color };
|
kernel.release(shape);
|
||||||
if (m.attributes.index && m.attributes.index.array) out.index = m.attributes.index.array;
|
self.postMessage({ type: "done", positions: mesh.positions, normals: mesh.normals, indices: mesh.indices }, [mesh.positions.buffer, mesh.normals.buffer, mesh.indices.buffer]);
|
||||||
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) {
|
} catch (err) {
|
||||||
self.postMessage({ type: 'error', message: 'parse: ' + (err && err.message || err) });
|
self.postMessage({ type: "error", message: "parse: " + (err && err.message || err) });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
+14
-6
@@ -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.
+110
-50
@@ -11,46 +11,24 @@
|
|||||||
|
|
||||||
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) {",
|
|
||||||
" 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 {",
|
" try {",
|
||||||
" var result = self.occt.ReadStepFile(d.data, d.params);",
|
" if (!initP) initP = OcctKernel.init({ wasm: d.wasm });",
|
||||||
" var meshes = [];",
|
" kernel = await initP;",
|
||||||
" (result.meshes || []).forEach(function (m) {",
|
" var shape = kernel.importStep(d.data);",
|
||||||
" if (!m.attributes || !m.attributes.position) return;",
|
" var mesh = kernel.tessellate(shape, d.params);",
|
||||||
" var out = { position: m.attributes.position.array, color: m.color };",
|
" kernel.release(shape);",
|
||||||
" if (m.attributes.index && m.attributes.index.array) out.index = m.attributes.index.array;",
|
" self.postMessage({ type: 'done', positions: mesh.positions, normals: mesh.normals, indices: mesh.indices }, [mesh.positions.buffer, mesh.normals.buffer, mesh.indices.buffer]);",
|
||||||
" 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) {",
|
" } catch (err) {",
|
||||||
" self.postMessage({ type: 'error', message: 'parse: ' + (err && err.message || 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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user