An EmDash CMS plugin that embeds interactive 3D previews of STL and 3MF files inside Portable Text content. Drop a 3D Model block into any post or page, pick a file (or paste a URL), and get a touch-friendly orbit viewer that loads three.js lazily on viewport entry.
- STL and 3MF — both formats supported. Format is auto-detected from the URL's file extension (
.stl/.3mf); set an explicit override in the block form for opaque URLs. - Lazy three.js — the library (~170 KB gzipped) downloads only when a viewer is scrolled near; pages with no model embeds pay nothing.
- Lazy 3MF decoder — the
3MFLoader(plus the bundled fflate zip decoder) loads on-demand the first time a 3MF URL is encountered. Pages with STL-only content never pay the 3MF cost. - Shared module — additional viewers on the same page reuse the cached module, only paying for their own scene.
- Five materials — matte plastic, glossy plastic, brushed metal, surface-normal rainbow, unlit clay. The configured material is applied uniformly to 3MF imports (the file's embedded materials are intentionally overridden so blocks render consistently with STL).
- Three sizes — compact / standard / tall, all responsive.
- Auto-rotate on idle — pauses on user input, resumes after a moment.
- Touch support — pinch-zoom, two-finger pan, one-finger orbit.
- Progress UI — bytes/total during model download, indeterminate while three.js loads.
- Auto Z-up detection — many printable STLs and 3MFs ship Z-up; the viewer rotates them so Y is up.
- Auto-framing — the model is recentered around the origin and the camera distance is derived from the bounding sphere and FOV.
pnpm add emdash-plugin-stl-viewer three
# (peers: astro, emdash, three)Register it in astro.config.mjs:
import { stlViewerPlugin } from "emdash-plugin-stl-viewer";
export default defineConfig({
integrations: [
emdash({
plugins: [stlViewerPlugin()],
// ...
}),
],
});That's it. The block type stl-viewer (labeled "3D Model (STL or 3MF)" in the slash menu) is now available in the Portable Text editor.
In the editor, type / and pick 3D Model (STL or 3MF). Fill in:
| Field | Description |
|---|---|
| Model file | Opens the EmDash media picker filtered to model/* MIME types. Pick an existing asset, upload a new .stl/.3mf, or paste a URL directly into the input. |
| Format | Auto-detect from URL (default) / STL / 3MF. Override when the URL has no extension. |
| Title | Optional header text. |
| Caption | Optional figcaption below the viewer. |
| Material | Matte plastic, glossy plastic, brushed metal, normal map, or unlit clay. |
| Color | Hex color for matte/glossy/metal materials. Ignored by normal. |
| Viewer height | Compact (320 px), Standard (440 px), Tall (560 px). |
| Auto-rotate when idle | Off if you'd rather have a static front view. |
| Show ground plane | Subtle grid beneath the model. |
The block's Model file field uses EmDash's media_picker with mime_type_filter: "model/". In the popup you can:
- Select an existing asset (filtered to
model/*MIME types). - Upload a new
.stlor.3mf— the file dialog'sacceptis scoped tomodel/, and EmDash's/_emdash/api/media/upload-urlendpoint passes the MIME type through to R2 without a global allowlist. - Paste a URL directly into the input — the field value is a plain string, so external URLs (
https://…) work too.
Browsers may not recognise .stl / .3mf from extension alone (the Content-Type arrives as application/octet-stream). EmDash now retains the upload's original Content-Type header, but if you're hosting your own asset server, set Content-Type: model/stl or model/3mf explicitly. The viewer itself does not require a particular MIME type — it sniffs the URL's extension or the explicit format override.
The plugin is native format — that's required for Portable Text block types because they need Astro components for site-side rendering.
src/
├── index.ts # PluginDescriptor + definePlugin() (Native format)
└── astro/
├── index.ts # Exports `blockComponents` map
├── StlViewer.astro # Server-rendered placeholder + scoped CSS
└── viewer-client.ts # Lazy three.js + 3MF init, OrbitControls, lifecycle
The Astro component renders a placeholder card with a CSS-animated phantom cube. When the wrapper enters the viewport (IntersectionObserver, 400 px root margin), the client script:
- Dynamically imports
three,three/addons/controls/OrbitControls.js, andthree/addons/loaders/STLLoader.js(memoised — one fetch per page). - If the URL ends in
.3mf(orformat === "3mf"), additionally importsthree/addons/loaders/3MFLoader.jslazily. - Streams the model via
fetchwith a progress callback so large files show byte counts. - STL: parses to a single
BufferGeometry, recenters it, computes smooth-shaded normals. 3MF: parses to aGroup(potentially multi-mesh), replaces embedded materials with the configured material, repositions the group's centroid to the origin. - Mounts a
WebGLRenderercanvas inside the stage, attachesOrbitControls. - Hooks up
ResizeObserverso the renderer follows container resizes, and a secondIntersectionObserverto pause the render loop when offscreen.
The canvas element is injected by three.js at runtime, so it doesn't carry the data-astro-cid-* attribute Astro adds to elements declared inside the component. Any selector targeting the canvas must use :global(canvas), or Astro's scope rewrite will silently leave the runtime element unstyled. The viewer hits this in two places — both are wrapped accordingly in StlViewer.astro.
Built via Vite (Astro):
| Chunk | Approx size | When loaded |
|---|---|---|
StlViewer.astro_…js |
~7 KB | Eagerly with the page that has a viewer |
three.module.js |
~690 KB | First viewer hits viewport |
OrbitControls.js |
~19 KB | First viewer hits viewport |
STLLoader.js |
~3 KB | First viewer hits viewport |
3MFLoader.js (+ fflate) |
~50 KB | Only on the first 3MF viewer on a page |
pnpm install
pnpm generate-fixtures # writes cube.stl / icosahedron.stl / torus.stl / knurled.stl to public/stls/ of the host repo
pnpm typecheck- Astro
>= 6.0 - EmDash
>= 0.12(uses Block Kit'smedia_pickerelement withmime_type_filter). three^0.171.0
- 0.3.0 — Switch the Model URL field to a
media_pickerfiltered tomodel/*MIME types. Authors get a unified pick/upload/paste-URL popup in the block form. Bumps the required EmDash version to>= 0.12. Existing content is unchanged — the field value is still a plain URL string. - 0.2.0 — Add 3MF support alongside STL.
3MFLoader(plus the bundled fflate zip decoder) lazy-loads only when a 3MF URL is encountered. - 0.1.0 — Initial release. STL viewer block with five materials, three sizes, auto-rotate, touch support, and a lazy three.js bundle.
MIT — see LICENSE.
