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 committed 2026-08-28 15:24:56 +02:00
1 parent 11216f8996
commit e13a5433d9
11 files changed
+1752 -1

No files matched your search

+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;
} )();
+19
View File
@@ -0,0 +1,19 @@
var occtimportjs = (() => {
var _scriptName = typeof document != 'undefined' ? document.currentScript?.src : undefined;
if (typeof __filename != 'undefined') _scriptName = _scriptName || __filename;
return (
function(moduleArg = {}) {
var moduleRtn;
var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";if(ENVIRONMENT_IS_NODE){}var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("fs");var nodePath=require("path");scriptDirectory=__dirname+"/";readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);var ret=fs.readFileSync(filename);return ret};readAsync=(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);return new Promise((resolve,reject)=>{fs.readFile(filename,binary?undefined:"utf8",(err,data)=>{if(err)reject(err);else resolve(binary?data.buffer:data)})})};if(!Module["thisProgram"]&&process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=url=>{if(isFileURI(url)){return new Promise((resolve,reject)=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=()=>{if(xhr.status==200||xhr.status==0&&xhr.response){resolve(xhr.response);return}reject(xhr.status)};xhr.onerror=reject;xhr.send(null)})}return fetch(url,{credentials:"same-origin"}).then(response=>{if(response.ok){return response.arrayBuffer()}return Promise.reject(new Error(response.status+" : "+response.url))})}}}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];var wasmBinary=Module["wasmBinary"];var wasmMemory;var ABORT=false;var EXITSTATUS;function assert(condition,text){if(!condition){abort(text)}}var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b)}var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;function preRun(){var preRuns=Module["preRun"];if(preRuns){if(typeof preRuns=="function")preRuns=[preRuns];preRuns.forEach(addOnPreRun)}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;if(!Module["noFSInit"]&&!FS.initialized)FS.init();FS.ignorePermissions=false;TTY.init();callRuntimeCallbacks(__ATINIT__)}function postRun(){var postRuns=Module["postRun"];if(postRuns){if(typeof postRuns=="function")postRuns=[postRuns];postRuns.forEach(addOnPostRun)}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function getUniqueRunDependency(id){return id}function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";if(runtimeInitialized){___trap()}var e=new WebAssembly.RuntimeError(what);readyPromiseReject(eLine truncated
return moduleRtn;
}
);
})();
if (typeof exports === 'object' && typeof module === 'object')
module.exports = occtimportjs;
else if (typeof define === 'function' && define['amd'])
define([], () => occtimportjs);
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
/**
* @license
* Copyright 2010-2021 Three.js Authors
* SPDX-License-Identifier: MIT
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).THREE={})}(this,(function(t){"use strict";const e="128",n=100,i=300,r=301,s=302,a=303,o=304,l=306,c=307,h=1e3,u=1001,d=1002,p=1003,m=1004,f=1005,g=1006,v=1007,y=1008,x=1009,_=1012,w=1014,b=1015,M=1016,S=1020,T=1022,E=1023,A=1026,L=1027,R=33776,C=33777,P=33778,D=33779,I=35840,N=35841,B=35842,z=35843,F=37492,O=37496,H=2300,G=2301,U=2302,k=2400,V=2401,W=2402,j=2500,q=2501,X=3e3,Y=3001,Z=3007,J=3002,Q=3004,K=3005,$=3006,tt=7680,et=35044,nt=35048,it="300 es";class rt{addEventListener(t,e){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[t]&&(n[t]=[]),-1===n[t].indexOf(e)&&n[t].push(e)}hasEventListener(t,e){if(void 0===this._listeners)return!1;const n=this._listeners;return void 0!==n[t]&&-1!==n[t].indexOf(e)}removeEventListener(t,e){if(void 0===this._listeners)return;const n=this._listeners[t];if(void 0!==n){const t=n.indexOf(e);-1!==t&&n.splice(t,1)}}dispatchEvent(t){if(void 0===this._listeners)return;const e=this._listeners[t.type];if(void 0!==e){t.target=this;const n=e.slice(0);for(let e=0,i=n.length;e<i;e++)n[e].call(this,t);t.target=null}}}const st=[];for(let t=0;t<256;t++)st[t]=(t<16?"0":"")+t.toString(16);let at=1234567;const ot=Math.PI/180,lt=180/Math.PI;function ct(){const t=4294967295*Math.random()|0,e=4294967295*Math.random()|0,n=4294967295*Math.random()|0,i=4294967295*Math.random()|0;return(st[255&t]+st[t>>8&255]+st[t>>16&255]+st[t>>24&255]+"-"+st[255&e]+st[e>>8&255]+"-"+st[e>>16&15|64]+st[e>>24&255]+"-"+st[63&n|128]+st[n>>8&255]+"-"+st[n>>16&255]+st[n>>24&255]+st[255&i]+st[i>>8&255]+st[i>>16&255]+st[i>>24&255]).toUpperCase()}function ht(t,e,n){return Math.max(e,Math.min(n,t))}function ut(t,e){return(t%e+e)%e}function dt(t,e,n){return(1-n)*t+n*e}function pt(t){return 0==(t&t-1)&&0!==t}function mt(t){return Math.pow(2,Math.ceil(Math.log(t)/Math.LN2))}function ft(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}var gt=Object.freeze({__proto__:null,DEG2RAD:ot,RAD2DEG:lt,generateUUID:ct,clamp:ht,euclideanModulo:ut,mapLinear:function(t,e,n,i,r){return i+(t-e)*(r-i)/(n-e)},inverseLerp:function(t,e,n){return t!==e?(n-t)/(e-t):0},lerp:dt,damp:function(t,e,n,i){return dt(t,e,1-Math.exp(-n*i))},pingpong:function(t,e=1){return e-Math.abs(ut(t,2*e)-e)},smoothstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*(3-2*t)},smootherstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*t*(t*(6*t-15)+10)},randInt:function(t,e){return t+Math.floor(Math.random()*(e-t+1))},randFloat:function(t,e){return t+Math.random()*(e-t)},randFloatSpread:function(t){return t*(.5-Math.random())},seededRandom:function(t){return void 0!==t&&(at=t%2147483647),at=16807*at%2147483647,(at-1)/2147483646},degToRad:function(t){return t*ot},radToDeg:function(t){return t*lt},isPowerOfTwo:pt,ceilPowerOfTwo:mt,floorPowerOfTwo:ft,setQuaternionFromProperEuler:function(t,e,n,i,r){const s=Math.cos,a=Math.sin,o=s(n/2),l=a(n/2),c=s((e+i)/2),h=a((e+i)/2),u=s((e-i)/2),d=a((e-i)/2),p=s((i-e)/2),m=a((i-e)/2);switch(r){case"XYX":t.set(o*h,l*u,l*d,o*c);break;case"YZY":t.set(l*d,o*h,l*u,o*c);break;case"ZXZ":t.set(l*u,l*d,o*h,o*c);break;case"XZX":t.set(o*h,l*m,l*p,o*c);break;case"YXY":t.set(l*p,o*h,l*m,o*c);break;case"ZYZ":t.set(l*m,l*p,o*h,o*c);break;default:console.warn("THREE.MathUtils: .setQuaternionFromProperEuler() encountered an unknown order: "+r)}}});class vt{constructor(t=0,e=0){this.x=t,this.y=e}get width(){return this.x}set width(t){this.x=t}get height(){return this.y}set height(t){this.y=t}set(t,e){return this.x=t,this.y=e,this}setScalar(t){return this.x=t,this.y=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;default:throw new Error("index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;default:throw new Error("index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y)}copy(t){return this.x=t.x,this.y=t.y,this}add(t,e){return void 0!==e?(console.warn("THREE.Vector2: .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(t,e)):(this.x+=t.x,this.y+=t.y,this)}addScalar(t){return this.x+=t,this.y+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this}sub(t,e){return void 0!==e?(console.warn("THREE.Vector2: .sub() now only accepts one argument. Use .subVectors( a, b ) instead."),this.subVectors(t,e)):(this.x-=t.x,this.y-=t.y,this)}subScalar(t){return this.x-=t,this.y-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this}multiply(t){return this.x*=t.x,this.y*=t.y,this}multiplyScalar(t){return this.x*=t,this.y*=t,this}divide(t){return this.x/=t.x,this.y/=t.y,this}divideScalar(t){return this.multiplyScalar(1/t)}applyMatrix3(t){const e=tLine truncated
+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;