heerich.js A tiny engine for 3D voxel scenes rendered to SVG
heerich.js is a minimalist JavaScript engine that constructs 3D voxel compositions and distills them into pristine SVG. By extruding volumes, carving negative space, and applying boolean operations, you wield a programmatic chisel—projecting complex spatial arrangements into a flat, resolution-independent vector canvas.
While SVG trades raw frame-rates for architectural elegance, its integration with the DOM is profound. The resulting geometry sits natively within the browser, inviting manipulation through CSS and uncompromised infinite scaling. The output is not an ephemeral pixel buffer, but semantic, stylable, and enduring markup.
The visual language draws deep inspiration from the geometric rigor of Erwin Heerich (1922–2004) — exploring stacked topologies, deliberate subtractions, and the quiet tension that exists between solid mass and absolute void.
This exhibition serves as both a technical manual and an interactive gallery. Engage with the examples below to comprehend the mechanics of the engine one primitive at a time.
Creating an engine
A Heerich instance holds a 3D grid of voxels and knows
how to project them into 2D SVG. You create one with a few options —
grid dimensions, tile size for rendering, and a default style for
every voxel.
import { Heerich } from './src/heerich.js'
const engine = new Heerich({
tile: [30, 30], // pixel size per tile
camera: { type: 'oblique', angle: 315, distance: 25 },
style: { fill: '#ddd', stroke: 'var(--stroke-c)', strokeWidth: 0.5 }
})
All options are optional — new Heerich() works with
sensible defaults. Call engine.toSVG() to get an SVG
string you can drop into the DOM.
The SVG is automatically centered — the engine computes the exact
bounding box of all visible geometry and sets the
viewBox to fit it, with optional padding. No manual
positioning needed, even when shapes extend into negative coordinates.
// Auto-centered with padding
document.body.innerHTML = engine.toSVG({ padding: 30 })
// Access raw bounds if you need them
const { x, y, w, h } = engine.getViewBoxBounds()
Boxes
The simplest primitive is a box — a rectangular solid defined by a corner position and a size. Every voxel inside the box gets created with the default style.
engine.addBox({ position: [0, 0, 0], size: [6, 4, 3] })
Try dragging the sliders — the engine clears and rebuilds each
frame. Because getFaces() uses a dirty-flag cache,
re-rendering after a change is efficient.
Coordinates & alignment
The engine has no concept of a ground plane —
position always refers to the minimum corner of the
shape. To align two boxes of different heights on a shared surface,
you offset the position yourself.
const big = [6, 6, 6], small = [2, 2, 2]
// Align max corners (tops flush)
const max = big.map((b, i) => b - small[i])
// Center (works perfectly when difference is even)
const center = big.map((b, i) => (b - small[i]) / 2)
It's one subtraction per axis — the engine stays simple and you stay in control. For pixel-perfect centering, keep the size difference even on each axis.
Boolean operations
Every primitive accepts a mode option that controls how
it interacts with existing voxels:
- union — add voxels (default)
- subtract — remove voxels
- intersect — keep only the overlap
- exclude — XOR, toggle each voxel
engine.addBox({ position: [0, 0, 0], size: [6, 6, 6] })
// Carve a sphere out of the box
engine.addSphere({
center: [3, 3, 3], radius: 3,
mode: 'subtract'
})
// Keep only where box and sphere overlap
engine.addSphere({
center: [3, 3, 3], radius: 3,
mode: 'intersect'
})
removeBox() and removeSphere() still work
— they're shortcuts for mode: 'subtract'.
Rotation
Every primitive accepts a rotate option for 90°
increments. Specify the axis and number of
turns. The shape rotates around its own center by
default, or a custom center.
// Build one arm, rotate a copy to make an L
engine.addBox({ position: [0, 0, 0], size: [2, 8, 2] })
engine.addBox({
position: [0, 0, 0], size: [2, 8, 2],
rotate: { axis: 'z', turns: 1 }
})
// Rotate all existing voxels in place
engine.rotate({ axis: 'y', turns: 2 })
This makes it easy to build symmetric forms — define one piece, then rotate copies. The Kreuzplastik in the gallery below is built from a single arm rotated twice.
Smooth solids
Every voxel is 1×1×1 and gets its own stroke. To make a
shape look like a single smooth solid, set the
stroke to the same color as the fill — the
internal grid lines disappear.
engine.addBox({
position: [0, 0, 0], size: [4, 4, 4],
style: { default: { fill: '#0e0e0e', stroke: '#0e0e0e' } }
})
The surrounding 1×1×1 voxels keep their strokes, creating a clear contrast between the smooth solid and the grid.
Styling faces
Each voxel has a style per face: top,
front, left, right,
bottom, back, and a
default that applies to all unspecified faces. You can
set styles when adding voxels, or apply them later with
styleBox().
engine.addBox({
position: [0, 0, 0], size: [4, 4, 4],
style: {
default: { fill: '#eee', stroke: '#333' },
top: { fill: '#ff6666' },
front: { fill: '#6666ff' },
right: { fill: '#66ff66' }
}
})
styleBox() applies styles to existing voxels
without creating new ones — useful for painting regions after
construction.
SVG style properties
Style objects map directly to SVG attributes — so anything SVG
understands, you can pass in. This includes opacity,
strokeDasharray, strokeLinecap, or any
other
presentation attribute.
engine.addBox({
position: [0, 0, 0], size: [5, 5, 5],
style: {
default: {
fill: 'var(--fill)',
stroke: 'var(--stroke-c)',
strokeWidth: 'var(--stroke-w)',
opacity: 0.6,
strokeDasharray: '4 2'
}
}
})
Values are strings — so CSS var() references work too.
That's how the global fill color and stroke width controls on this
page work: the engine writes var(--fill) into the SVG,
and the browser resolves it from the document's CSS variables in
real time.
Spheres
addSphere() fills a sphere using a distance check from
the center. Using a .5 radius and center (like
3.5) tends to produce the cleanest shapes on integer
grids.
engine.addSphere({ center: [4.5, 4.5, 4.5], radius: 4.5 })
removeSphere() carves a spherical hole — same
parameters, opposite effect. Combine a box and a sphere subtraction
for arched doorways.
Lines
addLine() draws a voxelized line between two points.
Set radius to thicken it, and shape to
'rounded' (spheres along the path) or
'square'
(cubes along the path).
engine.addLine({
from: [0, 0, 0], to: [8, 8, 8],
radius: 1.5, shape: 'rounded'
})
Functional styles
Style values can be functions that receive the voxel's
(x, y, z)
coordinates and return a style object. This lets you create
gradients, patterns, or any position-dependent coloring without
manually styling each voxel.
engine.addBox({
position: [0, 0, 0], size: [8, 8, 8],
style: {
default: (x, y, z) => {
const L = 0.4 + (y / 8) * 0.5
const C = 0.05 + (z / 8) * 0.2
const H = (x / 8) * 360
return {
fill: `oklch(${L} ${C} ${H})`,
stroke: `oklch(${L - 0.12} ${C} ${H})`
}
}
}
})
The function is evaluated once per voxel when it's created. Each face can also have its own function — mix static and functional styles freely.
Camera & projection
The engine supports two projections. Oblique is a parallel projection where you control the angle and depth offset — good for clean isometric-style views. Perspective uses a single vanishing point and camera position.
// Oblique (parallel)
engine.setCamera({ type: 'oblique', angle: 315, distance: 25 })
// Perspective (1-point)
engine.setCamera({ type: 'perspective', position: [5, 5], distance: 12 })
Try it now — the floating Camera panel in the top right controls every demo on this page. Switch to perspective, change the angle, and scroll through the demos to see them all update.
In perspective mode, the Angle slider moves the camera's X position, and very low distances can clip geometry behind the camera.
Querying voxels
The engine exposes methods to inspect voxel state — useful for procedural decoration, game logic, or custom shading based on adjacency.
// Color voxels by how exposed they are
engine.forEach((voxel, pos) => {
const n = engine.getNeighbors(pos)
const exposed = Object.values(n)
.filter(v => !v).length
// exposed = number of open faces (0–6)
})
getNeighbors() returns the six cardinal neighbors as
voxel objects or null. In the demo, voxels are colored
by how many open faces they have — fully buried voxels stay dark,
exposed ones light up.
engine.hasVoxel([3, 4, 5]) // boolean
engine.getVoxel([3, 4, 5]) // voxel data or null
engine.forEach((voxel, pos) => { ... })
// Serialization
const json = engine.toJSON()
const copy = Heerich.fromJSON(json)
Content voxels
Pass a content string to place arbitrary SVG inside a
voxel cell. The engine projects it to 2D, depth-sorts it with all
other faces, and wraps it in a <g> with
transform and CSS custom properties for sizing.
engine.addBox({
position: [3, 0, 6], size: [1, 1, 1],
opaque: false,
content: `<text
font-family="Aboreto"
font-size="20"
text-anchor="middle"
dominant-baseline="central"
>A</text>`
})
Set opaque: false so neighbors render their faces as if
the content cell is empty — otherwise the content would block
adjacent voxels.
The content is wrapped in a <g> with
transform="translate(x, y) scale(s)" and CSS custom
properties you can use for sizing:
--x,--y— projected 2D position--z— original z coordinate-
--scale— perspective foreshortening (1 in oblique) --tile— tile size in pixels
Transparent voxels
Set opaque: false and the voxel still renders but
neighbors treat it as empty — their faces show through. Combine with
a transparent fill to create wireframe overlays, cages, or
see-through guides on top of solid geometry.
// Wireframe cage over a solid core
engine.addBox({ position: [1, 1, 1], size: [3, 3, 3] })
engine.addBox({
position: [0, 0, 0], size: [5, 5, 5],
opaque: false,
style: { default: { fill: 'none' } }
})
Animation
The engine doesn't own a clock — you drive animation externally with
requestAnimationFrame. Tween a continuous value (like a
hole's depth) and rebuild the scene each frame. The
removeBox carves away one layer at a time as the value
crosses integers — smooth deconstruction.
const holes = [
{ x: 1, y: 1, targetDepth: 5 },
{ x: 5, y: 2, targetDepth: 4 },
]
function frame(now) {
engine.clear()
engine.addBox(...)
holes.forEach((h, i) => {
const t = ease(elapsed / duration)
engine.removeBox({
position: [h.x, h.y, 0],
size: [3, 3, Math.round(t * h.targetDepth)]
})
})
container.innerHTML = engine.toSVG()
if (!done) requestAnimationFrame(frame)
}
The stagger offset means each hole starts animating slightly after the previous one, creating a cascading reveal.
Putting it all together
A scene combining everything: boxes, spheres, lines, functional styles, carving, and animated deconstruction. Hit play to watch holes carve into the structure with staggered easing, then reset to rebuild.
After Heerich
Recreations of Erwin Heerich's sculptures, built entirely with heerich.js.
Kreuzplastik (Brass Cross)
Schachbrett (Checkerboard)