Skip to content

Voxel builder ​

Click the floor to place a cube, click a face to place one against it and shift-click a cube to remove it, or switch to erase mode. In paint mode a click recolors a cube and a shift-click picks up its color. Drag the figure to rotate it, and flick it to let it spin out. With the figure focused, the arrow keys walk a cursor over the floor, Enter builds on top of it, or erases or paints its top cube in erase or paint mode, Delete takes the top cube away, the keys 1 to 6 pick a color, E switches between building and erasing and P between building and painting. Save and load, under the figure, writes the build as JSON and reads one back, pasted into the field or from a file.

The floor is one plane ​

The floor is a single plane and its grid lines, not 64 cells. A click on a face can tell which cube it hit, because every face is an element with the cube on its vertices. A click on the floor has nothing like that: no element per cell, and no depth on the screen at all. An orthographic projection drops the depth, so a screen point stands for a whole line of points behind it.

unproject answers the question the other way round: it takes the screen point back into the scene and returns the point where that line meets a plane. The floor is the plane through the origin whose normal points along y:

ts
const [x, y] = pointer(event, group.node());
const point = cubes.unproject({ x, y }, { x: 0, y: 0, z: 0 }, { x: 0, y: 1, z: 0 });

const column = Math.floor(point.x + 4);
const row = Math.floor(point.z + 4);

The renderer already knows its scale, origin, rotation and pivot, so the call takes only the point and the plane. The readout under the figure shows point while the pointer moves: at a crossing of two grid lines it reads whole numbers, however the floor is turned. unproject returns null for a plane seen edge on; the camera never gets there, because the tilt stays between 10 and 85 degrees above the floor.

A face knows its neighbor ​

Over a cube the face under the pointer names the cell. cubes3D names every face, and the name is all it takes to find the cell behind it:

ts
const STEPS = {
    front: [0, 0, 1],
    back: [0, 0, -1],
    left: [-1, 0, 0],
    right: [1, 0, 0],
    top: [0, 1, 0],
    bottom: [0, -1, 0]
};

The steps are in column, layer and row. A click on a top face builds one layer up, a click on a side face builds next to the cube, and a click in erase or paint mode takes the cube itself. No coordinates are involved.

Faces nobody can see ​

A face that touches another cube, or rests on the floor, can never be seen, so the face join drops it before it reaches the DOM:

ts
function isOpen(cell, face) {
    const [dc, dl, dr] = STEPS[face];
    return (
        cell.layer + dl >= 0 &&
        !built.has(keyOf({ column: cell.column + dc, layer: cell.layer + dl, row: cell.row + dr }))
    );
}

The count under the figure shows what that saves: a solid 2 × 2 × 2 block is 8 cubes and 48 faces, of which 20 are drawn. The faces that turn away from the camera are a different matter: they change with every frame of a drag, so they keep their elements and are only hidden.

Stable cubes ​

The cubes are joined by their cell, the faces by their name. A drag only moves the paths of the build; one of its elements comes or goes only when a cube is placed or erased, or a face is covered or uncovered.

The ghost, the see-through cube that shows where the next click lands, is one more cube in the same join, under the key ghost, so it is sorted with the others and hides behind them where it should. It is the one cube whose elements come and go as it moves from cell to cell. It takes no pointer events: the pointer always finds the face or the floor behind it. The target is picked again after every frame of a glide, so the ghost stays under a resting pointer while the scene spins out.

The click that ends a drag never arrives: d3.drag swallows it once the pointer has moved, so turning the scene never builds.

Painting is a class change ​

The figure never sets a fill. The group of a cube carries the class of its color, color-1 for orange, and the stylesheet turns the class into the fill of every face. The state is the map of the built cells, and a paint only changes the color of one of them:

ts
built.set(key, { ...cube, color });
render();

The render joins the cubes by their cell, as before, so the painted cube keeps its group, and the group takes another class. A paint adds no element and removes none, and the counts of cubes and faces drawn stay the same. The cubes are put in depth order before the join, so a render that changes no order does not move a group either: a MutationObserver on the cubes sees nothing at all.

The preview under the pointer is the same swap: for as long as the pointer rests on a cube, its group takes the class of the current color and a dashed outline. A shift-click in paint mode is the eyedropper: it reads the color of the cube from the map, not from the screen, and selects its swatch.

Saving a build ​

Export writes the build into the field as JSON and offers it as the download voxels.json, from a blob URL that the next export revokes. In short, the file reads:

