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 initialize one by providing grid dimensions, a rendering tile size in pixels, and an optional baseline style applied universally across the active sequence.

import { Heerich } from './src/heerich.js'

const heerich = 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 heerich.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 = heerich.toSVG({ padding: 30 })

// Access raw bounds if you need them
const { x, y, w, h } = heerich.getViewBoxBounds()

Boxes

The most fundamental primitive is a box — a rectangular volume anchored by a corner position and dimensions. Every voxel inside it inherits the default style.

heerich.addBox({ position: [0, 0, 0], size: [6, 4, 3] })

Drag the sliders below — the engine clears and rebuilds the scene on every frame. Because getFaces() utilizes a dirty-flag cache, calculating rapid structural changes remains highly efficient.

Coordinates & alignment

The system intentionally avoids a generic 'ground plane'. Instead, position always anchors to the minimum corner of the shape's bounds. To align objects of different dimensions on a shared surface, you handle the offsets manually.

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)

This requires just one simple subtraction per axis, keeping the engine minimal and leaving you in complete control. For pixel-perfect centering on an integer grid, aim for even-numbered coordinate differences.

Spheres

addSphere() plots a rounded volume using an internal distance check. Using fractional offsets like .5 for both radius and center (e.g., center: 3.5, radius: 3.5) tends to produce the cleanest symmetrical forms on integer grids.

heerich.addSphere({ center: [4.5, 4.5, 4.5], radius: 4.5 })

Conversely, removeSphere() carves a hollow void using the exact same parameters. Layer a sphere subtraction into a solid box to effortlessly create arched doorways or dome cutouts.

Lines

addLine() plots a continuous voxelized path between two spatial coordinates. You can thicken the stroke via radius, and alter its joint geometry via shape — choosing either 'rounded' (a sequence of overlapping spheres) or 'square' (overlapping cubes).

heerich.addLine({
  from: [0, 0, 0], to: [8, 8, 8],
  radius: 1.5, shape: 'rounded'
})

Custom shapes

addWhere() is the engine's architectural bedrock — allowing you to define arbitrary geometry as an evaluation function over (x, y, z) coordinates. Under the hood, boxes, spheres, and lines are merely convenient wrappers around this core method.

heerich.addWhere({
  bounds: [[0, 0, 0], [s, s, s]],
  test: (x, y, z) => {
    const c = Math.ceil(s / 4)
    const nearEdge = [x, y, z].filter(
      v => v < c || v >= s - c
    ).length
    return nearEdge < 3
  }
})

The test method evaluates every coordinate within the specified bounds — returning true spawns a voxel. All standard properties (like style, scale, or mode) continue to apply.

Boolean operations

Every shape primitive accepts a mode property dictating how it weaves into the existing voxel grid:

  • union — add voxels (default)
  • subtract — remove voxels
  • intersect — keep only the overlap
  • exclude — XOR, toggle each voxel
heerich.addBox({ position: [0, 0, 0], size: [6, 6, 6] })

// Carve a sphere out of the box
heerich.addSphere({
  center: [3, 3, 3], radius: 3,
  mode: 'subtract'
})

// Keep only where box and sphere overlap
heerich.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 configuration mapped to precise 90° increments. Simply specify a rotation axis and the count of quarter-turns. By default, shapes pivot around their local center, but arbitrary custom origins are seamlessly supported.

// Build one arm, rotate a copy to make an L
heerich.addBox({ position: [0, 0, 0], size: [2, 8, 2] })
heerich.addBox({
  position: [0, 0, 0], size: [2, 8, 2],
  rotate: { axis: 'z', turns: 1 }
})

// Rotate all existing voxels in place
heerich.rotate({ axis: 'y', turns: 2 })

This dramatically simplifies creating symmetrical structures: construct one facet, then repeatedly rotate clones around a central pivot. (For example, the Kreuzplastik in the gallery below derives entirely from a single arm sequence rotated into a cross.)

