Add local 3D model viewer (three.js + occt-import-js) for STEP/STL, 8px box + caption; embed guide; licenses
This commit is contained in:
@@ -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/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 |
|
| `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/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>`) |
|
| `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 |
|
| `templates/blog_article.html` | `Blog Article` | article template with promoted labels |
|
||||||
| `examples/*` | ignis8 icons/script | sample category logo |
|
| `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 }`.
|
- 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
|
||||||
|
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
|
- 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
|
||||||
@@ -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
|
`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
|
backend script is impossible** without patching Trilium source. Do not attempt to add
|
||||||
sharp-based code to the generator.
|
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
|
- 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
|
by the self-trigger guard (`isGeneratedNoteId(BLOG_ROOT_ID) → true`). To force a full
|
||||||
regeneration, change an attribute/content on a **published article** instead.
|
regeneration, change an attribute/content on a **published article** instead.
|
||||||
|
|||||||
@@ -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**.
|
- **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
|
- **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
|
||||||
|
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.
|
- 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
|
||||||
@@ -55,9 +59,11 @@ no external dependencies (except `highlight.js`, vendored in `lib/`).
|
|||||||
| `theme/favicon.svg` | default site favicon | code (SVG) |
|
| `theme/favicon.svg` | default site favicon | code (SVG) |
|
||||||
| `theme/cookie.woff2` | "Cookie" font for the Buy me a coffee button | file, `font/woff2` |
|
| `theme/cookie.woff2` | "Cookie" font for the Buy me a coffee button | file, `font/woff2` |
|
||||||
| `lib/highlight.js` | vendored highlight.js | code, `application/javascript` |
|
| `lib/highlight.js` | vendored highlight.js | code, `application/javascript` |
|
||||||
|
| `lib/3d/*` | three.js + OrbitControls + STLLoader + occt-import-js (WASM) for the 3D viewer | code / file |
|
||||||
| `lib/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 |
|
||||||
| `templates/blog_article.html` | article template with promoted labels | text |
|
| `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 |
|
| `examples/*` | sample category logo + menu icon script (ignis8) | code / SVG |
|
||||||
| `generated/*` | reference output, **regenerated automatically** by `blog_generator.js` | code / xml |
|
| `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` |
|
| `search header` | `scripts/search_header.js` | `text/html` |
|
||||||
| `note fallback` | `scripts/note_fallback.js` | `text/html` |
|
| `note fallback` | `scripts/note_fallback.js` | `text/html` |
|
||||||
| `menu date sort` | `scripts/menu_date_sort.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) |
|
| `feed.xml` | `generated/feed.xml` | code (regenerated) |
|
||||||
| `sitemap.xml` | `generated/sitemap.xml` | code (regenerated) |
|
| `sitemap.xml` | `generated/sitemap.xml` | code (regenerated) |
|
||||||
| `state_map` | `generated/state_map.js` | `text/html` (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` | – |
|
| `iconClass` | `bx bx-edit-alt` | – |
|
||||||
| `~shareCss` | → `blog_share_theme.css` | yes |
|
| `~shareCss` | → `blog_share_theme.css` | yes |
|
||||||
| `~shareFavicon` | → `favicon` | 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) |
|
| `~shareHtml` | → `hide home title` | **no** (index only) |
|
||||||
| `~runOnNoteContentChange`, `~runOnAttributeChange`, `~runOnNoteCreation`, `~runOnNoteTitleChange`, `~runOnAttributeCreation`, `~runOnBranchDeletion`, `~runOnChildNoteCreation` | → `blog_generator` | yes |
|
| `~runOnNoteContentChange`, `~runOnAttributeChange`, `~runOnNoteCreation`, `~runOnNoteTitleChange`, `~runOnAttributeCreation`, `~runOnBranchDeletion`, `~runOnChildNoteCreation` | → `blog_generator` | yes |
|
||||||
| `~runOnAttributeChange`, `~runOnAttributeCreation` | → `blog_publish_manager` | 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_ID__` | `highlight.js` |
|
||||||
| `__HLJS_LN_ID__` | `hljs line numbers plugin` |
|
| `__HLJS_LN_ID__` | `hljs line numbers plugin` |
|
||||||
| `__COOKIE_FONT_ID__` | `Cookie` |
|
| `__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) |
|
| `__TITLE_ICON_ID__` | (only needed for the `ignis8` example) |
|
||||||
| `__CATEGORY_NOTE_ID__` | (only needed for the `ignis8` example) |
|
| `__CATEGORY_NOTE_ID__` | (only needed for the `ignis8` example) |
|
||||||
|
|
||||||
|
|||||||
@@ -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) |
|
| `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/) |
|
| `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) |
|
| `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) |
|
| `scripts/bmc_button.js` | embeds the Trilium logo (SVG) from [Trilium/TriliumNext](https://github.com/TriliumNext/Trilium) | [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.txt) |
|
||||||
|
|
||||||
## BSD-3-Clause
|
## BSD-3-Clause
|
||||||
@@ -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".
|
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
|
## AGPL-3.0
|
||||||
|
|
||||||
The Trilium logo embedded in `scripts/bmc_button.js` belongs to the Trilium /
|
The Trilium logo embedded in `scripts/bmc_button.js` belongs to the Trilium /
|
||||||
|
|||||||
@@ -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
@@ -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.
Vendored
+6
File diff suppressed because one or more lines are too long
@@ -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>
|
||||||
@@ -808,6 +808,56 @@ html.theme-light #content code {
|
|||||||
border-radius: 8px;
|
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) ---------- */
|
/* ---------- Bildunterschriften (Gruvbox) ---------- */
|
||||||
#content figcaption {
|
#content figcaption {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
|
|||||||
Reference in New Issue
Block a user