json
{
    "format": "d3-3d-voxels",
    "version": 1,
    "size": { "columns": 8, "layers": 6, "rows": 8 },
    "cubes": [
        { "column": 3, "layer": 0, "row": 3, "color": "blue" },
        { "column": 4, "layer": 0, "row": 3, "color": "blue" }
    ]
}

The coordinates count from 0, a color is the name of a palette entry, and the cubes are written in layer, row and column order, so the same build always gives the same text.

Load reads the field, or a file chosen from disk, and both go through one loader. A saved build is untrusted input: a pasted text or a file can hold anything, and every number in it becomes a map key and a coordinate, the color a class name. So the loader checks all of it before it touches the build:

  • The size. A text over 64 KB is rejected before it is parsed, and a file before it is read, so a huge file cannot freeze the tab. A full grid takes about 35 KB.
  • The JSON. JSON.parse runs in a try block, and a parse error becomes "Not valid JSON."
  • The envelope. An object with the format d3-3d-voxels, the version 1 and an array of at most 384 cubes, one per cell of the 8 × 6 × 8 grid. A file of another tool or a later version is turned away, and the loop never runs longer than the grid.
  • The bounds. Column and row are whole numbers from 0 to 7 and the layer one from 0 to 5, checked with Number.isInteger and the bounds: a cube outside the grid, at 2.5 or at the string "1" would make a key that no cell has.
  • The color. Exactly one of the six palette names. It is kept as an index into the palette, so no class name ever comes from the file.
  • No duplicates. Two cubes in one cell are rejected; the map would silently keep only the last one.
  • A copy. Every cube is a new object of its four checked fields, and any other key is dropped, so a __proto__ key in the file never reaches an object the figure uses.
ts
const isIndex = (value: unknown, count: number): value is number =>
    typeof value === 'number' && Number.isInteger(value) && value >= 0 && value < count;

const { column, layer, row } = cube;
if (!isIndex(layer, H)) {
    return outOf(i, 'layer', H);
}
// … the column, the row, the color and the cell …
voxels.push({ column, layer, row, color });

The loader returns every cube or one message, and the build changes only when it returns cubes: a load is all or nothing. The message names the first thing wrong, for example "Cube 12: layer must be a whole number from 0 to 5.", and the field is marked invalid and described by it. The message is set as text, never as markup, and nothing of the file is ever inserted as HTML.

The whole source ​

The figure and its styles live in docs/examples/voxelBuilder.ts in the repository: the figure adds the styles and its class to the element it mounts into, so it needs no stylesheet. The SVG and the drag live in docs/examples/rotatingFigure.ts, the camera, the floor and the pointer over it in docs/examples/floorView.ts, the cube join, the face join and the neighbor steps in docs/examples/cubeFaces.ts and the grid lines in docs/examples/segments.ts. The figure, ready to copy:

ts
import { pointer, select } from 'd3';
import { camera3D, cubes3D } from '../../index';
import type { CubeFace, CubeFaceName, Point3D } from '../../index';
import { cellBox, joinCells, keyOf, neighborOf, STEPS } from './cubeFaces';
import type { Cell } from './cubeFaces';
import { floorGrid, followPointer, pose } from './floorView';
import type { Pointer } from './floorView';
import { rotatingFigure } from './rotatingFigure';

/*
 * A voxel builder: unit cubes on an 8 × 8 floor. The floor is one plane and
 * its grid lines, so there is no element per cell to hit: the cell under the
 * pointer comes from `unproject`, the inverse of the projection. A clicked
 * face names its own neighbor, and a face that touches another cube or the
 * floor is never drawn. The styles ship with the module: the figure adds
 * them to its host.
 */

// The cells along each side of the floor and the layers a column may hold.
const N = 8;
const H = 6;
const HALF = N / 2;
// The scene turns around the middle of the space a build can fill. The
// camera stays above the floor, so the floor is behind every cube and needs
// no place in the sort.
const VIEW = { scale: 38, center: { x: 0, y: -H / 2, z: 0 } };

const COLORS = ['blue', 'orange', 'green', 'yellow', 'purple', 'red'];

/** A cube of the build: its cell and the index of its color. */
type Voxel = Cell & { color: number };

/** One vertex of a cube: a point that carries its cube, so the cube survives the projection. */
type VoxelVertex = Point3D & Voxel & { key: string; ghost: boolean };

/** What a click does in each mode of the mode radio. */
type Mode = 'build' | 'erase' | 'paint';

/** What a click does: the mode, or with Shift its counterpart. Pick takes the color of a cube. */
type Action = Mode | 'pick';