Smooth solids

By default, every 1×1×1 unit renders its own distinct stroke. To fuse a shape into a single, visually seamless solid, simply assign the stroke the exact same color as the fill, effectively rendering the internal grid geometry invisible.

heerich.addBox({
  position: [0, 0, 0], size: [4, 4, 4],
  style: { default: { fill: '#0e0e0e', stroke: '#0e0e0e' } }
})

Untouched structural voxels retain their outlines, naturally producing a crisp contrast between smooth, monolithic volumes and articulated grids.

Styling faces

The engine tracks styles on a strict per-face basis: top, front, left, right, bottom, and back, alongside a default fallback. You can inject these styles during shape creation, or explicitly overwrite existing regions later using styleBox().

heerich.addBox({
  position: [0, 0, 0], size: [4, 4, 4],
  style: {
    default: { fill: '#eee', stroke: '#333' },
    top:   { fill: '#ff6666' },
    front: { fill: '#6666ff' },
    right: { fill: '#66ff66' }
  }
})

Crucially, styleBox() applies localized 'paint' to existing geometry without fabricating new voxels — ideal for shading specific cross-sections after the overall volume is finalized.

SVG style properties

Internal style objects map perfectly to native SVG attributes. If SVG natively understands a property, you can pass it directly. This unleashes the full rendering spec — enabling opacity, dotted edges via strokeDasharray, rounded strokeLinecap margins, or any other presentation attribute.

heerich.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'
    }
  }
})

Because style values resolve as strings, you can seamlessly pass native CSS var() references. That strategy powers the interactive controls across this documentation: the engine natively outputs var(--fill), and the browser resolves the cascade against document state in real time.

Functional styles

Instead of static objects, styles can be assigned as pure computing functions tracking over (x, y, z) parameters. This effortlessly powers cascading gradients, periodic patterns, or geometry-dependent shading rules — bypassing the need to iterate through individual voxels manually.

heerich.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})`
      }
    }
  }
})

These callbacks are invoked exactly once at voxel creation. Individual faces can possess their own isolated functions — letting you freely cross-pollinate static hues with calculated spectral shifts.

Voxel scaling

Every single voxel supports an internal scale — an [x, y, z] envelope mapped from 0 to 1 that collapses the volume along internal axes. Scaled geometry automatically triggers non-opaque rendering, correctly preserving visibility for occluded geometry layered behind it.

// Static — same scale for every voxel
heerich.addBox({
  position: [0, 0, 0],
  size: [1, 1, 1],
  scale: [1, 0.5, 1],
  scaleOrigin: [0.5, 1, 0.5]
})

// Functional — scale varies by position
heerich.addBox({
  position: [0, 0, 0],
  size: [4, 4, 4],
  scale: (x, y, z) => [1, 1 - y * 0.2, 1],
  scaleOrigin: [0.5, 1, 0.5]
})

The scaleOrigin dictates the spatial pivot for this scaling inside the 1×1×1 unit block — for example, [0.5, 1, 0.5] anchors geometry strictly to the bottom-center, creating natural staircase topologies. As with styles, both vectors seamlessly accept functional callbacks for dynamic parameter shifting.

Functional scale

As implied, evaluating scale as an (x, y, z) function empowers you to calculate spatial decay based exclusively on local position. You can instantly engineer complex tapers, rounded organic falloffs, or gracefully staggered architectural elements without stepping outside a single generative routine.

heerich.addBox({
  position: [0, 0, 0], size: [s, s, s],
  scale: (x, y, z) => {
    const t = 1 - y / s
    const f = 1 - t * taper
    return [f, 1, f]
  },
  scaleOrigin: [0.5, 1, 0.5]
})

Return null from the function to leave a voxel at full size.

Camera & projection

The rendering pipeline natively exposes two distinct geometric projections. Oblique implements parallel projection where depth angles and offset scales are manually enforced — perfect for hyper-clean orthographic aesthetics. Perspective leans on classical single-vanishing-point math anchored by an abstract camera position.

// Oblique (parallel)
heerich.setCamera({ type: 'oblique', angle: 315, distance: 25 })

// Perspective (1-point)
heerich.setCamera({ type: 'perspective', position: [5, 5], distance: 12 })

Look for the floating Settings panel (typically in the top right). Swapping to perspective, adjusting camera elevations, or shifting base hues applies sweeping, uninterrupted recalculations instantly across the entire documentation.

In perspective mode, the Angle slider moves the camera's X position, and very low distances can clip geometry behind the camera.

Content voxels

You can embed pure SVG arbitrarily into volumetric cells by injecting a literal content string. The engine intercepts these strings, seamlessly calculates their projected 2D location alongside other faces, enforces a strict Z-level depth sort, and encases everything cleanly inside an integrated <g> element.

heerich.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>`
})

