FMG-10: correct the STL previews guide and point it at forgejo-stlview #5

Open
butterrobot wants to merge 3 commits from butterrobot/fmg-10-stl-guide-corrections into master AGit
2 changed files with 46 additions and 13 deletions

View file

@ -4,17 +4,19 @@ date = 2026-03-15
tags = ["forgejo", "homelab", "3d-printing"]
+++
I run a self-hosted [Forgejo](https://forgejo.org/) instance where I keep my repositories, including now some 3D printing projects I have started along with STL files (and SCAD files where applicable). By default Forgejo treats STL files as plain text which means browsing one gives you a wall of ASCII coordinates and makes the page take ages to load. Not ideal.
I run a self-hosted [Forgejo](https://forgejo.org/) instance where I keep my repositories, including now some 3D printing projects I have started along with STL files (and SCAD files where applicable). By default Forgejo doesn't know what to do with them. An ASCII STL gets served as plain text, so browsing one gives you a wall of coordinates and a page that takes ages to load, and a binary STL just gets you the "this file is binary" placeholder. Neither one is useful.
Codeberg (which runs on Forgejo) [had a nice 3D preview for STL files](https://codeberg.org/Codeberg-Infrastructure/build-deploy-forgejo/src/branch/codeberg-8/etc/gitea/public/assets/stlview.js) and I wanted the same thing for my instance. It turns out you can achieve this without patching Forgejo itself by combining three things: the markup configuration, custom templates, and a bundled Three.js viewer.
<!--more-->
I put everything below into [`forgejo-stlview`](https://git.nakama.town/fmartingr/forgejo-stlview) if you'd rather copy the files than assemble them yourself.
Forgejo supports [custom markup renderers](https://forgejo.org/docs/latest/admin/config-cheat-sheet/#markup-markup) that let you register file extensions and a command to render them. It also allows injecting custom HTML/JS/CSS via `header.tmpl` and `footer.tmpl` templates and serving static assets from a custom public directory.
The plan is:
1. Register `.stl` as a markup format so Forgejo doesn't render it as plain text, showing a placeholder instead.
1. Register `.stl` as a markup format so Forgejo runs it through a renderer that outputs nothing, instead of dumping the file contents on the page.
2. Inject CSS to hide the (now empty) rendered content and a script tag to load the viewer.
3. Have the viewer script detect STL pages, fetch the raw file, and render it with Three.js.
@ -30,10 +32,15 @@ environment:
- FORGEJO__markup.stl__FILE_EXTENSIONS=.stl
- FORGEJO__markup.stl__RENDER_COMMAND=echo
- FORGEJO__markup.stl__IS_INPUT_FILE=false
volumes:
- ./custom/templates:/data/gitea/templates
- ./custom/public:/data/gitea/public
# ...
```
The trick here is using `echo` as the render command. We don't actually want Forgejo to render the STL content, we just need it to recognize the extension so it goes through the markup pipeline instead of being dumped as raw text. The `echo` command produces empty output, which is exactly what we want since our JavaScript viewer will take over.
The two mounts are for the next steps but they go in the same file, so they're here already. Watch the target paths: the custom directory inside the official image is `/data/gitea` and not `/data/forgejo`, which is easy to get wrong. Anything you drop under `custom/public/assets/` gets served at `/assets/`, which is where the script tag in step 2 points.
The trick here is using `echo` as the render command. We don't actually want Forgejo to render the STL content, we just need it to recognize the extension so it goes through the markup pipeline instead of being dumped as raw text. With `IS_INPUT_FILE=false` Forgejo pipes the blob into the command's standard input, and `echo` without arguments ignores stdin and prints a single newline, so the markup output ends up empty. That's exactly what we want since our JavaScript viewer will take over.
## Step 2: Custom templates
@ -67,7 +74,7 @@ if (window.location.pathname.toLowerCase().endsWith('.stl'))
</style>
```
This adds an `stl-page` class to the document root as early as possible, which hides the file view content via CSS and shows a loading placeholder. This prevents any flash of raw content before the viewer kicks in.
This adds an `stl-page` class to the document root as early as possible, which hides the file view content via CSS and shows a loading placeholder. This prevents any flash of raw content before the viewer kicks in. The viewer removes the class again once it's ready to draw, for reasons I'll get to in step 3.
> This was also done at the beginning to let the user know that the STL preview was loading while the entire body of the STL file was loading in the background to the page, before the `markup` configuration in the first step of this guide was applied, but allows for a smoother transition once the viewer is ready.
@ -80,20 +87,25 @@ The footer loads the viewer script as an ES module:
<script src="{{AppSubUrl}}/assets/stlview.js" type="module"></script>
```
The `{{AppSubUrl}}` template variable ensures the path works even if Forgejo is served under a subpath. [`stlview.js`](https://codeberg.org/Codeberg-Infrastructure/build-deploy-forgejo/src/branch/codeberg-8/etc/gitea/public/assets/stlview.js) is the viewer itself, which we add in the next step.
The `{{AppSubUrl}}` template variable ensures the path works even if Forgejo is served under a subpath. `stlview.js` is the viewer itself, which we add in the next step.
## Step 3: The Three.js viewer
The viewer script is based on [Codeberg's `stlview.js`](https://codeberg.org/Codeberg-Infrastructure/build-deploy-forgejo/src/branch/codeberg-8/etc/gitea/public/assets/stlview.js), so start by copying that file into `custom/public/assets/stlview.js`. It uses Three.js r160 with ES modules, bundled locally so there's no CDN dependency.
The viewer started out as [Codeberg's `stlview.js`](https://codeberg.org/Codeberg-Infrastructure/build-deploy-forgejo/src/branch/codeberg-8/etc/gitea/public/assets/stlview.js) but it ended up rewritten enough that you can't drop their copy in here. Grab mine from [`forgejo-stlview`](https://git.nakama.town/fmartingr/forgejo-stlview) instead, at `custom/public/assets/stlview.js`.
The differences matter. Theirs hooks the *view raw* block that Forgejo shows for binary files, while mine keys off the URL and takes over `.file-view`, which is what the markup renderer from step 1 leaves behind. Theirs also pulls in Three.js through the old UMD build and the global `THREE` object, and the module layout below doesn't ship that build at all.
It uses Three.js r160 as ES modules, bundled locally so there's no CDN dependency.
The snippets below are there to explain what the script does, they are not the complete file. Here's the high-level flow:
1. **URL detection**: Check if the current page path ends with `.stl`. If not, bail out.
2. **Raw URL construction**: Replace `/src/` in the URL with `/raw/` to get the actual file download URL.
3. **Scene setup**: Create a Three.js scene with a WebGL renderer, perspective camera.
4. **Auto-fit**: Compute the bounding box of the loaded geometry and position the camera to fit the model.
5. **Lighting**: Three directional lights with shadows plus a hemisphere light for ambient illumination.
6. **Controls**: TrackballControls for rotate/pan/zoom interaction. Double-click (or double-tap on mobile) resets the camera.
3. **Container takeover**: Remove the `stl-page` class and swap the contents of `.file-view` for the viewer's own loading indicator.
4. **Scene setup**: Create a Three.js scene with a WebGL renderer, perspective camera.
5. **Auto-fit**: Compute the bounding box of the loaded geometry and position the camera to fit the model.
6. **Lighting**: Three directional lights with shadows plus a hemisphere light for ambient illumination.
7. **Controls**: TrackballControls for rotate/pan/zoom interaction. Double-click (or double-tap on mobile) resets the camera.
The core of the loading logic:
@ -101,9 +113,13 @@ The core of the loading logic:
// Build raw URL by replacing /src/ with /raw/
var raw_url = path.replace(/\/src\//, '/raw/');
// Find the file content container to replace
// Find the file content container and take it over
var container = document.querySelector('.file-view');
// Drop the class from step 2, or its CSS hides whatever we inject below
document.documentElement.classList.remove('stl-page');
container.innerHTML = '<div id="stl-loading">Loading 3D view\u2026</div>';
// Load Three.js modules dynamically
var THREE = await import("./three.js/build/three.module.min.js");
@ -119,6 +135,8 @@ var TBC = await import("./three.js/examples/jsm/controls/TrackballControls.js");
});
```
That `classList.remove` call is easy to miss and nothing works without it. The CSS from step 2 hides every child of `.file-view`, so if you leave the class in place it hides the canvas you just injected along with everything else and the page sits on the placeholder forever. The two placeholders are meant to hand over to each other: the CSS one covers the page before the script runs, then the script swaps in its own element, which is what the loading percentage writes into.
The file structure for the assets:
```
@ -136,13 +154,28 @@ custom/public/assets/
Where each file comes from:
- `stlview.js`: [Codeberg's version](https://codeberg.org/Codeberg-Infrastructure/build-deploy-forgejo/src/branch/codeberg-8/etc/gitea/public/assets/stlview.js).
- `stlview.js`: [`forgejo-stlview`](https://git.nakama.town/fmartingr/forgejo-stlview). It is derived from Codeberg's version, which is AGPL-3.0, so the viewer carries that license too.
- `three.js/`: the [r160 release](https://github.com/mrdoob/three.js/releases/tag/r160) of Three.js, keeping only the three modules listed above.
The Three.js files come with a catch. The stock `examples/jsm/` modules import the library through the bare specifier `'three'`, which a browser can't resolve without an import map, so both of them need one line changed:
```diff
-} from 'three';
+} from '../../../build/three.module.min.js';
```
`build/three.module.min.js` itself stays untouched. The copies in the repo are already patched, but if you pull them straight from the release this is the step that leaves you with a viewer that never loads and a `Failed to resolve module specifier "three"` sitting in the console.
## What it doesn't cover
The viewer keys off the URL ending in `.stl`, so it only fires on the file view page. Diffs, LFS pointer pages and directory listings are left alone, which means an STL inside a pull request still renders as whatever Forgejo would normally show you.
If a preview gets stuck on *Loading 3D view...*, the browser console is the first place to look. The script logs every step under `[stlview]`, including the raw URL it resolved and the vertex count once the geometry parses.
## Result
<img src="screenshot.png" alt="Preview of an STL file in Forgejo">
After restarting the container, browsing any `.stl` file in the repository shows an interactive 3D preview. You can rotate the model by dragging, zoom with the scroll wheel, pan with right-click drag, and double-click to reset the view. The viewer also shows a loading progress indicator for larger files.
The whole setup is self-contained — no external dependencies (everything is served from the forge), no patches to Forgejo, and it survives upgrades since everything is mounted from outside the container. At least unless Forgejo updates the HTML markup at some point.
The whole setup is self-contained: no external dependencies since everything is served from the forge, no patches to Forgejo, and it survives upgrades because everything is mounted from outside the container. At least until Forgejo changes its HTML markup at some point. `.file-view` is the selector that would break, and it has held up fine through Forgejo 16.0.3, which is what I'm running as of writing this.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Before After
Before After