/** Shift turns every mode into its counterpart. */
const SHIFTED: Record<Mode, Action> = { build: 'erase', erase: 'build', paint: 'pick' };

/** The class that marks the cube a click would act on. The ghost marks a build. */
const MARKS: Record<Action, string> = {
    build: '',
    erase: ' is-target',
    paint: ' is-paint',
    pick: ' is-pick'
};

/** Where the next click lands: a free cell to build in, or a cube to erase, paint or pick. */
type Target = Cell & { action: Action };

const FACES = Object.keys(STEPS) as CubeFaceName[];

/** How the arrow keys move the cursor, as a step in column and row. */
const MOVES: Record<string, [number, number]> = {
    ArrowUp: [0, -1],
    ArrowDown: [0, 1],
    ArrowLeft: [-1, 0],
    ArrowRight: [1, 0]
};

/** A stepped corner, so the floor is not empty on the first visit. */
const STARTER: Voxel[] = [
    { column: 3, layer: 0, row: 3, color: 0 },
    { column: 4, layer: 0, row: 3, color: 0 },
    { column: 3, layer: 0, row: 4, color: 1 },
    { column: 4, layer: 0, row: 4, color: 2 },
    { column: 3, layer: 1, row: 3, color: 3 },
    { column: 4, layer: 1, row: 3, color: 0 },
    { column: 3, layer: 2, row: 3, color: 4 }
];

const inside = (cell: Cell) =>
    cell.column >= 0 &&
    cell.column < N &&
    cell.row >= 0 &&
    cell.row < N &&
    cell.layer >= 0 &&
    cell.layer < H;

const clamp = (i: number) => Math.max(0, Math.min(N - 1, i));

const plural = (count: number) => `${count} cube${count === 1 ? '' : 's'}`;

// A saved build: its format, its version and the most text it may hold.
// 384 cubes of JSON take about 35 KB.
const FORMAT = 'd3-3d-voxels';
const VERSION = 1;
const MAX_TEXT = 64 * 1024;

/** The build as the text of a saved file, its cubes in layer, row and column order. */
function writeBuild(voxels: Voxel[]): string {
    const cubes = [...voxels]
        .sort((a, b) => a.layer - b.layer || a.row - b.row || a.column - b.column)
        .map(({ column, layer, row, color }) => ({ column, layer, row, color: COLORS[color] }));
    const size = { columns: N, layers: H, rows: N };
    return JSON.stringify({ format: FORMAT, version: VERSION, size, cubes }, null, 2);
}

const isRecord = (value: unknown): value is Record<string, unknown> =>
    typeof value === 'object' && value !== null && !Array.isArray(value);

/** Whether `value` is a whole number from 0 to `count - 1`. */
const isIndex = (value: unknown, count: number): value is number =>
    typeof value === 'number' && Number.isInteger(value) && value >= 0 && value < count;

const outOf = (i: number, field: string, count: number) =>
    `Cube ${i}: ${field} must be a whole number from 0 to ${count - 1}.`;

/**
 * Reads the text of a saved file, which may hold anything. Returns all of
 * its cubes, or the message that names the first thing wrong with it. Every
 * cube is a new object of its four checked fields, so no other key of the
 * file reaches the figure, and the color becomes a palette index.
 */
function readBuild(text: string): Voxel[] | string {
    if (text.length > MAX_TEXT) {
        return 'The build is over 64 KB.';
    }
    let file: unknown;
    try {
        file = JSON.parse(text);
    } catch {
        return 'Not valid JSON.';
    }
    if (!isRecord(file)) {
        return 'The build must be a JSON object.';
    }
    if (file.format !== FORMAT) {
        return `The format must be "${FORMAT}".`;
    }
    if (file.version !== VERSION) {
        return `The version must be ${VERSION}.`;
    }
    if (!Array.isArray(file.cubes)) {
        return 'The cubes must be an array.';
    }
    const entries: unknown[] = file.cubes;
    if (entries.length > N * H * N) {
        return `A build holds at most ${N * H * N} cubes, one per cell.`;
    }

    const voxels: Voxel[] = [];
    const cells = new Map<string, number>();
    for (const [i, cube] of entries.entries()) {
        if (!isRecord(cube)) {
            return `Cube ${i} must be an object.`;
        }
        const { column, layer, row } = cube;
        if (!isIndex(column, N)) {
            return outOf(i, 'column', N);
        }
        if (!isIndex(layer, H)) {
            return outOf(i, 'layer', H);
        }
        if (!isIndex(row, N)) {
            return outOf(i, 'row', N);
        }
        const color = COLORS.findIndex((name) => name === cube.color);
        if (color < 0) {
            return `Cube ${i}: color must be one of ${COLORS.join(', ')}.`;
        }
        const key = keyOf({ column, layer, row });
        if (cells.has(key)) {
            return `Cube ${i} is in the same cell as cube ${cells.get(key)}.`;
        }
        cells.set(key, i);
        voxels.push({ column, layer, row, color });
    }
    return voxels;
}

