Add local 3D model viewer (three.js + occt-import-js) for STEP/STL, 8px box + caption; embed guide; licenses

This commit is contained in:
trilium-share-gruvbox
2026-08-28 15:24:56 +02:00
parent 11216f8996
commit e13a5433d9
11 changed files with 1752 additions and 1 deletions
+9
View File
@@ -22,6 +22,7 @@ CSS file (Gruvbox, light + dark, theme toggle without reload).
| `theme/blog_share_theme.css` | `blog_share_theme.css` (`shareCss`) | the entire Gruvbox theme |
| `theme/favicon.svg`, `theme/cookie.woff2` | `favicon`, `Cookie` | site icon + BMC font |
| `lib/highlight.js`, `lib/hljs_line_numbers.js` | `highlight.js`, `hljs line numbers plugin` | code highlighting |
| `lib/3d/*` | three.js, OrbitControls, STLLoader, occt-import-js (WASM) | local libraries for the 3D model viewer |
| `scripts/*.js` | one code note each (`.blog`), attached via `~shareHtml` | frontend snippets (verbatim content incl. `<script>`) |
| `templates/blog_article.html` | `Blog Article` | article template with promoted labels |
| `examples/*` | ignis8 icons/script | sample category logo |
@@ -80,6 +81,12 @@ CSS file (Gruvbox, light + dark, theme toggle without reload).
- Embedded images: `#content img:not(.blog-thumb) { border-radius:8px }`.
- **Figure captions**: `#content figcaption` is a Gruvbox chip (secondary bg, highlight
border, 8px radius, muted italic text, centered).
- **3D models** (`scripts/3d_model_viewer.js`): `.step`/`.stp`/`.stl` file notes (title ends
in the extension) or inline attachment links (link text ends in the extension) get an
interactive three.js viewer (drag/zoom), 8px rounded box + caption. STEP is parsed by
occt-import-js (OpenCascade WASM). All five libraries are hosted as local notes and
referenced via `__THREE_ID__`/`__THREE_ORBIT_ID__`/`__THREE_STL_ID__`/`__OCCT_JS_ID__`/
`__OCCT_WASM_ID__`. User guide: `docs/embed-3d-model.md`.
- Images in the share are served full-size only (no resize endpoint) → keep the
`_thumb` child-note convention for small thumbnails.
- Built-in share footer navigation and book children list are hidden
@@ -92,6 +99,8 @@ CSS file (Gruvbox, light + dark, theme toggle without reload).
`fs`, `child_process`, `http` etc. are blocked. Therefore **image resizing inside a
backend script is impossible** without patching Trilium source. Do not attempt to add
sharp-based code to the generator.
- `occt-import-js` / OpenCASCADE (used by the 3D viewer) is **LGPL-2.1** (see
`THIRD_PARTY_LICENSES.md`); three.js is MIT. Both are hosted locally under `lib/3d/`.
- Triggering the generator via an attribute change on the **blog root itself** is ignored
by the self-trigger guard (`isGeneratedNoteId(BLOG_ROOT_ID) → true`). To force a full
regeneration, change an attribute/content on a **published article** instead.
+18 -1
View File
@@ -40,6 +40,10 @@ no external dependencies (except `highlight.js`, vendored in `lib/`).
- **Search** (client-side over a generated index), **RSS feed** and **sitemap.xml**.
- **Widget rendering** for Mermaid, Excalidraw (canvas) and MindElixir (mind map) notes, with a
collapsible, highlighted "Source" block and copy button.
- **3D models**: `.step` / `.stp` / `.stl` files (as file notes or inline attachments) render
in an interactive three.js viewer (STEP via occt-import-js, OpenCascade WASM) drag to
rotate, scroll to zoom, 8px rounded box with caption. All libraries are hosted locally.
See [docs/embed-3d-model.md](docs/embed-3d-model.md).
- Code highlighting (highlight.js + line numbers) and copy buttons on all code boxes.
- Optional "Buy me a coffee" button with locally-hosted Cookie font.
- `blog_generator` (backend) builds index/feed/sitemap/search-data; `blog_publish_manager` moves
@@ -55,9 +59,11 @@ no external dependencies (except `highlight.js`, vendored in `lib/`).
| `theme/favicon.svg` | default site favicon | code (SVG) |
| `theme/cookie.woff2` | "Cookie" font for the Buy me a coffee button | file, `font/woff2` |
| `lib/highlight.js` | vendored highlight.js | code, `application/javascript` |
| `lib/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 |
| `scripts/*.js` | frontend share snippets (content is pasted **verbatim** into a code note, including the `<script>`/`<style>` wrapper) | code |
| `templates/blog_article.html` | article template with promoted labels | text |
| `docs/embed-3d-model.md` | how to embed STEP/STL in a note | |
| `examples/*` | sample category logo + menu icon script (ignis8) | code / SVG |
| `generated/*` | reference output, **regenerated automatically** by `blog_generator.js` | code / xml |
@@ -123,6 +129,12 @@ Under **`.blog`** create one code note per file, using the file name as the note
| `search header` | `scripts/search_header.js` | `text/html` |
| `note fallback` | `scripts/note_fallback.js` | `text/html` |
| `menu date sort` | `scripts/menu_date_sort.js` | `text/html` |
| `3d model viewer` | `scripts/3d_model_viewer.js` | `text/html` |
| `three.js` | `lib/3d/three.min.js` | `application/javascript` |
| `three orbit controls` | `lib/3d/OrbitControls.js` | `application/javascript` |
| `three stl loader` | `lib/3d/STLLoader.js` | `application/javascript` |
| `occt-import-js` | `lib/3d/occt-import-js.js` | `application/javascript` |
| `occt-import-js.wasm` | `lib/3d/occt-import-js.wasm` | `application/wasm` (file note) |
| `feed.xml` | `generated/feed.xml` | code (regenerated) |
| `sitemap.xml` | `generated/sitemap.xml` | code (regenerated) |
| `state_map` | `generated/state_map.js` | `text/html` (regenerated) |
@@ -147,7 +159,7 @@ mime `application/javascript;env=backend`) and `blog_publish_manager` (from
| `iconClass` | `bx bx-edit-alt` | |
| `~shareCss` | → `blog_share_theme.css` | yes |
| `~shareFavicon` | → `favicon` | yes |
| `~shareHtml` | → `table column hover`, `blog card click`, `garden row click`, `code note highlight`, `code copy & lines`, `state_map`, `title state badge`, `bmc button`, `logo share alias`, `blog_data`, `prev next`, `reading time`, `search header`, `ignis8 menu icon` (optional), `note fallback`, `menu date sort` | yes |
| `~shareHtml` | → `table column hover`, `blog card click`, `garden row click`, `code note highlight`, `code copy & lines`, `state_map`, `title state badge`, `bmc button`, `logo share alias`, `blog_data`, `prev next`, `reading time`, `search header`, `ignis8 menu icon` (optional), `note fallback`, `menu date sort`, `3d model viewer` | yes |
| `~shareHtml` | → `hide home title` | **no** (index only) |
| `~runOnNoteContentChange`, `~runOnAttributeChange`, `~runOnNoteCreation`, `~runOnNoteTitleChange`, `~runOnAttributeCreation`, `~runOnBranchDeletion`, `~runOnChildNoteCreation` | → `blog_generator` | yes |
| `~runOnAttributeChange`, `~runOnAttributeCreation` | → `blog_publish_manager` | yes |
@@ -194,6 +206,11 @@ placeholder tokens. Replace every `__TOKEN__` with the real note ID of the corre
| `__HLJS_ID__` | `highlight.js` |
| `__HLJS_LN_ID__` | `hljs line numbers plugin` |
| `__COOKIE_FONT_ID__` | `Cookie` |
| `__THREE_ID__` | `three.js` |
| `__THREE_ORBIT_ID__` | `three orbit controls` |
| `__THREE_STL_ID__` | `three stl loader` |
| `__OCCT_JS_ID__` | `occt-import-js` |
| `__OCCT_WASM_ID__` | `occt-import-js.wasm` |
| `__TITLE_ICON_ID__` | (only needed for the `ignis8` example) |
| `__CATEGORY_NOTE_ID__` | (only needed for the `ignis8` example) |
+8
View File
@@ -9,6 +9,8 @@ The following files in this repository are **not** covered by the GPL-3.0 and ke
| `lib/hljs_line_numbers.js` | [highlightjs-line-numbers](https://github.com/yury-dymov/highlightjs-line-numbers) | [MIT](#mit-license) |
| `theme/cookie.woff2` | "Cookie" font, © 2011 [Ania Kruk](https://fonts.google.com/specimen/Cookie) | [SIL Open Font License 1.1](https://openfontlicense.org/) |
| `assets/palette.png` | Gruvbox palette image from [morhetz/gruvbox](https://github.com/morhetz/gruvbox) (repo: MIT) | [MIT](#mit-license) |
| `lib/3d/three.min.js`, `lib/3d/OrbitControls.js`, `lib/3d/STLLoader.js` | [three.js](https://github.com/mrdoob/three.js) r128 | [MIT](#mit-license) |
| `lib/3d/occt-import-js.js`, `lib/3d/occt-import-js.wasm` | [occt-import-js](https://github.com/kovacsv/occt-import-js) (OpenCASCADE, WASM) | [LGPL-2.1](#lgpl-21) |
| `scripts/bmc_button.js` | embeds the Trilium logo (SVG) from [Trilium/TriliumNext](https://github.com/TriliumNext/Trilium) | [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) |
## BSD-3-Clause
@@ -76,6 +78,12 @@ The full license text is available at <https://openfontlicense.org/> or
Copyright (c) 2011 by Ania Kruk, with Reserved Font Name "Cookie".
## LGPL-2.1
`occt-import-js` is distributed under the GNU Lesser General Public License v2.1
(<https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html>). OpenCASCADE is LGPL-2.1 as well;
the WASM blob is loaded at runtime by `scripts/3d_model_viewer.js` and is not statically linked.
## AGPL-3.0
The Trilium logo embedded in `scripts/bmc_button.js` belongs to the Trilium /
+36
View File
@@ -0,0 +1,36 @@
# Embedding a 3D model (STEP / STL) in a note
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.
## Option A Model as its own note (file note)
1. Create (or upload) a **file note** and make sure its **title ends in `.step`, `.stp` or `.stl`**,
e.g. `bracket.step` or `rear_housing.stp`.
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:
drag to rotate, scroll to zoom; below the model there is a caption with the file name.
## Option B Model embedded inline in a text note (attachment)
1. Upload the STEP/STL file as an **attachment** of the note:
drag & drop it onto the note, or use the attachment dialog (paperclip icon).
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`.
3. **Insert the attachment link** into the note body:
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.
> 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
> ~7.6 MB WASM once and then caches it in the browser.
## Requirements
- The `3d model viewer` script must be wired: `~shareHtml → 3d model viewer`
(inheritable) on the blog root, `shareHtmlLocation=body:end`.
- The five libraries must exist as notes and be referenced by the script via the
placeholder tokens in `scripts/3d_model_viewer.js`:
`__THREE_ID__`, `__THREE_ORBIT_ID__`, `__THREE_STL_ID__`, `__OCCT_JS_ID__`,
`__OCCT_WASM_ID__` (see the README installation for the note titles).
File diff suppressed because it is too large Load Diff
+371
View File
@@ -0,0 +1,371 @@
( function () {
/**
* Description: A THREE loader for STL ASCII files, as created by Solidworks and other CAD programs.
*
* Supports both binary and ASCII encoded files, with automatic detection of type.
*
* The loader returns a non-indexed buffer geometry.
*
* Limitations:
* Binary decoding supports "Magics" color format (http://en.wikipedia.org/wiki/STL_(file_format)#Color_in_binary_STL).
* There is perhaps some question as to how valid it is to always assume little-endian-ness.
* ASCII decoding assumes file is UTF-8.
*
* Usage:
* const loader = new STLLoader();
* loader.load( './models/stl/slotted_disk.stl', function ( geometry ) {
* scene.add( new THREE.Mesh( geometry ) );
* });
*
* For binary STLs geometry might contain colors for vertices. To use it:
* // use the same code to load STL as above
* if (geometry.hasColors) {
* material = new THREE.MeshPhongMaterial({ opacity: geometry.alpha, vertexColors: true });
* } else { .... }
* const mesh = new THREE.Mesh( geometry, material );
*
* For ASCII STLs containing multiple solids, each solid is assigned to a different group.
* Groups can be used to assign a different color by defining an array of materials with the same length of
* geometry.groups and passing it to the Mesh constructor:
*
* const mesh = new THREE.Mesh( geometry, material );
*
* For example:
*
* const materials = [];
* const nGeometryGroups = geometry.groups.length;
*
* const colorMap = ...; // Some logic to index colors.
*
* for (let i = 0; i < nGeometryGroups; i++) {
*
* const material = new THREE.MeshPhongMaterial({
* color: colorMap[i],
* wireframe: false
* });
*
* }
*
* materials.push(material);
* const mesh = new THREE.Mesh(geometry, materials);
*/
class STLLoader extends THREE.Loader {
constructor( manager ) {
super( manager );
}
load( url, onLoad, onProgress, onError ) {
const scope = this;
const loader = new THREE.FileLoader( this.manager );
loader.setPath( this.path );
loader.setResponseType( 'arraybuffer' );
loader.setRequestHeader( this.requestHeader );
loader.setWithCredentials( this.withCredentials );
loader.load( url, function ( text ) {
try {
onLoad( scope.parse( text ) );
} catch ( e ) {
if ( onError ) {
onError( e );
} else {
console.error( e );
}
scope.manager.itemError( url );
}
}, onProgress, onError );
}
parse( data ) {
function isBinary( data ) {
const reader = new DataView( data );
const face_size = 32 / 8 * 3 + 32 / 8 * 3 * 3 + 16 / 8;
const n_faces = reader.getUint32( 80, true );
const expect = 80 + 32 / 8 + n_faces * face_size;
if ( expect === reader.byteLength ) {
return true;
} // An ASCII STL data must begin with 'solid ' as the first six bytes.
// However, ASCII STLs lacking the SPACE after the 'd' are known to be
// plentiful. So, check the first 5 bytes for 'solid'.
// Several encodings, such as UTF-8, precede the text with up to 5 bytes:
// https://en.wikipedia.org/wiki/Byte_order_mark#Byte_order_marks_by_encoding
// Search for "solid" to start anywhere after those prefixes.
// US-ASCII ordinal values for 's', 'o', 'l', 'i', 'd'
const solid = [ 115, 111, 108, 105, 100 ];
for ( let off = 0; off < 5; off ++ ) {
// If "solid" text is matched to the current offset, declare it to be an ASCII STL.
if ( matchDataViewAt( solid, reader, off ) ) return false;
} // Couldn't find "solid" text at the beginning; it is binary STL.
return true;
}
function matchDataViewAt( query, reader, offset ) {
// Check if each byte in query matches the corresponding byte from the current offset
for ( let i = 0, il = query.length; i < il; i ++ ) {
if ( query[ i ] !== reader.getUint8( offset + i, false ) ) return false;
}
return true;
}
function parseBinary( data ) {
const reader = new DataView( data );
const faces = reader.getUint32( 80, true );
let r,
g,
b,
hasColors = false,
colors;
let defaultR, defaultG, defaultB, alpha; // process STL header
// check for default color in header ("COLOR=rgba" sequence).
for ( let index = 0; index < 80 - 10; index ++ ) {
if ( reader.getUint32( index, false ) == 0x434F4C4F
/*COLO*/
&& reader.getUint8( index + 4 ) == 0x52
/*'R'*/
&& reader.getUint8( index + 5 ) == 0x3D
/*'='*/
) {
hasColors = true;
colors = new Float32Array( faces * 3 * 3 );
defaultR = reader.getUint8( index + 6 ) / 255;
defaultG = reader.getUint8( index + 7 ) / 255;
defaultB = reader.getUint8( index + 8 ) / 255;
alpha = reader.getUint8( index + 9 ) / 255;
}
}
const dataOffset = 84;
const faceLength = 12 * 4 + 2;
const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array( faces * 3 * 3 );
const normals = new Float32Array( faces * 3 * 3 );
for ( let face = 0; face < faces; face ++ ) {
const start = dataOffset + face * faceLength;
const normalX = reader.getFloat32( start, true );
const normalY = reader.getFloat32( start + 4, true );
const normalZ = reader.getFloat32( start + 8, true );
if ( hasColors ) {
const packedColor = reader.getUint16( start + 48, true );
if ( ( packedColor & 0x8000 ) === 0 ) {
// facet has its own unique color
r = ( packedColor & 0x1F ) / 31;
g = ( packedColor >> 5 & 0x1F ) / 31;
b = ( packedColor >> 10 & 0x1F ) / 31;
} else {
r = defaultR;
g = defaultG;
b = defaultB;
}
}
for ( let i = 1; i <= 3; i ++ ) {
const vertexstart = start + i * 12;
const componentIdx = face * 3 * 3 + ( i - 1 ) * 3;
vertices[ componentIdx ] = reader.getFloat32( vertexstart, true );
vertices[ componentIdx + 1 ] = reader.getFloat32( vertexstart + 4, true );
vertices[ componentIdx + 2 ] = reader.getFloat32( vertexstart + 8, true );
normals[ componentIdx ] = normalX;
normals[ componentIdx + 1 ] = normalY;
normals[ componentIdx + 2 ] = normalZ;
if ( hasColors ) {
colors[ componentIdx ] = r;
colors[ componentIdx + 1 ] = g;
colors[ componentIdx + 2 ] = b;
}
}
}
geometry.setAttribute( 'position', new THREE.BufferAttribute( vertices, 3 ) );
geometry.setAttribute( 'normal', new THREE.BufferAttribute( normals, 3 ) );
if ( hasColors ) {
geometry.setAttribute( 'color', new THREE.BufferAttribute( colors, 3 ) );
geometry.hasColors = true;
geometry.alpha = alpha;
}
return geometry;
}
function parseASCII( data ) {
const geometry = new THREE.BufferGeometry();
const patternSolid = /solid([\s\S]*?)endsolid/g;
const patternFace = /facet([\s\S]*?)endfacet/g;
let faceCounter = 0;
const patternFloat = /[\s]+([+-]?(?:\d*)(?:\.\d*)?(?:[eE][+-]?\d+)?)/.source;
const patternVertex = new RegExp( 'vertex' + patternFloat + patternFloat + patternFloat, 'g' );
const patternNormal = new RegExp( 'normal' + patternFloat + patternFloat + patternFloat, 'g' );
const vertices = [];
const normals = [];
const normal = new THREE.Vector3();
let result;
let groupCount = 0;
let startVertex = 0;
let endVertex = 0;
while ( ( result = patternSolid.exec( data ) ) !== null ) {
startVertex = endVertex;
const solid = result[ 0 ];
while ( ( result = patternFace.exec( solid ) ) !== null ) {
let vertexCountPerFace = 0;
let normalCountPerFace = 0;
const text = result[ 0 ];
while ( ( result = patternNormal.exec( text ) ) !== null ) {
normal.x = parseFloat( result[ 1 ] );
normal.y = parseFloat( result[ 2 ] );
normal.z = parseFloat( result[ 3 ] );
normalCountPerFace ++;
}
while ( ( result = patternVertex.exec( text ) ) !== null ) {
vertices.push( parseFloat( result[ 1 ] ), parseFloat( result[ 2 ] ), parseFloat( result[ 3 ] ) );
normals.push( normal.x, normal.y, normal.z );
vertexCountPerFace ++;
endVertex ++;
} // every face have to own ONE valid normal
if ( normalCountPerFace !== 1 ) {
console.error( 'THREE.STLLoader: Something isn\'t right with the normal of face number ' + faceCounter );
} // each face have to own THREE valid vertices
if ( vertexCountPerFace !== 3 ) {
console.error( 'THREE.STLLoader: Something isn\'t right with the vertices of face number ' + faceCounter );
}
faceCounter ++;
}
const start = startVertex;
const count = endVertex - startVertex;
geometry.addGroup( start, count, groupCount );
groupCount ++;
}
geometry.setAttribute( 'position', new THREE.Float32BufferAttribute( vertices, 3 ) );
geometry.setAttribute( 'normal', new THREE.Float32BufferAttribute( normals, 3 ) );
return geometry;
}
function ensureString( buffer ) {
if ( typeof buffer !== 'string' ) {
return THREE.LoaderUtils.decodeText( new Uint8Array( buffer ) );
}
return buffer;
}
function ensureBinary( buffer ) {
if ( typeof buffer === 'string' ) {
const array_buffer = new Uint8Array( buffer.length );
for ( let i = 0; i < buffer.length; i ++ ) {
array_buffer[ i ] = buffer.charCodeAt( i ) & 0xff; // implicitly assumes little-endian
}
return array_buffer.buffer || array_buffer;
} else {
return buffer;
}
} // start
const binData = ensureBinary( data );
return isBinary( binData ) ? parseBinary( binData ) : parseASCII( ensureString( data ) );
}
}
THREE.STLLoader = STLLoader;
} )();
File diff suppressed because one or more lines are too long
Binary file not shown.
+6
View File
File diff suppressed because one or more lines are too long
+190
View File
@@ -0,0 +1,190 @@
<script>
(function () {
var THREE_JS = "api/notes/__THREE_ID__/download";
var ORBIT_JS = "api/notes/__THREE_ORBIT_ID__/download";
var STL_JS = "api/notes/__THREE_STL_ID__/download";
var OCCT_JS = "api/notes/__OCCT_JS_ID__/download";
var OCCT_WASM = "api/notes/__OCCT_WASM_ID__/download";
var MODEL_RE = /\.(stl|step|stp)$/i;
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";
wrap.appendChild(status);
box.appendChild(wrap);
if (name) {
var cap = document.createElement("div");
cap.className = "model-viewer-caption";
cap.textContent = name;
box.appendChild(cap);
}
return box;
}
function loadScript(src, cb) {
var s = document.createElement("script");
s.src = src;
s.onload = cb;
document.head.appendChild(s);
}
function initViewer(el, url, ext) {
var isStep = /\.(step|stp)$/i.test(ext);
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);
});
});
}
function status(text) {
var s = el.querySelector(".model-viewer-status");
if (s) s.textContent = text;
}
function fit(group) {
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 buildScene(cb) {
var statusEl = el.querySelector(".model-viewer-status");
var wrap = el.querySelector(".model-viewer-canvas") || el;
var w = wrap.clientWidth || el.clientWidth || 400;
var h = Math.min(520, Math.max(320, Math.round(w * 0.62)));
var renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(w, h);
renderer.setClearColor(0x1d2021, 1);
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);
var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(45, w / 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);
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
cb({ group: group, scene: scene, camera: camera, controls: controls, statusEl: statusEl });
}
function onError(msg) {
var s = el.querySelector(".model-viewer-status");
if (s) { s.textContent = msg || "Could not load 3D model."; s.classList.add("error"); }
}
loadThree(function () {
buildScene(function (ctx) {
fetch(url).then(function (r) {
if (!r.ok) throw new Error("fetch " + r.status);
return r.arrayBuffer();
}).then(function (buf) {
var data = new Uint8Array(buf);
if (isStep) {
ctx.statusEl.textContent = "Loading STEP parser\u2026";
function runOcct() {
ctx.statusEl.textContent = "Parsing STEP\u2026";
window.occtimportjs({ locateFile: function () { return OCCT_WASM; } }).then(function (occt) {
var result = occt.ReadStepFile(data, null);
if (!result || !result.success) { onError("Could not parse STEP file."); return; }
(result.meshes || []).forEach(function (mesh) {
if (!mesh.attributes || !mesh.attributes.position) return;
var geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(mesh.attributes.position.array, 3));
if (mesh.attributes.normal && mesh.attributes.normal.array) {
geo.setAttribute("normal", new THREE.Float32BufferAttribute(mesh.attributes.normal.array, 3));
}
if (mesh.attributes.index && mesh.attributes.index.array) {
geo.setIndex(mesh.attributes.index.array);
}
var color = mesh.color && mesh.color.length === 3
? new THREE.Color(mesh.color[0], mesh.color[1], mesh.color[2]) : null;
var mat = new THREE.MeshStandardMaterial({
color: color || 0xb8bb26,
metalness: 0.25,
roughness: 0.55,
side: THREE.DoubleSide
});
ctx.group.add(new THREE.Mesh(geo, mat));
});
fit(ctx.group);
}).catch(function (e) { onError("Could not load STEP parser."); });
}
if (window.occtimportjs) { runOcct(); } else { loadScript(OCCT_JS, runOcct); }
} else {
var geo = new THREE.STLLoader().parse(data);
geo.computeVertexNormals();
var mat = new THREE.MeshStandardMaterial({ color: 0xb8bb26, metalness: 0.25, roughness: 0.55 });
ctx.group.add(new THREE.Mesh(geo, mat));
fit(ctx.group);
}
}).catch(function (e) { onError("Could not load 3D model."); });
});
});
}
var type = (document.body.className.match(/type-([\w-]+)/) || [])[1];
if (type === "file") {
var title = ((document.getElementById("title") || {}).textContent || document.title || "").trim();
var m = title.match(MODEL_RE);
if (m) {
var content = document.getElementById("content");
if (content) {
var noteId = document.body.getAttribute("data-note-id");
var el = makeViewer(title.trim());
content.innerHTML = "";
content.appendChild(el);
initViewer(el, "api/notes/" + noteId + "/download", m[0]);
}
}
}
document.querySelectorAll("#content a[href*='api/attachments/']").forEach(function (a) {
var name = (a.textContent || "").trim();
var m = name.match(MODEL_RE);
if (!m) return;
var href = a.getAttribute("href");
var el = makeViewer(name);
a.replaceWith(el);
initViewer(el, href, m[0]);
});
})();
</script>
+50
View File
@@ -808,6 +808,56 @@ html.theme-light #content code {
border-radius: 8px;
}
/* ---------- 3D model viewer (STL / STEP) ---------- */
.model-viewer {
margin: 1em 0;
background: #1d2021;
border: 1px solid var(--background-highlight);
border-radius: 8px;
overflow: hidden;
}
.model-viewer-canvas {
position: relative;
}
.model-viewer-status {
padding: 48px;
text-align: center;
color: #a89984;
font-size: 0.9em;
}
.model-viewer-status.error {
color: #fb4934;
}
.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: var(--text-menu);
text-align: center;
padding: 4px 12px;
background: var(--background-secondary);
border-top: 1px solid var(--background-highlight);
}
.model-viewer canvas {
display: block;
width: 100%;
height: auto;
}
/* ---------- Bildunterschriften (Gruvbox) ---------- */
#content figcaption {
font-size: 0.85em;