Felipe Martín

Adding STL 3D Previews to Forgejo

I run a self-hosted Forgejo 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.

Codeberg (which runs on Forgejo) had a nice 3D preview for STL files 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.

Forgejo supports custom markup renderers 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.
  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.

Step 1: Register STL as a markup format

Forgejo can be configured via environment variables or an app.ini file, since my setup is docker based I’m going to use the FORGEJO__section__KEY=value environment variables format. To register a custom markup handler for STL files:

1
2
3
4
5
6
7
8
# docker-compose.yml
# ...
environment:
  - FORGEJO__markup.stl__ENABLED=true
  - FORGEJO__markup.stl__FILE_EXTENSIONS=.stl
  - FORGEJO__markup.stl__RENDER_COMMAND=echo
  - FORGEJO__markup.stl__IS_INPUT_FILE=false
# ...

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.

Step 2: Custom templates

Forgejo lets you inject content into every page via custom header and footer templates.

We need two things: hide the empty markup output while the viewer loads, and load the viewer script.

Header template

The header runs early, before the page content renders. We use it to detect STL pages and hide the default file view:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<!-- custom/templates/custom/header.tmpl -->
<script>
if (window.location.pathname.toLowerCase().endsWith('.stl'))
  document.documentElement.classList.add('stl-page');
</script>
<style>
.stl-page .file-view > * {
  display: none;
}

.stl-page .file-view::before {
  content: "Loading 3D view...";
  padding: 3em;
  text-align: center;
  color: #888;
  font-size: 1.2em;
  display: block;
}
</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 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.

The footer loads the viewer script as an ES module:

1
2
<!-- custom/templates/custom/footer.tmpl -->
<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.

Step 3: The Three.js viewer

The viewer script (stlview.js) is based on Codeberg’s implementation. It uses Three.js r160 with ES modules, bundled locally so there’s no CDN dependency.

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.

The core of the loading logic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Build raw URL by replacing /src/ with /raw/
var raw_url = path.replace(/\/src\//, '/raw/');

// Find the file content container to replace
var container = document.querySelector('.file-view');

// Load Three.js modules dynamically
var THREE = await import("./three.js/build/three.module.min.js");

// https://github.com/mrdoob/three.js/blob/dev/examples/jsm/loaders/STLLoader.js
var STL = await import("./three.js/examples/jsm/loaders/STLLoader.js");

// https://github.com/mrdoob/three.js/blob/dev/examples/jsm/controls/TrackballControls.js
var TBC = await import("./three.js/examples/jsm/controls/TrackballControls.js");

// Load and render the STL
(new STL.STLLoader()).load(raw_url, function (geometry) {
  // ... scene setup, camera positioning, animation loop
});

The file structure for the assets:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
custom/public/assets/
├── stlview.js
└── three.js/
    ├── build/
    │   └── three.module.min.js
    └── examples/jsm/
        ├── loaders/
        │   └── STLLoader.js
        └── controls/
            └── TrackballControls.js

Result

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.