/** The eight vertices of the cube in a cell, in the order cubes3D expects. */
function makeCube(voxel: Voxel, ghost = false): VoxelVertex[] {
    const key = ghost ? 'ghost' : keyOf(voxel);
    return cellBox(voxel, { x: -HALF, y: 0, z: -HALF }).map((corner) => ({
        ...voxel,
        ...corner,
        key,
        ghost
    }));
}

/**
 * Mounts the figure into `host` and returns a function that removes it.
 */
export function mountVoxelBuilder(host: HTMLElement): () => void {
    // The state: every input writes it, and `render` reads it.
    const built = new Map<string, Voxel>();
    let color = 0;
    let mode: Mode = 'build';
    let yaw = 0;
    let pitch = 0;
    let target: Target | undefined;
    // What picks the target: the pointer while it rests over the figure with
    // no button down, otherwise the keyboard's cursor column.
    let hovering: Pointer | undefined;
    let cursor: { column: number; row: number } | undefined;
    // The blob URL of the last export, revoked by the next one and on dispose.
    let fileUrl = '';

    // The cubes and the floor hang on one camera.
    const camera = camera3D();
    const cubes = cubes3D<VoxelVertex>().camera(camera);

    const { group, button } = rotatingFigure(
        host,
        'voxel',
        'An 8 by 8 floor to build on with cubes. Drag it to rotate it, click the floor or a cube to place a cube and shift-click a cube to remove it, in paint mode click a cube to recolor it and shift-click it to pick its color, or focus it and build with the arrow keys and Enter.',
        (dragYaw, dragPitch) => {
            yaw = dragYaw;
            pitch = dragPitch;
            render();
            retarget();
        }
    );

    host.classList.add('voxel-builder');
    host.insertAdjacentHTML(
        'beforeend',
        `<style>${STYLES}</style>
        <div class="voxel-controls">
            <fieldset>
                <legend>color</legend>
                ${COLORS.map(
                    (name, i) =>
                        `<input class="swatch color-${i}" type="radio" name="voxel-color" value="${i}" aria-label="${name}" title="${name} (${i + 1})"${i === color ? ' checked' : ''}>`
                ).join('')}
            </fieldset>
            <fieldset>
                <legend>mode</legend>
                <label><input type="radio" name="voxel-mode" value="build" checked> build</label>
                <label><input type="radio" name="voxel-mode" value="erase"> erase</label>
                <label><input type="radio" name="voxel-mode" value="paint"> paint</label>
            </fieldset>
            <p class="voxel-readout"><span></span> <span aria-live="polite"></span> <span></span></p>
            <details class="voxel-save">
                <summary>save and load</summary>
                <label>build as JSON <textarea rows="8" spellcheck="false" aria-describedby="voxel-save-fault"></textarea></label>
                <p class="voxel-save-fault" id="voxel-save-fault"></p>
                <div class="voxel-save-actions">
                    <button type="button">export</button>
                    <button type="button">load</button>
                    <label>or a file <input type="file" accept=".json,application/json"></label>
                    <a download="voxels.json" hidden>download voxels.json</a>
                </div>
            </details>
        </div>`
    );

    const svg = select(host).select<SVGSVGElement>('svg').attr('tabindex', 0);
    // The floor goes in before the cubes, so it is always painted first.
    const drawFloor = floorGrid(svg, 'voxel', camera, N);
    group.attr('class', 'voxel-cubes');
    const [floorText, status, tally] = Array.from(
        host.querySelectorAll<HTMLElement>('.voxel-readout span')
    );
    const colorInputs = Array.from(host.querySelectorAll<HTMLInputElement>('[name=voxel-color]'));
    const modeInputs = Array.from(host.querySelectorAll<HTMLInputElement>('[name=voxel-mode]'));
    const field = host.querySelector<HTMLTextAreaElement>('.voxel-save textarea')!;
    const fault = host.querySelector<HTMLElement>('.voxel-save-fault')!;
    const [exportButton, loadButton] = Array.from(
        host.querySelectorAll<HTMLButtonElement>('.voxel-save button')
    );
    const fileInput = host.querySelector<HTMLInputElement>('.voxel-save [type=file]')!;
    const download = host.querySelector<HTMLAnchorElement>('.voxel-save a')!;

    /** Whether the face of a cell in this direction shows: no cube and no floor behind it. */
    function isOpen(cell: Cell, face: CubeFaceName): boolean {
        const next = neighborOf(cell, face);
        return next.layer >= 0 && !built.has(keyOf(next));
    }

    /** Turns the scene, draws the floor and paints the cubes and the ghost back to front. */
    function render(): void {
        pose(camera, VIEW, yaw, pitch);
        drawFloor();

        const voxels = [...built.values()].map((voxel) => makeCube(voxel));
        const action = target?.action;
        if (target && action === 'build') {
            voxels.push(makeCube({ ...target, color }, true));
        }
        const marked = target && action !== 'build' ? keyOf(target) : undefined;

        // A face against another cube or the floor is dropped, so the DOM
        // only holds the surface of the build. The cube a click would erase,
        // paint or pick carries a mark, and a paint shows the color it gives.
        joinCells(group, cubes, voxels, isOpen).attr('class', (d) => {
            const mark = d[0].key === marked ? action : undefined;
            const shown = mark === 'paint' ? color : d[0].color;
            return `cube color-${shown}${d[0].ghost ? ' ghost' : ''}${mark ? MARKS[mark] : ''}`;
        });

        const faces = [...built.values()].reduce(
            (sum, voxel) => sum + FACES.filter((face) => isOpen(voxel, face)).length,
            0
        );
        tally.textContent = `${plural(built.size)} · ${faces} faces drawn`;
    }

    /** The point of the floor plane under a pointer, in world units, or null when it misses. */
    function floorAt(at: Pointer): Point3D | null {
        const [x, y] = pointer(at, group.node());
        return cubes.unproject({ x, y }, { x: 0, y: 0, z: 0 }, { x: 0, y: 1, z: 0 });
    }

    /** The first free cell of a column to build in, or its top cube to erase, paint or pick. */
    function columnTarget(column: number, row: number, action: Action): Target | undefined {
        let layer = 0;
        while (layer < H && built.has(keyOf({ column, layer, row }))) {
            layer += 1;
        }
        if (action !== 'build') {
            return layer > 0 ? { column, layer: layer - 1, row, action } : undefined;
        }
        return layer < H ? { column, layer, row, action } : undefined;
    }

    /**
     * Where a click at the pointer would land. Over a face the face names the
     * cell: the cube itself to erase, paint or pick it, its neighbor to
     * build. Anywhere else the floor plane under the pointer names the column.
     */
    function targetAt(at: Pointer): Target | undefined {
        const action = at.shiftKey ? SHIFTED[mode] : mode;
        const hit = document.elementFromPoint(at.clientX, at.clientY);

        if (hit && hit.matches('.face') && host.contains(hit)) {
            const face = select<Element, CubeFace<VoxelVertex>>(hit).datum();
            const { column, layer, row } = face[0];
            if (action !== 'build') {
                return { column, layer, row, action };
            }
            const next = neighborOf(face[0], face.face);
            return inside(next) && !built.has(keyOf(next)) ? { ...next, action } : undefined;
        }

        const point = floorAt(at);
        const column = point ? Math.floor(point.x + HALF) : -1;
        const row = point ? Math.floor(point.z + HALF) : -1;
        if (action !== 'build' || !inside({ column, layer: 0, row })) {
            return undefined;
        }
        return columnTarget(column, row, action);
    }

    /** Picks the target again from the pointer or the cursor, and draws it if it moved. */
    function retarget(): void {
        const targetKey = (t: Target | undefined) => (t ? `${keyOf(t)} ${t.action}` : '');
        const previous = target;
        target = hovering
            ? targetAt(hovering)
            : cursor
              ? columnTarget(cursor.column, cursor.row, mode)
              : undefined;
        const point = hovering ? floorAt(hovering) : null;
        floorText.textContent = point
            ? `floor x ${point.x.toFixed(2)}, z ${point.z.toFixed(2)}`
            : '';
        if (targetKey(previous) !== targetKey(target)) {
            render();
        }
    }

    /**
     * Builds, erases, paints or picks the cube of a target and tells what
     * happened. A paint or a pick changes no cell, so it adds or removes no
     * element: the paint only swaps the class of the cube's group.
     */
    function apply(change: Target): void {
        const key = keyOf(change);
        const cube = built.get(key);
        const where = `column ${change.column + 1}, row ${change.row + 1}, layer ${change.layer + 1}`;
        if (change.action === 'pick') {
            setColor(cube!.color);
            status.textContent = `Picked ${COLORS[color]}.`;
            return;
        }
        if (change.action === 'paint') {
            // A new object, so the starter build keeps its colors for a reset.
            built.set(key, { ...cube!, color });
            status.textContent = `Painted the cube at ${where} ${COLORS[color]}, was ${COLORS[cube!.color]}.`;
        } else {
            const erase = change.action === 'erase';
            const name = COLORS[erase ? cube!.color : color];
            if (erase) {
                built.delete(key);
            } else {
                built.set(key, {
                    column: change.column,
                    layer: change.layer,
                    row: change.row,
                    color
                });
            }
            status.textContent = `${erase ? 'Removed' : 'Placed'} ${/^[aeiou]/.test(name) ? 'an' : 'a'} ${name} cube at ${where}. ${plural(built.size)}.`;
        }
        target = undefined;
        retarget();
        render();
    }

    function setColor(index: number): void {
        color = index;
        colorInputs[index].checked = true;
        render();
    }

    function setMode(next: Mode): void {
        mode = next;
        for (const input of modeInputs) {
            input.checked = input.value === next;
        }
        retarget();
    }

    function reset(voxels = STARTER): void {
        built.clear();
        for (const voxel of voxels) {
            built.set(keyOf(voxel), voxel);
        }
        target = undefined;
        retarget();
        render();
    }

    /** Shows the message of a load that failed on the field, or clears it. */
    function showFault(message: string): void {
        fault.textContent = message;
        if (message) {
            field.setAttribute('aria-invalid', 'true');
            status.textContent = message;
        } else {
            field.removeAttribute('aria-invalid');
        }
    }

    /** Writes the build into the field and points the download link at it. */
    function save(): void {
        const text = writeBuild([...built.values()]);
        field.value = text;
        showFault('');
        if (fileUrl) {
            URL.revokeObjectURL(fileUrl);
        }
        fileUrl = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
        download.href = fileUrl;
        download.hidden = false;
        status.textContent = `Exported ${plural(built.size)}.`;
    }

    /** Loads the text of a saved file: all of its cubes, or nothing at all. */
    function load(text: string): void {
        const voxels = readBuild(text);
        if (typeof voxels === 'string') {
            showFault(voxels);
            return;
        }
        showFault('');
        cursor = undefined;
        reset(voxels);
        status.textContent = `Loaded ${plural(voxels.length)}.`;
    }

    // The pointer is kept while no button is down, so the target follows the
    // scene through a glide; during a drag there is no target. A move takes
    // over from the keyboard's cursor.
    followPointer(svg, (at, left) => {
        hovering = at;
        cursor = left ? cursor : undefined;
        retarget();
    });

    // d3.drag swallows the click that ends a drag, so turning the scene
    // never builds. The target is picked from the click itself, so a tap
    // builds without a hover first.
    svg.on('click', (event: MouseEvent) => {
        const at = targetAt(event);
        if (at) {
            apply(at);
        }
    });

    // The arrow keys walk a cursor column over the floor; Enter builds on
    // top of it, or erases or paints its top cube, and Delete takes its top
    // cube away.
    svg.on('keydown', (event: KeyboardEvent) => {
        // Cmd and Ctrl keep their browser shortcuts, such as Cmd+P to print.
        // AltGr reports Ctrl too, and types characters on many layouts.
        if (event.metaKey || (event.ctrlKey && !event.getModifierState('AltGraph'))) {
            return;
        }
        const move = MOVES[event.key];
        if (move) {
            event.preventDefault();
            hovering = undefined;
            cursor = cursor
                ? { column: clamp(cursor.column + move[0]), row: clamp(cursor.row + move[1]) }
                : { column: HALF, row: HALF };
            const height = columnTarget(cursor.column, cursor.row, 'erase')?.layer ?? -1;
            status.textContent = `Column ${cursor.column + 1}, row ${cursor.row + 1}: ${height + 1} high.`;
            retarget();
            return;
        }
        if ((event.key === 'Enter' || event.key === ' ') && target) {
            event.preventDefault();
            apply(target);
        } else if ((event.key === 'Delete' || event.key === 'Backspace') && cursor) {
            event.preventDefault();
            const top = columnTarget(cursor.column, cursor.row, 'erase');
            if (top) {
                apply(top);
            }
        } else if (Number(event.key) >= 1 && Number(event.key) <= COLORS.length) {
            setColor(Number(event.key) - 1);
        } else if (event.key === 'e' || event.key === 'E') {
            setMode(mode === 'erase' ? 'build' : 'erase');
        } else if (event.key === 'p' || event.key === 'P') {
            setMode(mode === 'paint' ? 'build' : 'paint');
        } else if (event.key === 'Escape') {
            cursor = undefined;
            retarget();
        }
    });

    for (const input of colorInputs) {
        input.addEventListener('change', () => setColor(Number(input.value)));
    }

    for (const input of modeInputs) {
        input.addEventListener('change', () => setMode(input.value as Mode));
    }

    exportButton.addEventListener('click', save);
    loadButton.addEventListener('click', () => load(field.value));

    // A file goes through the same loader as a pasted text. Its size is
    // checked before it is read, and its text lands in the field.
    fileInput.addEventListener('change', async () => {
        const file = fileInput.files?.[0];
        fileInput.value = '';
        if (!file) {
            return;
        }
        if (file.size > MAX_TEXT) {
            showFault('The file is over 64 KB.');
            return;
        }
        const text = await file.text();
        field.value = text;
        load(text);
    });

    button.text('reset').on('click', () => {
        reset();
        status.textContent = 'Back to the starter build.';
    });

    reset();

    return () => {
        if (fileUrl) {
            URL.revokeObjectURL(fileUrl);
        }
        host.innerHTML = '';
        host.classList.remove('voxel-builder');
    };
}

