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.
npm install heerichimport { Heerich } from 'heerich'Or use the UMD build via a <script> tag — the global Heerich will be available.
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()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 })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]) |
addGeometry(opts)— shortcut forapplyGeometry({ ...opts, mode: 'union' })removeGeometry(opts)— shortcut forapplyGeometry({ ...opts, mode: 'subtract' })
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.
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' } }
})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' } }
})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]
})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.
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'
})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.
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' },
}
})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',
})
}
})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' } }
})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.
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] })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 meta object get additional data-* attributes.
Get the projected 2D face array directly (for custom renderers or Canvas output):
// From stored voxels
const faces = h.getFaces()
// Stateless — from a test function, no voxels stored
const faces = h.renderTest({
bounds: [[-10, -10, -10], [10, 10, 10]],
test: (x, y, z) => x*x + y*y + z*z <= 100,
style: (x, y, z, faceName) => ({ fill: faceName === 'top' ? '#fff' : '#ccc' })
})
// Render pre-computed faces
const svg = h.toSVG({ faces })getFaces() returns everything you need to build your own renderer. Each face has:
face.points— projected 2D coordinates (flat array viaface.points.data:[x0, y0, x1, y1, ...])face.style— resolved style object (fill,stroke,strokeWidth, etc.)face.type— face direction ('top','front','right', etc.) or'content'face.voxel— source voxel withx,y,z, and optionalmetaface.depth— depth value (array is already sorted back-to-front)
const faces = h.getFaces()
for (const face of faces) {
if (face.type === 'content') continue
const d = face.points.data
// d = [x0, y0, x1, y1, x2, y2, x3, y3] — four corners of a quad
ctx.beginPath()
ctx.moveTo(d[0], d[1])
ctx.lineTo(d[2], d[3])
ctx.lineTo(d[4], d[5])
ctx.lineTo(d[6], d[7])
ctx.closePath()
ctx.fillStyle = face.style.fill
ctx.fill()
}Compute the 2D bounding box of the rendered geometry:
const { x, y, w, h } = h.getBounds()
const padded = h.getBounds(30)Embed arbitrary SVG at a voxel position (depth-sorted with the rest of the scene):
h.applyGeometry({
type: 'box',
position: [3, 0, 3],
size: 1,
content: '<text font-size="12" text-anchor="middle">Hi</text>',
opaque: false,
})Content voxels receive CSS custom properties --x, --y, --z, --scale, --tile for positioning.
h.getVoxel([2, 3, 1]) // voxel data or null
h.hasVoxel([2, 3, 1]) // boolean
h.getNeighbors([2, 3, 1]) // { top, bottom, left, right, front, back }
for (const voxel of h) { /* voxel.x, voxel.y, voxel.z, voxel.styles, ... */ }const data = h.toJSON()
const json = JSON.stringify(data)
const h2 = Heerich.fromJSON(JSON.parse(json))Note: functional styles (callbacks) cannot be serialized and will be omitted with a console warning.
- X — horizontal (left/right)
- Y — vertical (up/down). Note: Y increases downward, originating from SVG/DOM screen space.
- Z — depth (front/back).
Because the engine outputs standard SVG graphics and relies on Oblique projections, its grid behaves slightly differently than classic WebGL or mathematical 3D setups:
- Y Pointing Down: Setting a voxel at
y: -4places it above the origin, andy: 4places it below the origin in standard rendering. - Oblique Z-Offset: At the default angle of
315°(pointing up and left visually), the Z-axis projects horizontally and vertically on screen. - The "Front" Quadrant: Due to this isometric-style camera offset and Painter's Algorithm sorting, the closest visual corner pointing toward the camera is the
[-X, -Y, -Z](Negative) octant, not[+X, +Y, +Z](Positive) as one might expect. Carving out the "front" of a block to expose the inside means subtracting negative values.
Valid voxel coordinate bounds range from -512 to 511 on each axis.
Shape calculations for lines and spheres are based on the excellent guides by Red Blob Games:
MIT © 2026 David Aerne