Ensuring opaque: false explicitly directs neighboring elements to render their intersection faces accurately. Otherwise, neighbors would falsely assume a solid structure is blocking their boundaries.

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 itself remains visible, but adjacent voxels treat it as empty space — forcing their inner faces to be drawn. Combine this with a transparent fill to construct wireframe cages, bounding volumes, or see-through sculptural guides anchored cleanly over solid geometry.

// Wireframe cage over a solid core
heerich.addBox({ position: [1, 1, 1], size: [3, 3, 3] })
heerich.addBox({
  position: [0, 0, 0], size: [5, 5, 5],
  opaque: false,
  style: { default: { fill: 'none' } }
})

Querying voxels

A robust set of internal methods query localized voxel boundaries. This is fundamental for evaluating complex procedural decorations, handling adjacency logic, or driving edge-aware shading passes.

// Color voxels by how exposed they are
heerich.forEach((voxel, pos) => {
  const n = heerich.getNeighbors(pos)
  const exposed = Object.values(n)
    .filter(v => !v).length
  // exposed = number of open faces (0–6)
})

The getNeighbors() query explicitly retrieves adjacent solid geometry across the six cardinal planes. In the interactive demo, individual cubes compute their overall exposure count — completely encased shapes remain flat, while openly protruding forms inherit high-contrast luminance.

heerich.hasVoxel([3, 4, 5])   // boolean
heerich.getVoxel([3, 4, 5])   // voxel data or null
heerich.forEach((voxel, pos) => { ... })

// Serialization
const json = heerich.toJSON()
const copy = Heerich.fromJSON(json)

Animation

The heerich.js architecture strictly separates state from timing — animation is driven entirely externally, hooking directly into your own requestAnimationFrame loop. By interpolating a fractional value and redrawing the active sequence per frame, standard voxel functions progressively toggle discrete coordinates — yielding snappy, pixel-aligned deconstruction.

const holes = [
  { x: 1, y: 1, targetDepth: 5 },
  { x: 5, y: 2, targetDepth: 4 },
]

function frame(now) {
  heerich.clear()
  heerich.addBox(...)
  holes.forEach((h, i) => {
    const t = ease(elapsed / duration)
    heerich.removeBox({
      position: [h.x, h.y, 0],
      size: [3, 3, Math.round(t * h.targetDepth)]
    })
  })
  container.innerHTML = heerich.toSVG()
  if (!done) requestAnimationFrame(frame)
}

Applying a stagger offset means each hole strictly starts animating instantly after the previous one, rendering a beautifully cascading reveal.

Putting it all together

A dynamic, procedurally generated composition combining boxes, boolean carving, and functional styles. Recessed voids plunge into a full-width block, revealing depth-shaded walls, while towers steadily rise from within. Fractional voxel scaling smooths the transitions between integer depths, allowing layers to slide smoothly into place rather than abruptly snapping. Click the scene to regenerate.

Exacting recreations of physical cardboard and brass sculptures designed by Erwin Heerich, synthesized entirely in code using this engine.