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()The engine exposes four projection types:
- oblique (default): Parallel projection where depth recedes at a configurable angle. Classic pixel-art / cabinet-projection look.
- perspective: Single-vanishing-point projection with an explicit camera position.
- orthographic: True 3D parallel projection with configurable pan (
angle) and tilt (pitch). Use this for dimetric, trimetric, or any custom orthographic view. - isometric: Orthographic preset with pitch locked to 35.264°. For the classic isometric diamond grid.
// Oblique (parallel)
new Heerich({ camera: { type: 'oblique', angle: 45, distance: 15 } })
// Perspective (1-point)
new Heerich({ camera: { type: 'perspective', position: [5, 5], distance: 10 } })
// Orthographic (parallel 3D)
new Heerich({ camera: { type: 'orthographic', angle: 45, pitch: 35.264 } })
// Isometric (orthographic with fixed pitch)
new Heerich({ camera: { type: 'isometric', angle: 45 } })
// Update camera at any time
h.setCamera({ angle: 30, distance: 20 })The angle parameter is shared across camera types for easy switching, but its meaning differs:
| Type | angle controls |
Default |
|---|---|---|
oblique |
Direction the depth (Z) axis recedes | 45° |
orthographic |
Horizontal rotation (pan) around the scene | 45° |
isometric |
Horizontal rotation — recommended values: 45°, 135°, 225°, 315° | 45° |
perspective |
(Mapped to camera X position) | — |
For isometric, any angle value works, but 45°, 135°, 225°, and 315° produce the standard isometric orientations where edges align to the pixel grid.
Note:
orthographicandisometricuse parallel projection —distancehas no effect in these modes.
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 |
occlusion |
boolean | Enable built-in occlusion culling (no external dependency) |
resolveOcclusion |
function | Custom occlusion resolver (overrides built-in). Providing this implicitly enables occlusion. Input (subjectCoords, overlappingCoords[]), return pathString or null |
By default, the engine relies on the browser's Paint Algorithm (back-to-front rendering). For zero overlapping vectors (perfect for plotters), enable built-in occlusion culling:
const svg = h.toSVG({ occlusion: true });The built-in clipper assumes convex occluders, which works well for oblique projection but may produce minor artifacts with perspective (where projected quads can become non-convex). For exact clipping, drop in polygon-clipping:
<script src="https://unpkg.com/[email protected]/dist/polygon-clipping.umd.js"></script>const svg = h.toSVG({
resolveOcclusion: (subject, occluders) => {
try {
const result = polygonClipping.difference([subject], ...occluders.map(o => [o]));
if (!result || result.length === 0) return null; // Fully occluded
let d = "";
for (const polygon of result) {
for (const ring of polygon) {
ring.forEach((pt, i) => { d += (i === 0 ? `M ${pt[0]} ${pt[1]} ` : `L ${pt[0]} ${pt[1]} `); });
d += "Z ";
}
}
return d.trim();
} catch { return null; }
}
});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.
Stamp SVG paths onto voxel faces. Define decals as <path> elements in a 0–1 unit coordinate space, then reference them by name in any face style. The engine warps every path coordinate via bilinear interpolation onto the projected face quad — perspective-correct, no affine approximation.
// Register a decal — one or more <path> elements in 0–1 unit space
h.defineDecal('circle', {
content: '<path d="M0.5 0 A0.5 0.5 0 1 1 0.5 1 A0.5 0.5 0 1 1 0.5 0 Z" fill="none" stroke="#333" stroke-width="1" vector-effect="non-scaling-stroke"/>'
})
// Shorthand — just the SVG string
h.defineDecal('cross', '<path d="M0 0 L1 1 M1 0 L0 1" stroke="#333" stroke-width="1" vector-effect="non-scaling-stroke" fill="none"/>')
// Reference by name in any face style
h.addGeometry({
type: 'box', position: [0, 0, 0], size: 3,
style: {
top: { fill: '#fff', decal: 'circle' },
front: { fill: '#ccc', decal: 'cross' },
}
})
// Per-use style overrides
h.addGeometry({
type: 'box', position: [4, 0, 0], size: 1,
style: {
top: { fill: '#fff', decal: { name: 'circle', style: { opacity: 0.5 } } }
}
})All SVG path commands are supported, both absolute (M, L, H, V, C, S, Q, T, A, Z) and relative (m, l, h, v, c, s, q, t, a, z). Arcs are automatically converted to cubic beziers before warping. Use vector-effect="non-scaling-stroke" for uniform stroke widths across faces. Decals are included in toJSON()/fromJSON() serialization.
Limitations:
- Only
<path>elements are currently supported. Other SVG shapes (<circle>,<rect>,<line>, etc.) must be converted to<path>before use. - Decals are not clipped by occlusion culling. Partially occluded faces will render their decals at full size — the painter's algorithm (back-to-front rendering) hides the overflow in most cases, but it may be visible with transparent occluders.
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