3D viewer overhaul: full-bleed + responsive, Web Worker STEP parse, progress bar, caption stats, inline models in text notes, STL fix, app widget; weekday dates (dateText); fix shields badge escaping; docs + licenses

This commit is contained in:
trilium-share-gruvbox
2026-08-28 18:15:50 +02:00
parent 3968011e3c
commit 41ec295954
9 changed files with 544 additions and 56 deletions
+51 -16
View File
@@ -44,10 +44,13 @@ CSS file (Gruvbox, light + dark, theme toggle without reload).
- **Article prev/next** (`scripts/prev_next.js`) is scoped to the article's category - **Article prev/next** (`scripts/prev_next.js`) is scoped to the article's category
(`blog_data.articles[].category`); falls back to the global list when uncategorized. (`blog_data.articles[].category`); falls back to the global list when uncategorized.
- **Menu sorting** (`scripts/menu_date_sort.js`, `shareHtmlLocation=body:end`): reads - **Menu sorting** (`scripts/menu_date_sort.js`, `shareHtmlLocation=body:end`): reads
`blog_data.articles[].date` (generator exposes the `date` label, fallback `dateCreated`, `blog_data.articles[].date` (ISO, used for sorting via `data-pubdate`) and displays
`YYYY-MM-DD`), re-sorts each category's menu entries by date descending and prefixes the `dateText` (`Fri 2026-08-28`), re-sorts each category's menu entries by date descending
label with `YYYY-MM-DD <shortened title>` (icons preserved). Non-article entries are left and prefixes the label with `Fri 2026-08-28 <shortened title>` (icons preserved).
in place. Non-article entries are left in place.
- **Dates**: all visible dates (post cards, garden table) are rendered by the generator's
`dateText()` helper as `Fri 2026-08-28` (English weekday + ISO date); `blog_data.date`
stays ISO for sorting, `blog_data.dateText` is added for display. JSON-LD keeps ISO dates.
- **Thumbnails** (`thumbSrc()`): prefer a child note of the article titled `*_thumb.*` - **Thumbnails** (`thumbSrc()`): prefer a child note of the article titled `*_thumb.*`
(e.g. `ngc281_thumb.jpg`) → `api/notes/<id>/download`; otherwise use the first `<img>` (e.g. `ngc281_thumb.jpg`) → `api/notes/<id>/download`; otherwise use the first `<img>`
from the note content. Layout: image left, summary right (`.blog-post-inner`), from the note content. Layout: image left, summary right (`.blog-post-inner`),
@@ -55,6 +58,14 @@ CSS file (Gruvbox, light + dark, theme toggle without reload).
- Regenerates `feed.xml`, `sitemap.xml`, `state_map` (garden states) and `blog_data` - Regenerates `feed.xml`, `sitemap.xml`, `state_map` (garden states) and `blog_data`
(search index: id/title/url/summary/tags/category). Sets `shareDescription` from `summary`. (search index: id/title/url/summary/tags/category). Sets `shareDescription` from `summary`.
## Publishing workflow (blog_publish_manager)
New notes are created under `Unpublished/<Category>` (the `Unpublished` root mirrors the blog
categories). Setting `publish=true` makes the manager clone the note into the matching blog
category (`ensureNoteIsPresentInParent`) and run the generator; setting it back to `false`
moves the clone back out of the blog. So: create the note under `Unpublished/<Category>`,
set the other labels, and set `publish=true` **last**.
## Wiring / attributes (the ground truth) ## Wiring / attributes (the ground truth)
- All inheritable relations live on the **blog root**: `~shareCss → blog_share_theme.css`, - All inheritable relations live on the **blog root**: `~shareCss → blog_share_theme.css`,
@@ -81,12 +92,19 @@ CSS file (Gruvbox, light + dark, theme toggle without reload).
- Embedded images: `#content img:not(.blog-thumb) { border-radius:8px }`. - Embedded images: `#content img:not(.blog-thumb) { border-radius:8px }`.
- **Figure captions**: `#content figcaption` is a Gruvbox chip (secondary bg, highlight - **Figure captions**: `#content figcaption` is a Gruvbox chip (secondary bg, highlight
border, 8px radius, muted italic text, centered). border, 8px radius, muted italic text, centered).
- **3D models** (`scripts/3d_model_viewer.js`): `.step`/`.stp`/`.stl` file notes (title ends - **3D models** (`scripts/3d_model_viewer.js`): renders `.step`/`.stp`/`.stl` as file notes
in the extension) or inline attachment links (link text ends in the extension) get an (title ends in the extension), inline attachment links, or links to published model file
interactive three.js viewer (drag/zoom), 8px rounded box + caption. STEP is parsed by notes from text notes (resolved via `window.blogData`). The viewer is **full-bleed**
occt-import-js (OpenCascade WASM). All five libraries are hosted as local notes and (`~100vw`, `height: min(70vh, 900px)`, ResizeObserver), with a loading progress bar and a
referenced via `__THREE_ID__`/`__THREE_ORBIT_ID__`/`__THREE_STL_ID__`/`__OCCT_JS_ID__`/ caption that always shows `name · size · triangle count`. STEP is parsed by occt-import-js
`__OCCT_WASM_ID__`. User guide: `docs/embed-3d-model.md`. (OpenCascade WASM) in a **Web Worker** (non-blocking) with
`linearDeflection: 0.0002, angularDeflection: 0.1`. Libraries hosted as local notes via
`__THREE_ID__`/`__THREE_ORBIT_ID__`/`__THREE_STL_ID__`/`__OCCT_JS_ID__`/`__OCCT_WASM_ID__`.
User guide: `docs/embed-3d-model.md`.
- **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
worker, loaded from a code note instead of a Blob so the app CSP allows it). 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
@@ -116,10 +134,27 @@ CSS file (Gruvbox, light + dark, theme toggle without reload).
for bandwidth-friendly thumbnails. for bandwidth-friendly thumbnails.
- Comments/commits in the repo are English; communication with the owner is German. - Comments/commits in the repo are English; communication with the owner is German.
## Releasing changes ## Releasing changes (do this after every change set)
Edit the corresponding Trilium note (live), verify on `richard.familie-zink.org`, then mirror 1. Edit the corresponding Trilium note (live) and verify on `richard.familie-zink.org`.
the anonymized file into this repo (replace `richard.familie-zink.org``https://YOUR-INSTANCE.example.com`, 2. Mirror the anonymized file(s) into this repo: replace
`Richard's Blog``Your Blog`, and the note-ID tokens → `__…__`), update `README.md` if the `richard.familie-zink.org``https://YOUR-INSTANCE.example.com`,
feature list changed, commit and push to `main`. Never commit real note IDs, the ETAPI token, `Richard's Blog``Your Blog`, `by fam.zink``by the author`, and every note-ID
or personal data (name/domain are allowed in the README demo badge only). token → `__…_ID__` (mappings are the tokens already used in `blog_generator.js` /
`scripts/*.js`). New component notes (e.g. the app widget) get a `__…_ID__` token in
the repo reference file.
3. Check licenses: `THIRD_PARTY_LICENSES.md` must list every vendored third-party file;
original code stays GPL-3.0. Update it if new libraries were added.
4. Update docs if behaviour changed: `docs/embed-3d-model.md` (3D viewer), `README.md`
(features/layout) and this file.
5. Commit (English message) and push to `main` with the neutral identity
`trilium-share-gruvbox <trilium-share-gruvbox@users.noreply.git.familie-zink.org>`.
Never commit real note IDs, the ETAPI token, or personal data (name/domain are allowed
in the README demo badge only).
## Repo gotchas
- shields.io badges: a literal `-` in label/message must be escaped as `--` in the badge URL
(e.g. `familie-zink.org``familie--zink.org`), otherwise shields renders a "404 badge not
found" error badge.
+14 -6
View File
@@ -1,7 +1,7 @@
# Trilium Share Gruvbox Blog System # Trilium Share Gruvbox Blog System
[![License](https://img.shields.io/badge/License-GPL--3.0-fb4934)](LICENSE) [![License](https://img.shields.io/badge/License-GPL--3.0-fb4934)](LICENSE)
[![Live demo](https://img.shields.io/badge/Live%20demo-richard.familie-zink.org-8ec07c)](https://richard.familie-zink.org) [![Live demo](https://img.shields.io/badge/Live%20demo-richard.familie--zink.org-8ec07c)](https://richard.familie-zink.org)
[![TriliumNext](https://img.shields.io/badge/TriliumNext-Public%20Share-83a598)](https://github.com/TriliumNext/Trilium) [![TriliumNext](https://img.shields.io/badge/TriliumNext-Public%20Share-83a598)](https://github.com/TriliumNext/Trilium)
[![Theme](https://img.shields.io/badge/Theme-Gruvbox-98971a)](https://github.com/morhetz/gruvbox) [![Theme](https://img.shields.io/badge/Theme-Gruvbox-98971a)](https://github.com/morhetz/gruvbox)
[![Built with](https://img.shields.io/badge/Built%20with-Opencode-fe8019)](https://opencode.ai) [![Built with](https://img.shields.io/badge/Built%20with-Opencode-fe8019)](https://opencode.ai)
@@ -27,7 +27,8 @@ no external dependencies (except `highlight.js`, vendored in `lib/`).
- Gruvbox light/dark theme for the share, with a theme toggle in the header. - Gruvbox light/dark theme for the share, with a theme toggle in the header.
- Blog index, category pages, sub-categories, tags and tag overview. - Blog index, category pages, sub-categories, tags and tag overview.
- **Menu sorting**: articles in the left menu are sorted by publication date (newest first) - **Menu sorting**: articles in the left menu are sorted by publication date (newest first)
and labelled `YYYY-MM-DD <shortened title>` automatically. and labelled `Fri 2026-08-28 <shortened title>` automatically. All date displays
(post cards, garden table, menu) include the weekday (`dateText` from the generator).
- **Article thumbnails**: each post card shows the first image of the article on the left - **Article thumbnails**: each post card shows the first image of the article on the left
(bounded by the teaser height), summary on the right, lazy-loaded. If an article has a child (bounded by the teaser height), summary on the right, lazy-loaded. If an article has a child
note named `*_thumb.*` (e.g. `ngc281_thumb.jpg`), that image is used instead ideal for note named `*_thumb.*` (e.g. `ngc281_thumb.jpg`), that image is used instead ideal for
@@ -44,10 +45,15 @@ no external dependencies (except `highlight.js`, vendored in `lib/`).
(`templates/share/page.ejs`). (`templates/share/page.ejs`).
- **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 or inline attachments) render - **3D models**: `.step` / `.stp` / `.stl` files (as file notes, as inline attachments, or linked
in an interactive three.js viewer (STEP via occt-import-js, OpenCascade WASM) drag to from text notes) render in an interactive three.js viewer (STEP via occt-import-js, OpenCascade
rotate, scroll to zoom, 8px rounded box with caption. All libraries are hosted locally. WASM, parsed in a **Web Worker** so the page stays responsive). The viewer is **full-bleed**
See [docs/embed-3d-model.md](docs/embed-3d-model.md). (~`100vw`) with a viewport-based height, drag to rotate / scroll to zoom, rounded 8px box, a
loading progress bar and a caption that always shows `name · size · triangle count`.
STEP tessellation is dense (≈0.11 M+ triangles for assemblies). 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
(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.
- Optional "Buy me a coffee" button with locally-hosted Cookie font. - Optional "Buy me a coffee" button with locally-hosted Cookie font.
- `blog_generator` (backend) builds index/feed/sitemap/search-data; `blog_publish_manager` moves - `blog_generator` (backend) builds index/feed/sitemap/search-data; `blog_publish_manager` moves
@@ -66,6 +72,8 @@ no external dependencies (except `highlight.js`, vendored in `lib/`).
| `lib/3d/*` | three.js + OrbitControls + STLLoader + occt-import-js (WASM) for the 3D viewer | code / file | | `lib/3d/*` | three.js + OrbitControls + STLLoader + occt-import-js (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-worker.js` | Web Worker (STEP parsing) loaded by the app widget from a code note (CSP-safe) | code, `text/javascript` |
| `templates/blog_article.html` | article template with promoted labels | text | | `templates/blog_article.html` | article template with promoted labels | text |
| `templates/share/*.ejs` | custom share template + partials (canonical, OG, JSON-LD, robots) | code, `application/x-ejs` | | `templates/share/*.ejs` | custom share template + partials (canonical, OG, JSON-LD, robots) | code, `application/x-ejs` |
| `assets/og-banner.png` | default Open Graph image (1200×630, replace with your own) | file | | `assets/og-banner.png` | default Open Graph image (1200×630, replace with your own) | file |
+306
View File
@@ -0,0 +1,306 @@
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 <div ref={ref} />;
}
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 <ModelViewer note={note} />;
}
});
+38
View File
@@ -0,0 +1,38 @@
self.onmessage = function (e) {
var d = e.data;
if (d.type === 'loadLib') {
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 {
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) });
}
}
};
+13 -3
View File
@@ -114,10 +114,19 @@ function run() {
} }
} }
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function dateText(raw) {
const iso = String(raw || "").slice(0, 10);
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d)) return iso;
return WEEKDAYS[d.getUTCDay()] + " " + iso;
}
function renderArticles(list) { function renderArticles(list) {
const lines = []; const lines = [];
for (const a of list) { for (const a of list) {
const date = (a.getLabelValue("date") || a.dateCreated).slice(0, 10); const date = dateText(a.getLabelValue("date") || a.dateCreated);
const cat = a.getLabelValue("category") || ""; const cat = a.getLabelValue("category") || "";
const summary = a.getLabelValue("summary") || ""; const summary = a.getLabelValue("summary") || "";
const link = `${SHARE_BASE}/${encodeURIComponent(a.getLabelValue("shareAlias") || a.noteId)}`; const link = `${SHARE_BASE}/${encodeURIComponent(a.getLabelValue("shareAlias") || a.noteId)}`;
@@ -184,7 +193,7 @@ function run() {
const link = SHARE_BASE + "/" + encodeURIComponent(a.getLabelValue("shareAlias") || a.noteId); const link = SHARE_BASE + "/" + encodeURIComponent(a.getLabelValue("shareAlias") || a.noteId);
const type = a.type || "text"; const type = a.type || "text";
const state = a.getLabelValue("state") || ""; const state = a.getLabelValue("state") || "";
const date = (a.getLabelValue("date") || a.dateCreated).slice(0, 10); const date = dateText(a.getLabelValue("date") || a.dateCreated);
html += `<tr><td><a href="${escapeHtml(link)}">${escapeHtml(a.title)}</a></td><td><span class="note-type">${escapeHtml(type)}</span></td><td>${state ? `<span class="note-state ${stateClass(state)}"><i class="${stateIcon(state)}"></i>${escapeHtml(state)}</span>` : ""}</td><td>${escapeHtml(date)}</td></tr>`; html += `<tr><td><a href="${escapeHtml(link)}">${escapeHtml(a.title)}</a></td><td><span class="note-type">${escapeHtml(type)}</span></td><td>${state ? `<span class="note-state ${stateClass(state)}"><i class="${stateIcon(state)}"></i>${escapeHtml(state)}</span>` : ""}</td><td>${escapeHtml(date)}</td></tr>`;
} }
html += '</tbody></table>'; html += '</tbody></table>';
@@ -395,7 +404,8 @@ function run() {
summary: a.getLabelValue("summary") || "", summary: a.getLabelValue("summary") || "",
tags: tagList(a), tags: tagList(a),
category: a.getLabelValue("category") || "", category: a.getLabelValue("category") || "",
date: (a.getLabelValue("date") || a.dateCreated).slice(0, 10) date: (a.getLabelValue("date") || a.dateCreated).slice(0, 10),
dateText: dateText(a.getLabelValue("date") || a.dateCreated)
})); }));
const html = `<script>window.blogData = ${JSON.stringify({ articles: list })};</script>`; const html = `<script>window.blogData = ${JSON.stringify({ articles: list })};</script>`;
if (dataNote.getContent() !== html) { if (dataNote.getContent() !== html) {
+36 -6
View File
@@ -1,7 +1,12 @@
# Embedding a 3D model (STEP / STL) in a note # Embedding a 3D model (STEP / STL) in a note
The share can render `.step` / `.stp` / `.stl` models interactively (three.js + occt-import-js, The share can render `.step` / `.stp` / `.stl` models interactively (three.js + occt-import-js,
hosted locally). There are two ways to embed a model, depending on how you store the file. hosted locally). The viewer fills the available screen width (full-bleed, ~`100vw`) with a
height based on the viewport (`min(70vh, 900px)`), so models use the maximum available space
instead of the readable content width. The caption below the model always shows
`<file name> · <size> · <triangle count>` (e.g. `Wrench.stl · 711 KB · 14.6k triangles`).
There are three ways to embed a model, depending on how you store the file.
## Option A Model as its own note (file note) ## Option A Model as its own note (file note)
@@ -9,7 +14,7 @@ hosted locally). There are two ways to embed a model, depending on how you store
e.g. `bracket.step` or `rear_housing.stp`. e.g. `bracket.step` or `rear_housing.stp`.
2. Put it under a category in the blog and set `publish=true`. 2. Put it under a category in the blog and set `publish=true`.
3. The note's share page then shows the interactive **3D viewer** automatically: 3. The note's share page then shows the interactive **3D viewer** automatically:
drag to rotate, scroll to zoom; below the model there is a caption with the file name. drag to rotate, scroll to zoom.
## Option B Model embedded inline in a text note (attachment) ## Option B Model embedded inline in a text note (attachment)
@@ -18,13 +23,27 @@ hosted locally). There are two ways to embed a model, depending on how you store
2. **Rename the attachment** so its title ends in `.step`, `.stp` or `.stl` 2. **Rename the attachment** so its title ends in `.step`, `.stp` or `.stl`
(the viewer detects a model by the file name). Example: `flange.step`. (the viewer detects a model by the file name). Example: `flange.step`.
3. **Insert the attachment link** into the note body: 3. **Insert the attachment link** into the note body:
right-click the attachment → Insert link, or drag the attachment into the text. right-click the attachment → "Insert link", or drag the attachment into the text.
(Trilium creates a link showing the file name.)
4. On the share, that link is automatically replaced by the embedded 3D viewer. 4. On the share, that link is automatically replaced by the embedded 3D viewer.
## Option C Model embedded inline from a file note (recommended for blog posts)
This lets you write blog posts that contain models inline, referencing a model that also
exists as its own (published) file note — the model stays in one place.
1. Create the model as a **file note** (title ends in `.step` / `.stp` / `.stl`),
publish it (see Option A). The viewer needs it in `blog_data`, i.e. it must be a
published article reachable in the blog tree.
2. In your **text note**, add a link to that file note, e.g.
`[Wrench.stl](#root/<noteId>)` (markdown) or drag the note reference into the editor.
3. On the share, the link to a model file note is replaced by the inline viewer.
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). The first STEP view downloads the > parsed by occt-import-js (OpenCASCADE WASM, LGPL-2.1) in a **Web Worker** (non-blocking).
> ~7.6 MB WASM once and then caches it in the browser. > The first STEP view downloads the ~7.6 MB WASM once and then caches it in the browser.
> STEP tessellation is set to `linearDeflection: 0.0002, angularDeflection: 0.1`
> (dense meshes, typically a few hundred k to >1 M triangles for assemblies).
## Requirements ## Requirements
@@ -34,3 +53,14 @@ hosted locally). There are two ways to embed a model, depending on how you store
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). `__OCCT_WASM_ID__` (see the README installation for the note titles).
- 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
`./<alias>` links against `window.blogData`).
## Trilium app widget (independent of the share)
The same viewer is also available **inside Trilium itself** (independent of the blog):
a `note-detail-pane` widget (`app/3d-model-viewer-app.jsx` + `app/3d-worker.js`) renders
the viewer whenever you open a file note whose title ends in `.step` / `.stp` / `.stl`.
The worker is loaded from a code note (not a Blob) so it works under the app's CSP.
Wire it by giving the JSX note the label `#widget=3dModelViewer` and reload the app.
+54 -10
View File
@@ -11,7 +11,7 @@
var MODEL_RE = /\.(stl|step|stp)$/i; var MODEL_RE = /\.(stl|step|stp)$/i;
var STEP_PARAMS = { linearDeflection: 0.0005, angularDeflection: 0.2 }; var STEP_PARAMS = { linearDeflection: 0.0002, angularDeflection: 0.1 };
var WORKER_SRC = [ var WORKER_SRC = [
"self.onmessage = function (e) {", "self.onmessage = function (e) {",
@@ -153,12 +153,22 @@
} }
function showData(extra) { function showData(extra) {
var d = el.querySelector(".model-viewer-data"); var d = el.querySelector(".model-viewer-data");
if (!d) return; if (d) {
var parts = []; var parts = [];
if (fileSize) parts.push(formatBytes(fileSize)); if (fileSize) parts.push(formatBytes(fileSize));
if (extra) parts.push(extra); if (extra) parts.push(extra);
if (parts.length) { d.textContent = parts.join(" \u00b7 "); d.style.display = "block"; } 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) { function loadThree(cb) {
if (window.THREE && window.THREE.OrbitControls) { cb(); return; } if (window.THREE && window.THREE.OrbitControls) { cb(); return; }
@@ -176,14 +186,16 @@
loadThree(function () { loadThree(function () {
var wrap = el.querySelector(".model-viewer-canvas") || el; var wrap = el.querySelector(".model-viewer-canvas") || el;
var w = wrap.clientWidth || el.clientWidth || 400; function canvasSize() {
var h = Math.min(520, Math.max(320, Math.round(w * 0.62))); return { w: wrap.clientWidth || el.clientWidth || 400, h: wrap.clientHeight || 420 };
}
var initSize = canvasSize();
var renderer = new THREE.WebGLRenderer({ antialias: true }); var renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(w, h); renderer.setSize(initSize.w, initSize.h);
renderer.setClearColor(0x1d2021, 1); renderer.setClearColor(0x1d2021, 1);
var scene = new THREE.Scene(); var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 1000000); var camera = new THREE.PerspectiveCamera(45, initSize.w / initSize.h, 0.1, 1000000);
camera.position.set(300, 220, 320); camera.position.set(300, 220, 320);
scene.add(new THREE.AmbientLight(0xffffff, 0.55)); 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 dir = new THREE.DirectionalLight(0xffffff, 1.0); dir.position.set(1, 1.5, 1); scene.add(dir);
@@ -193,6 +205,15 @@
var group = new THREE.Group(); var group = new THREE.Group();
scene.add(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); } function animate() { requestAnimationFrame(animate); controls.update(); renderer.render(scene, camera); }
animate(); animate();
@@ -226,6 +247,8 @@
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);
var t0 = Date.now();
var parseTimer = setInterval(function () { setStatus("Parsing STEP\u2026 (" + Math.round((Date.now() - t0) / 1000) + "s)"); }, 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);
@@ -234,7 +257,7 @@
if (e.data.type === "ready") { if (e.data.type === "ready") {
worker.postMessage({ type: "parse", data: data, params: STEP_PARAMS }, [data.buffer]); worker.postMessage({ type: "parse", data: data, params: STEP_PARAMS }, [data.buffer]);
} else if (e.data.type === "done") { } else if (e.data.type === "done") {
settled = true; worker.terminate(); URL.revokeObjectURL(wurl); settled = true; worker.terminate(); URL.revokeObjectURL(wurl); clearInterval(parseTimer);
if (!e.data.success) { onError("Could not parse STEP file."); return; } if (!e.data.success) { onError("Could not parse STEP file."); return; }
var tris = 0; var tris = 0;
(e.data.meshes || []).forEach(function (m) { (e.data.meshes || []).forEach(function (m) {
@@ -251,17 +274,17 @@
showCanvas(); showCanvas();
showData(formatCount(tris) + " triangles"); showData(formatCount(tris) + " triangles");
} else if (e.data.type === "error") { } else if (e.data.type === "error") {
settled = true; worker.terminate(); URL.revokeObjectURL(wurl); 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.");
} }
}; };
worker.onerror = function (e) { worker.onerror = function (e) {
if (!settled) { settled = true; worker.terminate(); URL.revokeObjectURL(wurl); 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: "loadLib", libUrl: OCCT_JS_ABS, wasm: new Uint8Array(wasmBuf) }, [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); var geo = new THREE.STLLoader().parse(data.buffer);
geo.computeVertexNormals(); geo.computeVertexNormals();
var mat = new THREE.MeshStandardMaterial({ color: 0xb8bb26, metalness: 0.25, roughness: 0.55, flatShading: true }); var mat = new THREE.MeshStandardMaterial({ color: 0xb8bb26, metalness: 0.25, roughness: 0.55, flatShading: true });
group.add(new THREE.Mesh(geo, mat)); group.add(new THREE.Mesh(geo, mat));
@@ -299,5 +322,26 @@
a.replaceWith(el); a.replaceWith(el);
initViewer(el, href, m[0]); 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);
a.replaceWith(el);
initViewer(el, "api/notes/" + art.id + "/download", mm[0]);
});
})(); })();
</script> </script>
+6 -5
View File
@@ -13,9 +13,9 @@
function shorten(t) { function shorten(t) {
t = String(t || "").replace(/\s+/g, " ").trim(); t = String(t || "").replace(/\s+/g, " ").trim();
if (t.length <= 26) return t; if (t.length <= 32) return t;
var cut = t.slice(0, 26).replace(/\s+\S*$/, ""); var cut = t.slice(0, 32).replace(/\s+\S*$/, "");
return (cut.length ? cut : t.slice(0, 26)) + "…"; return (cut.length ? cut : t.slice(0, 32)) + "…";
} }
menu.querySelectorAll("li").forEach(function (li) { menu.querySelectorAll("li").forEach(function (li) {
@@ -25,8 +25,9 @@
if (!m) return; if (!m) return;
var art = byAlias[m[1]]; var art = byAlias[m[1]];
if (!art) return; if (!art) return;
var date = String(art.date || "").slice(0, 10); var pub = String(art.date || "").slice(0, 10);
li.setAttribute("data-pubdate", date); var date = String(art.dateText || pub || "");
li.setAttribute("data-pubdate", pub);
var span = a.querySelector("span"); var span = a.querySelector("span");
if (span) { if (span) {
var label = (date ? date + " " : "") + shorten(a.textContent.replace(/\s+/g, " ").trim()); var label = (date ? date + " " : "") + shorten(a.textContent.replace(/\s+/g, " ").trim());
+21 -5
View File
@@ -1,5 +1,5 @@
/* /*
* Gruvbox Share Theme for Your Blog (Trilium Public Share) * Gruvbox Share Theme for Richard's Blog (Trilium Public Share)
* Palette: bg #282828 / #3c3836 / #504945 / #1d2021 fg #ebdbb2 / #fbf1c7 * Palette: bg #282828 / #3c3836 / #504945 / #1d2021 fg #ebdbb2 / #fbf1c7
* red #fb4934 green #b8bb26 yellow #fabd2f * red #fb4934 green #b8bb26 yellow #fabd2f
* blue #83a598 purple #d3869b aqua #8ec07c orange #fe8019 * blue #83a598 purple #d3869b aqua #8ec07c orange #fe8019
@@ -811,7 +811,10 @@ html.theme-light #content code {
/* ---------- 3D model viewer (STL / STEP) ---------- */ /* ---------- 3D model viewer (STL / STEP) ---------- */
.model-viewer { .model-viewer {
position: relative; position: relative;
box-sizing: border-box;
width: calc(100vw - 24px);
margin: 1em 0; margin: 1em 0;
margin-left: calc(50% - (100vw - 24px) / 2);
background: #1d2021; background: #1d2021;
border: 1px solid var(--background-highlight); border: 1px solid var(--background-highlight);
border-radius: 8px; border-radius: 8px;
@@ -836,11 +839,20 @@ html.theme-light #content code {
.model-viewer-canvas { .model-viewer-canvas {
position: relative; position: relative;
width: 100%;
height: min(70vh, 900px);
min-height: 420px;
} }
.model-viewer-status { .model-viewer-status {
padding: 48px; position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center; text-align: center;
padding: 48px;
box-sizing: border-box;
color: #a89984; color: #a89984;
font-size: 0.9em; font-size: 0.9em;
} }
@@ -850,9 +862,13 @@ html.theme-light #content code {
} }
.model-viewer-progress { .model-viewer-progress {
position: relative; position: absolute;
left: 0;
right: 0;
bottom: 40px;
height: 6px; height: 6px;
margin: 0 48px 22px; width: min(60%, 480px);
margin: 0 auto;
background: #3c3836; background: #3c3836;
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
@@ -899,7 +915,7 @@ html.theme-light #content code {
.model-viewer canvas { .model-viewer canvas {
display: block; display: block;
width: 100%; width: 100%;
height: auto; height: 100%;
} }
/* ---------- Bildunterschriften (Gruvbox) ---------- */ /* ---------- Bildunterschriften (Gruvbox) ---------- */