Heerich
Tiny engine for 3D voxel scenes rendered to SVG — boolean ops, oblique/perspective cameras, zero dependencies. Inspired by the geometric cardboard sculptures of Erwin Heerich.
Install / Use
/learn @meodai/HeerichREADME
heerich.js
A tiny engine for 3D voxel scenes rendered to SVG. Build shapes with CSG-like boolean operations, style individual faces, and output crisp vector graphics — no WebGL, no canvas, just <svg>.
Named after Erwin Heerich, the German sculptor known for geometric cardboard sculptures.
Install
npm install heerich
import { Heerich } from 'heerich'
Or use the UMD build via a <script> tag — the global Heerich will be available.
Quick Start
import { Heerich } from 'heerich'
const h = new Heerich({
tile: 40,
camera: { type: 'oblique', angle: 45, distance: 15 },
})
// A simple house
h.applyGeometry({ type: 'box', position: [0, 0, 0], size: [5, 4, 5], style: {
default: { fill: '#e8d4b8', stroke: '#333' },
top: { fill: '#c94c3a' },
}})
// Carve out a door
h.removeGeometry({
type: 'box',
position: [2, 1, 0],
size: [1, 3, 1]
})
document.body.innerHTML = h.toSVG()
Camera
Two projection modes are available:
// Oblique (default) — classic pixel-art look
const h = new Heerich({
camera: { type: 'oblique', angle: 45, distance: 15 }
})
// Perspective — vanishing-point projection
const h = new Heerich({
camera: { type: 'perspective', position: [5, 5], distance: 10 }
})
// Update camera at any time
h.setCamera({ angle: 30, distance: 20 })
Shapes
All shape methods accept a common set of options:
| Option | Type | Description |
|-----------|------|-------------|
| mode | 'union' | 'subtract' | 'intersect' | 'exclude' | Boolean operation (default: 'union') |
| style | object or function | Per-face styles (see Styling) |
| content | string | Raw SVG content to render instead of polygon faces |
| opaque | boolean | Whether this voxel occludes neighbors (default: true) |
| meta | object | Key/value pairs emitted as data-* attributes on SVG polygons |
| rotate | object | Rotate coordinates before placement (see Rotation) |
| scale | [x, y, z] or (x, y, z) => [sx, sy, sz] | Per-axis scale 0–1 (auto-sets opaque: false) |
| scaleOrigin | [x, y, z] or (x, y, z) => [ox, oy, oz] | Scale anchor within the voxel cell (default: [0.5, 0, 0.5]) |
Convenience methods
addGeometry(opts)— shortcut forapplyGeometry({ ...opts, mode: 'union' })removeGeometry(opts)— shortcut forapplyGeometry({ ...opts, mode: 'subtract' })
Uniform positioning
Box, sphere, and fill all accept both position (min-corner) and center (geometric center) — the engine converts between them automatically based on the shape's size:
// These are equivalent for a 5×5×5 box:
h.applyGeometry({ type: 'box', position: [0, 0, 0], size: 5 })
h.applyGeometry({ type: 'box', center: [2, 2, 2], size: 5 })
// These are equivalent for a sphere with radius 3:
h.applyGeometry({ type: 'sphere', center: [3, 3, 3], radius: 3 })
h.applyGeometry({ type: 'sphere', position: [0, 0, 0], radius: 3 })
h.applyGeometry({ type: 'sphere', center: [3, 3, 3], size: 7 })
Fill also accepts position/center + size as an alternative to bounds.
Box
h.applyGeometry({
type: 'box',
position: [0, 0, 0],
size: [3, 2, 4]
})
h.removeGeometry({
type: 'box',
position: [1, 0, 1],
size: 1
})
// Style the carved walls (optional)
h.removeGeometry({
type: 'box',
position: [0, 0, 0],
size: 1,
style: { default: { fill: '#222' } }
})
Sphere
h.applyGeometry({
type: 'sphere',
center: [5, 5, 5],
radius: 3
})
h.removeGeometry({
type: 'sphere',
center: [5, 5, 5],
radius: 1.5
})
// Style the carved walls (optional)
h.removeGeometry({
type: 'sphere',
center: [5, 5, 5],
radius: 1,
style: { default: { fill: '#222' } }
})
Line
Lines are the only shape that uses different positioning — from/to instead of position/center + size:
h.applyGeometry({
type: 'line',
from: [0, 0, 0],
to: [10, 5, 0]
})
// Thick rounded line
h.applyGeometry({
type: 'line',
from: [0, 0, 0],
to: [10, 0, 0],
radius: 2,
shape: 'rounded'
})
// Thick square line
h.applyGeometry({
type: 'line',
from: [0, 0, 0],
to: [0, 10, 0],
radius: 1,
shape: 'square'
})
h.removeGeometry({
type: 'line',
from: [3, 0, 0],
to: [7, 0, 0]
})
Custom Shapes
applyGeometry with type: 'fill' is the general-purpose shape primitive — define any shape as a function of (x, y, z). Boxes, spheres, and lines are just convenience wrappers around this pattern.
// Hollow sphere
h.applyGeometry({
type: 'fill',
bounds: [[-6, -6, -6], [6, 6, 6]],
test: (x, y, z) => {
const d = x*x + y*y + z*z
return d <= 25 && d >= 16
}
})
// Torus
h.applyGeometry({
type: 'fill',
bounds: [[-8, -3, -8], [8, 3, 8]],
test: (x, y, z) => {
const R = 6, r = 2
const q = Math.sqrt(x*x + z*z) - R
return q*q + y*y <= r*r
}
})
h.removeGeometry({
type: 'fill',
bounds: [[0, -6, -6], [6, 6, 6]],
test: () => true
})
Combine with functional scale and style for fully procedural shapes — closest thing to a voxel shader.
Boolean Operations
All shape methods support a mode option for CSG-like operations:
// Union (default) — add voxels
h.applyGeometry({
type: 'box',
position: [0, 0, 0],
size: 5
})
// Subtract — carve out voxels
h.applyGeometry({
type: 'sphere',
center: [2, 2, 2],
radius: 2,
mode: 'subtract'
})
// Intersect — keep only the overlap
h.applyGeometry({
type: 'box',
position: [1, 1, 1],
size: 3,
mode: 'intersect'
})
// Exclude — XOR: add where empty, remove where occupied
h.applyGeometry({
type: 'box',
position: [0, 0, 0],
size: 5,
mode: 'exclude'
})
Styling carved faces
When removing voxels, you can pass a style to color the newly exposed faces of neighboring voxels — the "walls" of the carved hole:
h.applyGeometry({
type: 'box',
position: [0, 0, 0],
size: 10
})
// Carve a hole with dark walls
h.removeGeometry({
type: 'box',
position: [3, 3, 0],
size: [4, 4, 5],
style: { default: { fill: '#222', stroke: '#111' } }
})
This works on removeGeometry (with any type) and on applyGeometry with mode: 'subtract'. Without a style, subtract behaves as before — just deleting voxels.
Styling
Styles are set per face name: default, top, bottom, left, right, front, back.
Each face style is an object with SVG presentation attributes (fill, stroke, strokeWidth, etc.).
h.applyGeometry({
type: 'box',
position: [0, 0, 0],
size: 3,
style: {
default: { fill: '#6699cc', stroke: '#234' },
top: { fill: '#88bbee' },
front: { fill: '#557799' },
}
})
Dynamic styles
Style values can be functions of (x, y, z):
h.applyGeometry({
type: 'box',
position: [0, 0, 0],
size: 8,
style: {
default: (x, y, z) => ({
fill: `hsl(${x * 40}, 60%, ${50 + z * 5}%)`,
stroke: '#222',
})
}
})
Restyling
Restyle existing voxels without adding or removing them:
h.applyStyle({
type: 'box',
position: [0, 0, 0],
size: 3,
style: { top: { fill: 'red' } }
})
h.applyStyle({
type: 'sphere',
center: [5, 5, 5],
radius: 2,
style: { default: { fill: 'gold' } }
})
h.applyStyle({
type: 'line',
from: [0, 0, 0],
to: [10, 0, 0],
radius: 1,
style: { default: { fill: 'blue' } }
})
Voxel Scaling
Shrink individual voxels along any axis. Scaled voxels automatically become non-opaque, revealing neighbors behind them.
// Static — same scale for every voxel
h.applyGeometry({
type: 'box',
position: [0, 0, 0],
size: 1,
scale: [1, 0.5, 1],
scaleOrigin: [0.5, 1, 0.5]
})
// Functional — scale varies by position
h.applyGeometry({
type: 'box',
position: [0, 0, 0],
size: 4,
scale: (x, y, z) => [1, 1 - y * 0.2, 1],
scaleOrigin: [0.5, 1, 0.5]
})
The scaleOrigin sets where scaling anchors within the voxel cell (0–1 per axis). [0.5, 1, 0.5] pins to the bottom-center (floor), [0.5, 0, 0.5] pins to the top-center (ceiling). Both scale and scaleOrigin accept functions of (x, y, z) for per-voxel control. Return null from a scale function to leave that voxel at full size.
Rotation
Rotate coordinates by 90-degree increments before or after placement:
// Rotate a shape before placing it
h.applyGeometry({
type: 'box',
position: [0, 0, 0],
size: [5, 1, 3],
rotate: { axis: 'z', turns: 1 }
})
// Rotate all existing voxels in place
h.rotate({ axis: 'y', turns: 2 })
// With explicit center
h.rotate({ axis: 'x', turns: 1, center: [5, 5, 5] })
Rendering
toSVG(options?)
Render the scene to an SVG string:
const svg = h.toSVG()
const svg = h.toSVG({ padding: 40 })
const svg = h.toSVG({ viewBox: [0, 0, 800, 600] })
Options:
| Option | Type | Description |
|--------|------|-------------|
| padding | number | ViewBox padding in px (default: 20) |
| faces | Face[] | Pre-computed faces (skips internal rendering) |
| viewBox | [x,y,w,h] | Custom viewBox override |
| offset | [x,y] | Translate all geometry |
| prepend | string | Raw SVG inserted before faces |
| append | string | Raw SVG inserted after faces |
| faceAttributes | function | Per-face attribute callback |
Use prepend and append to inject SVG filters for effects like cel-shaded outlines:
const svg = h.toSVG({
prepend: `<defs><filter id="cel">
<feMorphology in="SourceAlpha" operator="dilate" radius="2" result="thick"/>
<feFlood flood-color="#000"/>
<feComposite in2="thick" operator="in" result="border"/>
<feMerge><feMergeNode in="border"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter></defs><g filter="url(#cel)">`,
append: `</g>`,
})
Every polygon gets data attributes for interactivity:
<... data-voxel="x,y,z" data-x="x" data-y="y" data-z="z" data-face="top" ../>
Voxels with a
