diff --git a/content/blog/2026/03/15/adding-stl-3d-previews-to-forgejo/index.md b/content/blog/2026/03/15/adding-stl-3d-previews-to-forgejo/index.md index 2cd8358..e9a52bf 100644 --- a/content/blog/2026/03/15/adding-stl-3d-previews-to-forgejo/index.md +++ b/content/blog/2026/03/15/adding-stl-3d-previews-to-forgejo/index.md @@ -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. +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')) ``` -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: ``` -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 = '
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.
diff --git a/content/blog/2026/03/15/adding-stl-3d-previews-to-forgejo/screenshot.png b/content/blog/2026/03/15/adding-stl-3d-previews-to-forgejo/screenshot.png
index 9ef1845..4e9934d 100644
Binary files a/content/blog/2026/03/15/adding-stl-3d-previews-to-forgejo/screenshot.png and b/content/blog/2026/03/15/adding-stl-3d-previews-to-forgejo/screenshot.png differ