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:
@@ -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.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Trilium Share – Gruvbox Blog System
|
# Trilium Share – Gruvbox Blog System
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://richard.familie-zink.org)
|
[](https://richard.familie-zink.org)
|
||||||
[](https://github.com/TriliumNext/Trilium)
|
[](https://github.com/TriliumNext/Trilium)
|
||||||
[](https://github.com/morhetz/gruvbox)
|
[](https://github.com/morhetz/gruvbox)
|
||||||
[](https://opencode.ai)
|
[](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.1–1 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 |
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
+37
-7
@@ -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
|
||||||
|
|
||||||
@@ -33,4 +52,15 @@ hosted locally). There are two ways to embed a model, depending on how you store
|
|||||||
- 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).
|
`__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.
|
||||||
+58
-14
@@ -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,11 +153,21 @@
|
|||||||
}
|
}
|
||||||
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) {
|
||||||
@@ -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>
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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) ---------- */
|
||||||
|
|||||||
Reference in New Issue
Block a user