// The styles of the figure, scoped by the class of its host. VitePress puts
// the class `dark` on the root element in dark mode.
const STYLES = `
.voxel-builder {
    --vb-ink: #1b1e21;
    --vb-muted: #5f6468;
    --vb-button: #f0f1f1;
    --vb-line: #c4c7c9;
    --vb-bg: #ffffff;
    --vb-floor: #eef0f1;
    --vb-grid: #b3b8bc;
    --vb-erase: #d03a3a;
    --vb-color-0: #2a78d6;
    --vb-color-1: #eb6834;
    --vb-color-2: #1baf7a;
    --vb-color-3: #eda100;
    --vb-color-4: #8a5cd6;
    --vb-color-5: #d64560;
    margin: 1.5rem 0;
    position: relative;
    overflow: hidden;
    border: 1px solid var(--vb-line);
    border-radius: 12px;
    background: var(--vb-bg);
    color: var(--vb-ink);
}

.dark .voxel-builder {
    --vb-ink: #e6e8e9;
    --vb-muted: #9aa0a4;
    --vb-button: #17181a;
    --vb-line: #3d4144;
    --vb-bg: #0c0d0e;
    --vb-floor: #1a1c1e;
    --vb-grid: #43484c;
    --vb-erase: #f06060;
    --vb-color-0: #3987e5;
    --vb-color-1: #d95926;
    --vb-color-2: #199e70;
    --vb-color-3: #c98500;
    --vb-color-4: #9a70e0;
    --vb-color-5: #e05a72;
}

.voxel-builder .voxel-svg {
    display: block;
    width: 100%;
    height: auto;
    cursor: crosshair;
    touch-action: none;
}

.voxel-builder .voxel-svg:active {
    cursor: grabbing;
}

/* The browser rings a focused SVG on every click: keep the ring for the keyboard. */
.voxel-builder .voxel-svg:focus {
    outline: none;
}

.voxel-builder .voxel-svg:focus-visible {
    outline: 2px solid var(--vb-ink);
    outline-offset: -4px;
}

.voxel-builder .floor {
    fill: var(--vb-floor);
}

.voxel-builder .voxel-floor line {
    stroke: var(--vb-grid);
    stroke-width: 0.75;
}

/* The color picks the hue, the face its shade, so the figure never sets a fill. */
.voxel-builder .color-0 {
    --vb-base: var(--vb-color-0);
}

.voxel-builder .color-1 {
    --vb-base: var(--vb-color-1);
}

.voxel-builder .color-2 {
    --vb-base: var(--vb-color-2);
}

.voxel-builder .color-3 {
    --vb-base: var(--vb-color-3);
}

.voxel-builder .color-4 {
    --vb-base: var(--vb-color-4);
}

.voxel-builder .color-5 {
    --vb-base: var(--vb-color-5);
}

.voxel-builder .face {
    fill: var(--vb-base);
    stroke: color-mix(in srgb, var(--vb-base) 50%, black);
    stroke-width: 0.75;
    stroke-linejoin: round;
}

.voxel-builder .face-front,
.voxel-builder .face-back {
    fill: color-mix(in srgb, var(--vb-base) 82%, black);
}

.voxel-builder .face-left,
.voxel-builder .face-right {
    fill: color-mix(in srgb, var(--vb-base) 66%, black);
}

/* The ghost shows where the next cube goes; it never takes the pointer, so
   the pointer always finds the cube or the floor behind it. */
.voxel-builder .ghost {
    pointer-events: none;
}

.voxel-builder .ghost .face {
    fill-opacity: 0.35;
    stroke-dasharray: 3 2;
}

.voxel-builder .is-target .face {
    fill: color-mix(in srgb, var(--vb-erase) 70%, var(--vb-base));
    stroke: var(--vb-erase);
    stroke-width: 1.5;
}

/* A paint shows the color it gives under a dashed outline, a pick has a solid one. */
.voxel-builder .is-paint .face {
    stroke: var(--vb-ink);
    stroke-width: 1.5;
    stroke-dasharray: 4 3;
}

.voxel-builder .is-pick .face {
    stroke: var(--vb-ink);
    stroke-width: 2.5;
}

.voxel-builder .voxel-button,
.voxel-builder .voxel-save button {
    padding: 0.35rem 0.9rem;
    border: 1px solid var(--vb-line);
    border-radius: 8px;
    background: var(--vb-button);
    color: var(--vb-ink);
    font-size: 0.85rem;
    cursor: pointer;
}

.voxel-builder .voxel-button {
    position: absolute;
    top: 0.75rem;
    right: 0.75rem;
}

.voxel-builder .voxel-button:hover,
.voxel-builder .voxel-save button:hover {
    border-color: var(--vb-ink);
}

.voxel-builder .voxel-controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem 1.5rem;
    padding: 0.75rem;
    border-top: 1px solid var(--vb-line);
    font-size: 0.85rem;
}

.voxel-builder fieldset {
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 0;
    padding: 0;
    border: 0;
}

.voxel-builder legend {
    float: left;
    margin-right: 4px;
    color: var(--vb-muted);
}

.voxel-builder .swatch {
    appearance: none;
    width: 20px;
    height: 20px;
    margin: 0;
    border: 2px solid var(--vb-bg);
    border-radius: 50%;
    outline: 1px solid var(--vb-line);
    background: var(--vb-base);
    cursor: pointer;
}

.voxel-builder .swatch:checked {
    outline: 2px solid var(--vb-ink);
}

.voxel-builder .swatch:focus-visible {
    outline: 3px solid var(--vb-ink);
}

.voxel-builder .voxel-readout {
    flex: 1 1 16rem;
    margin: 0;
    color: var(--vb-muted);
    font-variant-numeric: tabular-nums;
}

.voxel-builder .voxel-readout span:nth-child(2) {
    color: var(--vb-ink);
}

/* The save panel keeps the row short while it is closed, and takes a line of its own when open. */
.voxel-builder .voxel-save[open] {
    flex-basis: 100%;
}

.voxel-builder .voxel-save summary {
    color: var(--vb-muted);
    cursor: pointer;
}

.voxel-builder .voxel-save > label {
    display: grid;
    gap: 0.25rem;
    margin-top: 0.5rem;
    color: var(--vb-muted);
}

.voxel-builder .voxel-save textarea {
    box-sizing: border-box;
    width: 100%;
    padding: 0.5rem;
    border: 1px solid var(--vb-line);
    border-radius: 8px;
    background: var(--vb-bg);
    color: var(--vb-ink);
    font-family: var(--vp-font-family-mono, monospace);
    font-size: 0.8rem;
    resize: vertical;
}

.voxel-builder .voxel-save textarea[aria-invalid='true'] {
    border-color: var(--vb-erase);
    outline: 1px solid var(--vb-erase);
}

.voxel-builder .voxel-save-fault {
    margin: 0.25rem 0 0;
    color: var(--vb-erase);
}

.voxel-builder .voxel-save-fault:empty {
    display: none;
}

.voxel-builder .voxel-save-actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem 1rem;
    margin-top: 0.5rem;
}

.voxel-builder .voxel-save-actions label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
    max-width: 100%;
    color: var(--vb-muted);
}

.voxel-builder .voxel-save [type='file'] {
    max-width: 100%;
    color: var(--vb-muted);
    font: inherit;
}

.voxel-builder .voxel-save a {
    color: var(--vb-ink);
    text-decoration: underline;
}

/* At phone width the panel takes a line of its own, open or closed. */
@media (max-width: 640px) {
    .voxel-builder .voxel-save {
        flex-basis: 100%;
    }
}
`;

Released under the BSD-3-Clause License. Sponsor d3-3d on GitHub Sponsors.