Settings
Camera
Style

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.

Recreations of Erwin Heerich's sculptures, built entirely with heerich.js.