--- title: SPOLIA — How It Works created: 2026-09-25 12:57 updated: 2026-09-25 12:57 revision: 1 author: Auriea summary: "New page: technical description of SPOLIA" --- [[SPOLIA]] What [[SPOLIA]] is and how it works inside. For how to *use* it, see [[SPOLIA-UserGuide]]; for the supports in depth, [[SPOLIA-Supports]]. ## In one paragraph SPOLIA is a single HTML file — `TROY/SPOLIA/index.html`, about 14,000 lines, WebGL2, no libraries, no build step — that composes sculpture out of **signed distance fields** rather than meshes. Every fragment, whether a museum scan or a plain cube, is stored as a 3D grid of distances to its own surface. Placing, turning, scaling, welding and cutting are all arithmetic on those fields, evaluated per pixel by a raymarching shader, every frame. *"The field is the object: nothing meshes until export."* A triangle mesh exists exactly once, at the end, when a piece is exported to print. ## Fragments: the baked field A fragment is a **16-bit greyscale PNG** that is really a volume. The 3D grid is sliced along Z and the slices are laid out as a tiled atlas; each pixel is a distance, normalised into 0–65535 (negative inside). Everything needed to read it back — voxel dimensions, atlas grid, bounding box, distance range, voxel size, source file — travels inside the PNG in a `tEXt` chunk keyed `spolia`, so a fragment is one self-describing file. (Old fragments with a `.json` sidecar still load.) There are two bakers, producing the same format: - **In the browser.** Drop an `.stl`, `.obj` or `.glb` onto the page. A Web Worker bakes it at 256 voxels along the longest axis: exact distances in a band around the surface, a chamfer sweep beyond it. Closed meshes are signed as solids by an inside/outside vote along three axes; open scans become a thickened shell. The bake records its baker version, so an out-of-date bake is recut automatically the next time the mesh is dropped. - **Offline, in Python** (`SPOLIA/baker/bake.py`, with `batch_bake.py`, `make_primitives.py`, `verify.py`, `contact_sheet.py`). Up to 512 voxels, winding-number signing via libigl, `--mode solid|shell|auto`, and `--up z` to stand up Z-up scans and find their front. Meshes are normalised into a unit box first, because half-float textures lose precision below about a thousandth. The browser's own PNG decoder flattens to 8 bits, so SPOLIA carries its own: it inflates the image data with `DecompressionStream`, undoes the scanline filters and keeps all sixteen bits. Each fragment is then uploaded as an `R16F` **3D texture** with linear filtering — the GPU's trilinear interpolation is what turns the grid back into a continuous field. **The library.** A handful of primitives ship with the app (`fragments/index.json`: cube, sphere, cylinder, pyramid, four-headed). The shared library — over eighty scanned pieces — lives in Dropbox at `/TROY/SPOLIA/fragments/library`, reached by OAuth PKCE sign-in, and anything you import is also kept in the browser's IndexedDB. On first import SPOLIA guesses which way is up from the flattest broad face and asks you to confirm. ## A piece on the stage A placed piece — an *instance* — is a reference to a fragment plus: position; an orientation quaternion `q` and a separate "rest" rotation `qR` that the *apply* action folds rotation into; a positive scale per axis; a separate ±1 mirror per axis, so flipping never makes a scale negative; a combine operation and its blend radius `k` and count `n`; and a material with its grain scale, a random block offset (the "slab"), and a bleed width. The pivot is the fragment's box centre. **The stage holds eight pieces.** That is a compile-time limit, not a preference: the shader declares eight 3D-texture samplers and generates eight sampling functions, one per slot. ## Combining: the scene field The shader walks the pieces in stacking order, folding each into a running distance: | op | name | how | |---|---|---| | 0 | union | `min` | | 1 | smooth union | polynomial smooth-min, radius `k` | | 2 | subtract | `max(acc, −d)` | | 3 | intersect | `max` | | 4–9 | chamfer · columns · stairs · groove · engrave · weld | the joint profiles, after Mercury's *hg_sdf* library; columns and stairs take a count `n` | Before sampling a piece's texture it tests the piece's bounding box: if even the nearest possible point of that piece could not change the running result, the texture fetch is skipped. That one test is most of what keeps eight volumes interactive. One rule is written into the code in capitals. Every smooth-min that might meet the "nothing yet" value of `1e9` goes through a bounded helper, never `mix()` — because Direct3D, and Metal's fast-math, fold `mix(1e9, b, 1)` to zero, which on Windows turned every supported piece into a solid disk. The support forest is folded in last, as one more smooth union (see below). A **CPU twin** of the pieces' field, `mapWorld`, is kept line-for-line in step with the shader; it answers picking (which piece did you tap?) and drives support growth. ## Drawing it **Marching.** Each pixel's ray is clipped to the scene's bounding sphere, then sphere-traced with **over-relaxation** (after Keinert et al.): it tries a step 1.3× larger than the distance says is safe, and falls back to the safe step if consecutive spheres stop overlapping. At most 260 steps. Normals take six field samples; shadows are a 40-step soft shadow toward the sun with a per-pixel jitter to break up banding. Ambient occlusion is not a separate pass at all — it is read off *how many steps the march took*, since a ray that had to creep is a ray threading a crevice. **The deferred renderer** (since 24 September 2026). The single all-in-one shader proved impossible on Windows: Direct3D inlines every function call, so the ~33 KB stone library was pasted into the program about twenty times, and linking took between two and twelve minutes, or failed. The frame is now built in passes: 1. **G** — geometry only: march, normal, shadow, as one small state machine with a single call to the field. 2. **GW** — which materials each pixel is made of, and how much of each. 3. **M** — one small program *per material kind*, each adding its contribution. 4. **G2** — a look-through pass for glass, only when there is glass. 5. **C** — the composite: lighting, fog, dither. The geometry program comes in variants sized to the scene (1, 2, 4 or 8 pieces; with or without supports), and programs compile lazily and in the background — one at a time behind a *preparing the renderer* card on browsers without parallel shader compilation. The old single-pass shader is still there and takes over if the deferred build fails. **Only when needed.** Nothing redraws unless something changed. Resolution adapts: when frames run slow the drawing buffer shrinks (down to a quarter), and it climbs back only after a run of on-time frames; 200 ms after you stop, one full-resolution frame is drawn. A hard ceiling of four million pixels on the buffer keeps Windows' GPU watchdog from resetting the driver. `?sharp` pins full resolution. ## Stone The 28 materials are **solid 3D textures**, evaluated in each fragment's *local* space — so the veins travel with the piece as it moves and turns, and a cut reveals the inside of the block rather than a painted skin. They come from [[METOPE]] and [[LAPIDEO]]: - **Marbles and stones** — carrara, bardiglio, nero, portoro, rosso, pavonazzetto, pavonazzo, portasanta, emperador, cipollino, verde, africano, porfido, onice, miele, sodalite — built from hash noise, fractal noise, vein functions, Voronoi cells and speckle. - **Woods** — oak, walnut, cherry, maple, ebony — growth rings as cylinders around an off-centre pith. - **Others** — plaster, gold, bronze (verdigris gathering in the hollows, metal and roughness varying across the surface), silver, glass (the only transparent one), blood, and plain. Where pieces meet, each piece's stone is weighted by how close the point is to *its* surface, so one marble bleeds into the next over a controllable width. A separate **joint material** can paint every seam in the composition. **Light** is one sun (the draggable ball), a fixed fill, and a sky term; two specular lobes; an environment colour for metals and a capped Fresnel sky reflection on polished stone. The floor is an anti-aliased checker with the dashed survey ring at the origin. ## The console The interface is drawn *by the same GPU, in the same stone* — there is no HTML toolbar. Every control is a "housing": a small raymarched slab or ball with glyphs cut into it, lit by the scene's sun, carved in whichever of the 28 materials is chosen as the **skin** (plaster by default). The shelf's plaques are rendered portraits of each fragment packed into a thumbnail atlas. The collar rings around the selected piece and the manipulator are rebuilt every frame. Picking the interface and picking the scene share one pointer system: interface handles win, except over the material ball and the menu. ## Supports, briefly `growSupportForest()` finds every face leaning past 45°, thins those points into evenly spaced tips, and lets each one **descend** — never leaning more than 35° from vertical — merging with its neighbours on the way down, thickening by the pipe model (radius ∝ tips^(1/2.5)). The skeleton is then relaxed as a graph under a cone constraint and cut into capsules: up to 136 of them, 160 slots in the shader including extras. A forest can anchor to the **floor**, a **wall** behind the work, or both. Besides the legs there are **ties** (horizontal braces between legs), **connectors** (short bars bridging near-touching parts of the piece itself), **wedges** (pads under low overhangs), cloth **spans**, **lion's paws** on pelt feet, **lopped stubs** on trunks, and **ornaments** — any fragment repeated up to 128 times on the supports, its transforms in a data texture. The style — *smooth, trunk, vine, pelt, cloth, strut, spiral strut* — is carving in the shader, not geometry: the same capsules wear a different surface. The strut style takes a section (square / hexagonal / flat slab); cloth takes a cover (falls / half / shroud). All of that is on [[SPOLIA-Supports]]. For speed the forest has two accelerations: 32 bounding spheres over runs of capsules, so a ray can reject most of the forest at once; and a **baked forest** — the whole forest sampled into its own 3D texture, used far from the surface, with the exact analytic capsules taking over near it. A fingerprint of everything that shapes the forest tells the app when the bake is stale. ## Files and sharing - **Composition** — a small JSON: the fragments by name and source, the instances, the joint, the support record, the ornament, the camera. Fragments are referenced, not embedded. - **`.spolia` project** — a ZIP (written by the app itself, stored uncompressed) of `composition.json`, `preview.jpg` and `fragments/*.png`: the arrangement and its stones together, so it opens anywhere. Saved to IndexedDB and to Dropbox `/TROY/SPOLIA/projects`. - **Autosave** — the composition JSON in `localStorage`, 700 ms after the last edit. - **Undo** — 60 snapshots of the edit state (never the camera), taken 450 ms after an edit settles. - **Share** — any fragment the server lacks is posted to `fragment.php`, which checks it really is a SPOLIA atlas and stores it content-addressed as `f/.png`; the composition goes to `share.php`, stored as `c/.json`. Both are write-once. The link is `auriea.art/spolia/?comp=c/.json`. Only same-origin `?comp=`/`?frag=` paths are honoured, and every name from a link is escaped before it touches the page — a shared link could otherwise inject script into the origin that holds the Dropbox token. ## Export All four exports start from the same field. **Meshing.** The field — pieces *and* supports — is sampled on the GPU into float textures, four Z slices a pass, clipped at the floor and at the wall. The mesher is **surface nets with manifold per-component vertices** (after Schaefer et al., *Manifold Dual Contouring*): where one grid cell holds two separate sheets of surface, it gets two vertices, and those are nudged a sixth of a cell apart so a slicer does not weld them back into a non-manifold pinch. Cloth is held to a minimum 0.8 mm wall so it survives printing. 1. **STL** — binary, in millimetres at the chosen printed size (40–300 mm) and detail (128 / 256 / 384). 2. **GLB** — the same mesh with per-vertex colour from a GPU pass that evaluates the actual stone at each vertex, and ambient occlusion baked from the field. Roughness and metalness ride on a small trick: a 256×256 **ramp texture** whose green channel runs 0→1 across and blue 0→1 down, with each vertex's UV pointing at its own (roughness, metal) — so the renderer's own interpolation does the per-vertex blending. Occlusion reads the same image's red channel through a second UV set. Passes the Khronos glTF validator. 3. **Web viewer** — one self-contained HTML file that *raymarches the real atlases*, downsampled and embedded, with the supports' bake. The shader is specialised to the scene first: unused piece slots, unused stones and glass are compiled out. 4. **Baked web viewer** — the finished mesh, quantised (16-bit positions, 8-bit normals, colour, roughness, metal, occlusion) and compressed into one HTML file with a small shader that copies SPOLIA's lighting and a 2048 shadow map. Opens in under a second. There is no 3MF export. ## Getting it onto a phone Everything is sized from `visualViewport` through two helpers, `viewW()`/`viewH()`, the only places the window size is read; the page uses `viewport-fit=cover`, the `100dvh` chain, and reads all four safe-area insets through a hidden probe. (See `TROY/CLAUDE.md` on the blank band at the bottom of full-screen apps on iOS — SPOLIA is where that fix was worked out.) **Build stamp.** `deploy-spolia.sh` stamps the build time into the copy it uploads, syntax-checks every script block with `node --check`, uploads to `web/spolia/index.html` and compares the live md5. The running page asks the server for its file date whenever the tab comes back to the front, and says so on the shelf if a newer one is up. ## Tools around it - `spolia-mesh-check.js` — counts open edges, over-shared edges, degenerate triangles, bad normals and colours in an exported GLB or STL. - `spolia-rebuild-export.js` — lifts the viewer builder out of `index.html` and re-emits an old export's payload through the current code. - `spolia-link-probe.js` — links one shader variant per page load and reports each step back through the server log; how the Windows link failures were found. - Debug switches, from the console: `PERF.*` (no shadow, no supports, march-step heatmap, capsule-evaluation counts, raw shadow), `?render=single`, `?noprewarm`, `?nobake`, `?sharp`. The "i" diagnostics button in the web viewers reports the renderer and reads five pixels of the actual render twice — with and without the forest — which is how a GPU that draws the supports wrong can be told apart from one that draws everything wrong. [[tag:ai]]