# Glyphcss -- Full Documentation > An ASCII polygon mesh renderer for the DOM. Projects 3D meshes into a monospace character grid. --- # Headless API The `glyphcss` vanilla package exposes a glyph-shaped imperative API plus the raw `@glyphcss/core` math primitives. ## `createGlyphScene` The main entry point. Creates and mounts a scene into a host element and returns a `GlyphSceneHandle` with methods to add meshes, hotspots, and update options. **Always construct a camera first and pass it in.** ```ts import { createGlyphCamera, createGlyphScene } from "glyphcss"; const host = document.querySelector("#scene")!; const camera = createGlyphCamera({ rotX: 25, zoom: 50 }); const scene = createGlyphScene(host, { camera, mode: "solid", // "wireframe" | "solid" | "voxel" | "ink" cols: 100, // grid width in character columns rows: 30, // grid height in character rows cellAspect: 2.0, // cell height ÷ width (typical monospace: ~2.0) glyphPalette: "default", // named glyph palette useColors: true, // emit color s in output directionalLight: { direction: [0.5, 0.7, 0.5], intensity: 1 }, ambientLight: { intensity: 0.4 }, }); ``` ### `.add(polygons, transform?)` Register a `Polygon[]` as a mesh. Returns a `GlyphMeshHandle`. ```ts import { cubePolygons } from "@glyphcss/core"; const meshHandle = scene.add( cubePolygons({ center: [0, 0, 0], size: 1, color: "#4488ff" }), { position: [1, 0, 0], // translate in world space scale: 0.8, // uniform scale (or [sx, sy, sz]) rotation: [0, 45, 0], // XYZ Euler angles in degrees } ); // Later, update the transform and re-render: meshHandle.setTransform({ position: [2, 0, 0] }); scene.rerender(); // Replace the mesh geometry in place: meshHandle.setPolygons(nextPolygons); // Remove the mesh: meshHandle.dispose(); ``` ### `.setOptions(partial)` Update any scene option. Triggers an automatic re-render. ```ts scene.setOptions({ mode: "wireframe", cols: 120, rows: 36, directionalLight: { direction: [1, 0.5, 0.2], intensity: 1.2 }, }); ``` ### `.destroy()` Remove the scene DOM and clear all registered meshes and hotspots. ```ts scene.destroy(); ``` ## End-to-end example The following snippet covers `add`, `setOptions`, `addHotspot`, and `destroy` in one runnable block: ```ts import { createGlyphCamera, createGlyphScene, createGlyphOrbitControls, } from "glyphcss"; import { cubePolygons, octahedronPolygons, axesHelperPolygons } from "@glyphcss/core"; const host = document.querySelector("#scene")!; // 1. Create the camera first. const camera = createGlyphCamera({ rotX: 25, zoom: 50 }); // 2. Create the scene with the camera. const scene = createGlyphScene(host, { camera, mode: "solid", cols: 100, rows: 30 }); // 3. Add meshes. Each call returns a handle. const cubeHandle = scene.add( cubePolygons({ center: [-1, 0, 0], size: 0.8, color: "#4488ff" }) ); const octaHandle = scene.add( octahedronPolygons({ center: [1, 0, 0], size: 0.8, color: "#ffcc44" }) ); scene.add(axesHelperPolygons({ size: 1.5 })); // 4. Add a hotspot. const hotspot = scene.addHotspot( { id: "octa-top", at: [1, 0.8, 0], size: [4, 2] }, () => alert("octahedron top"), ); // 5. Attach orbit controls. const controls = createGlyphOrbitControls(scene, { drag: true, wheel: true }); // 6. Update options at any time. scene.setOptions({ mode: "wireframe" }); // 7. Move a mesh. cubeHandle.setTransform({ position: [-2, 0, 0] }); scene.rerender(); // 8. Cleanup everything. hotspot.remove(); controls.destroy(); octaHandle.dispose(); cubeHandle.dispose(); scene.destroy(); ``` ## Camera factories Two cameras: orthographic (default, `createGlyphCamera`) and perspective. First-person view is `createGlyphPerspectiveCamera` + `createGlyphFirstPersonControls`. ```ts import { createGlyphCamera, createGlyphPerspectiveCamera, createGlyphOrthographicCamera, } from "glyphcss"; // Orthographic — default alias, best for voxel/iso scenes const camera = createGlyphCamera({ rotX: 25, zoom: 50 }); // Orthographic — explicit form const ortho = createGlyphOrthographicCamera({ rotX: 25, zoom: 50 }); // Perspective — foreshortened; required for first-person controls const perspective = createGlyphPerspectiveCamera({ rotX: 25, rotY: 0, distance: 3, zoom: 50, stretch: 0.95, }); // All camera properties are mutable after creation: perspective.rotY = 45; perspective.distance = 4; // Swap the camera on a running scene: scene.setOptions({ camera: ortho }); ``` ## Lower-level pieces `@glyphcss/core` is browser-agnostic (no DOM globals) and can run in Node or a web worker. The rasterizer itself lives in `glyphcss` — it is pure too (geometry + camera → string), it just ships with the renderer rather than with core. ```ts import { icosahedronPolygons } from "@glyphcss/core"; import { buildRasterizeContext, rasterize, projectHotspots, createGlyphPerspectiveCamera, } from "glyphcss"; const polygons = icosahedronPolygons({ center: [0, 0, 0], size: 1, color: "#44ffcc" }); const camera = createGlyphPerspectiveCamera({ rotX: 25, zoom: 50 }); const grid = { cols: 160, rows: 48, cellAspect: 2.0 }; // `RasterizeContextOptions` — `polygons` is required for solid/voxel, and // wireframe edges are derived from it when `wireframe` is omitted. const ctx = buildRasterizeContext({ camera, grid, polygons, mode: "solid" }); // Rasterize one frame to a string: const text = rasterize(ctx); // Project hotspots to grid cells: const cells = projectHotspots( [{ id: "top", at: [0, 1, 0] }], camera, 160, 48, 2.0, ); // cells is { id, col, row, depth, visible }[] ``` `@glyphcss/core` owns the geometry and math — polygon generators (~44 named shapes plus `resolveGeometry`), parsers, mesh ops, and `buildSceneContext({ polygons })` for scene composition. It does not own the rasterizer. ## Static compile `compileScene` renders a scene to its `
` string with **no DOM** — same
defaults as `createGlyphScene`, byte-identical output. The foundation of the
build-time toolchain (see [Compiling to Static](/guides/compile)).

```ts
import { compileScene } from "glyphcss";

const { html, inner, cols, rows } = compileScene({ polygons, cols: 80, rows: 24 });
```

For interactive exports, `buildGlyphInteractiveExport(polygons, { interactions })`
(pure, browser-safe) emits a self-contained snippet shipping only the declared
control + a decimated mesh; `glyphCodepenPrefill` turns it into a CodePen POST.
Mesh simplification is available standalone as `decimatePolygons(polygons, { grid })`
from `@glyphcss/core`.

The Node file pipeline (`loadMeshFromFile`, `compileFile`, the Vite plugin, the
`glyphcss` CLI) lives in `@glyphcss/compile`.


---

# HTML API

`glyphcss/elements` is a side-effect import that registers a set of
`` custom elements with `customElements`. After the import runs,
you can use them in plain HTML — no React, no Vue, no build step required.

## Install

```bash
pnpm add glyphcss
```

```html

```

Or in a bundled app:

```ts
import "glyphcss/elements"; // side-effect — registers customElements
```

Re-imports are idempotent (the module checks `customElements.get` before
defining). In non-DOM environments (SSR, Node) the module silently no-ops.

## Element tree

Camera wraps scene. Attribute names are kebab-case (`rot-x`, `rot-y`,
`hidden-lines`, `auto-size`). Boolean attributes follow standard HTML
convention — present means true.

```html

  
    
    
    
  

```

## Elements

### ``

Root container. Owns the `
` rasterisation output and the lighting state.

| Attribute | Type | Notes |
|---|---|---|
| `mode` | `"wireframe" \| "solid" \| "voxel" \| "ink"` | Default `solid` |
| `cols` | number | Grid width in character columns |
| `rows` | number | Grid height in character rows |
| `cell-aspect` | number | Height ÷ width of the cell (default `2.0`) |
| `glyph-palette` | string | Named palette: `default`, `detail`, `ascii`, `lines`, `blocks`, `dots`, `solid`, `braille`, `runes`, `stars`, `arrows`, `math`, `binary`, `hex` |
| `char-mode` | `"ascii" \| "braille" \| "halfblock" \| "quadrant"` | Character encoding for rasterized output (default `"ascii"`). `"braille"` (wireframe-only) packs a 2×4 subcell dot grid into Unicode Braille Patterns for smoother edges. `"halfblock"` (solid-only) packs two independently colored subcells (top/bottom) into `▀`/`▄`/`█` for 2× vertical color resolution. `"quadrant"` (solid-only) generalizes that to a full 2×2 subcell split (16 possible glyphs) for both shape AND color resolution. Each is a documented no-op outside the mode it applies to |
| `use-colors` | bool | Emit color spans in the output |
| `wireframe-junctions` | flag | Box-drawing junction resolve pass (wireframe + `char-mode="ascii"` only): corners/T-junctions/crossings render from `┌┐└┘├┤┬┴┼─│` instead of a random per-edge glyph |
| `hidden-lines` | `"show" \| "hide"` | Hidden-line removal for wireframe (+ braille) and `ink`. Default `show`. No-op in `solid`, which is already depth-buffered |
| `color-tolerance` | number | Merge adjacent cells into one `` while their colors stay within this redmean distance (range `0`–`765`, not `0`–`255`). Default `0` (off, byte-identical). `NaN`/negative values degrade to `0`; `+Infinity`/`-Infinity` are recognized literally (as strings) and behave the same as the JS surface. No-op under `glyph-output="semantic"` — semantic colors are exact class identifiers, not shaded appearance |
| `glyph-output` | `"visible" \| "semantic"` | Default `visible`. `semantic` needs `sceneManifest` + `dictionary`, which are set as JS **properties** (they are data, not string attributes) |
| `directional-direction` | `"x,y,z"` | Key-light source vector — the direction from the shaded surface *toward* the light. Comma-separated, like ``. Either this or `directional-intensity` configures the light; the other falls back to its default |
| `directional-intensity` | number | Key-light intensity |
| `ambient-intensity` | number | Ambient fill intensity |
| `auto-size` | flag | Auto-fit `cols`/`rows` to the host's box via ResizeObserver |
| `interactive-downscale` | number | Render at `1/n` resolution while dragging, full detail on release (e.g. `2`). Keeps high-density scenes smooth. Default `1` (off) |
| `shadow` | flag | Enable shadow-map pass. Must be present for any shadows to render |
| `shadow-color` | string | Shadow tint hex color (default `"#000000"`) |
| `shadow-opacity` | number | Shadow darkness 0..1 (default `0.25`) |
| `shadow-lift` | number | Depth bias — prevents self-shadow acne (default `0.05`) |
| `shadow-max-extend` | number | Half-extent of the light-space projection volume (default `2000`) |

`solidWeightRamp` (solid-mode-only font-weight density ramp — see the
[render modes guide](/guides/render-modes/#font-weight-density-ramp-solidweightramp))
is a JS property, not an attribute — it is measurement data
(`{ glyph: string; weight: number }[]`), the same "property, not attribute"
rule `sceneManifest`/`dictionary` use: `document.querySelector("glyph-scene").solidWeightRamp = steps`.

### `` / `` / ``

`` is the ergonomic default — an alias for
``. All three accept the same orientation
attributes; perspective additionally accepts `distance`, `perspective`, and `stretch`.

| Attribute | Notes |
|---|---|
| `rot-x` | Pitch in degrees |
| `rot-y` | Yaw in degrees |
| `zoom` | CSS pixels per world unit |
| `distance` | Perspective only — pull-back; CSS pixels with CSS perspective, world units only in legacy `perspective="0"` mode |
| `perspective` | Perspective only — CSS-perspective distance in px (default 32000; `0` = legacy orbit) |
| `stretch` | Perspective only |

### ``

Polygon registration. Picks one source in descending precedence: `geometry`
< `src` < explicit polygons (only available via JS property, not attribute).

| Attribute | Notes |
|---|---|
| `src` | URL of OBJ / GLB / glTF / VOX mesh |
| `geometry` | Built-in name from `@glyphcss/core` registry (`cube`, `dodecahedron`, …) |
| `size` | Uniform size for `geometry` |
| `color` | Fill color for `geometry` |
| `position` | `x,y,z` translation |
| `scale` | `s` or `sx,sy,sz` |
| `rotation` | `rx,ry,rz` XYZ Euler degrees |
| `auto-center` | flag — recenter the mesh's bbox center to the origin so it pivots around its own center (center only) |
| `cast-shadow` | flag — this mesh casts shadows onto `receive-shadow` surfaces |
| `receive-shadow` | flag — this mesh displays shadows from `cast-shadow` meshes. Present on both = self-shadow |
| `density` | number — render this mesh at `density`× the scene resolution, in its own `
` (see the [Density & Detail guide](/guides/density/)) |
| `font-size` | explicit cell size (px or CSS length); overrides `density` |
| `line-height` | explicit cell line-height; overrides `density` |
| `transparent` | flag — see-through: doesn't occlude / isn't occluded |
| `mode` | `"wireframe" \| "solid" \| "voxel" \| "ink"` — render this mesh in its OWN mode, in its own `
`, when it differs from the scene's (see the [Density & Detail guide](/guides/density/)). An unrecognized value is ignored |

### ``

3D-anchored DOM hotspot. Child nodes are positioned at the projected screen
cell every render.

| Attribute | Notes |
|---|---|
| `hotspot-id` | Identifier for handle lookup |
| `at` | `x,y,z` anchor in world space |
| `size` | `w,h` in cells |

### ``

Mounts a retained appearance program inside ``. The executable
definition is a JavaScript value, so use the atomic property API rather than a
JSON attribute:

```ts
import { GlyphEffects } from "@glyphcss/effects";

const effect = document.querySelector("glyph-effect-layer")!;
effect.configure({
  effect: GlyphEffects.matrixRain,
  params: { glyphs: "HOLA", speedMin: 5, speedMax: 12 },
});
const handle = await effect.whenReady();
handle.params.time = performance.now() / 1000;
```

Attributes `target="surfaces|viewport"`, `blend="over|replace"`, `opacity`,
`order`, and `enabled="false"` override the matching configured option. The
element emits `glyphcss:effect-ready` and exposes `getEffectHandle()`. See
[Glyph Effects](/guides/effects) for the catalog, clocks, and current runtime
boundary.

### `` / ``

Camera controls. Orbit rotates around the target; map pans across the
target plane.

| Attribute | Notes |
|---|---|
| `drag` | flag — enable drag |
| `wheel` | flag — enable wheel zoom |
| `invert` | flag or number — invert axis multiplier |
| `clamp-pitch` | flag — stop the orbit pitch from passing over the poles |
| `animate-speed` | Orbit-only: auto-rotation speed |
| `animate-axis` | Orbit-only: `x` \| `y` |

### ``

First-person camera (requires a ``). Pointer-lock
mouselook, WASD/arrow move, Space jump, Ctrl crouch.

| Attribute | Notes |
|---|---|
| `look-enabled` / `move-enabled` / `jump-enabled` / `crouch-enabled` | flags — toggle each axis |
| `look-sensitivity` | degrees per pixel (default `0.15`) |
| `invert-y` | flag — invert vertical look |
| `move-speed` / `jump-velocity` / `gravity` | world-unit motion params |
| `eye-height` / `crouch-height` / `ground-z` | world-unit placement |
| `min-pitch` / `max-pitch` | pitch clamp (degrees) |

## Scene-ready handshake

Custom elements register asynchronously. If you need to call imperative
methods on the scene from JavaScript, listen for `glyphcss:scene-ready` on
the `` element:

```ts
const sceneEl = document.querySelector("glyph-scene")!;
sceneEl.addEventListener("glyphcss:scene-ready", (ev) => {
  const scene = (ev.target as any).getScene();
  scene.addHotspot({ id: "runtime", at: [0, 0, 1] }, () => alert("clicked"));
});
```

## End-to-end example

```html


  
    
    
  
  
    
      
        
        
        
          top
        
      
    
  

```

## See also

- [React API](/api/react) — same elements, JSX form
- [Vue API](/api/vue) — same elements, idiomatic Vue
- [Headless API](/api/headless) — the imperative factory the elements wrap


---

# React API

`@glyphcss/react` is the React binding for the ASCII paint backend. It is a
thin layer over the imperative `glyphcss` factory API — components register
meshes / hotspots / cameras with the surrounding ``; nothing
re-renders per frame.

The React surface mirrors the Vue surface one-to-one. Anything you can do
in React you can do in Vue, with the same option names and defaults.

## Install

```bash
pnpm add @glyphcss/react
```

## Component tree

A scene is always **camera-wraps-scene**. The camera component owns the
projection state; `` rasterises into a `
`; meshes, controls
and hotspots are children of the scene.

```tsx

  
    
    
    
  

```

## Components

| Component | Role |
|---|---|
| [``](/components/glyph-scene) | Root container — owns the `
`, grid, glyph palette, lighting |
| `` | SSR/SSG — renders the compiled `
` with no runtime ([Compiling to Static](/guides/compile)) |
| `` | Default camera (alias for ``) |
| `` | Foreshortened projection — needs a `distance` |
| `` | Parallel projection — best for iso / voxel scenes |
| `` | Polygon registration. Pass `polygons={…}`, `src="…"`, or `geometry="cube"` |
| [``](/guides/effects) | Scene-root retained appearance layer; exposes its stable core handle through a ref |
| `` | Horizontal ground plane. `receiveShadow` defaults to `true`; `castShadow` defaults to `false` |
| `` | 3D-anchored DOM hotspot — projects to a screen cell |
| `` | Drag-rotate + wheel-zoom around the target |
| `` | Drag-pan + wheel-zoom across the target plane |
| `` | WASD + pointer-lock first-person camera |
| `` | Renders world axes as a mesh |
| `` | Visualises the directional light vector |

## Hooks

| Hook | Returns |
|---|---|
| `useGlyphSceneContext()` | The scene + camera handles available to any descendant of `` |
| `useGlyphCamera()` | The camera handle from the surrounding camera component |
| `useGlyphMesh(polygons, options?)` | Imperative handle for a mesh (lower-level than ``) |
| `useGlyphAnimation(opts)` | Drive a `GlyphAnimationClip` from a mesh handle |

## `` shortcuts

`` accepts three mutually-exclusive geometry inputs, in
descending precedence:

```tsx
// 1. Explicit polygons — full control


// 2. File URL — fetched + parsed by the runtime (OBJ / GLB / glTF / VOX)


// 3. Built-in geometry name — resolves via @glyphcss/core registry

```

The transform props (`position`, `scale`, `rotation`) apply to whichever
source you pick. `rotation` is an XYZ Euler triple in degrees.

Shadow props on ``:

| Prop | Type | Default | Description |
|---|---|---|---|
| `castShadow` | `boolean` | `false` | This mesh casts shadows onto `receiveShadow` surfaces |
| `receiveShadow` | `boolean` | `false` | This mesh displays shadows from `castShadow` meshes. A mesh that is both casts and receives self-shadows |

Detail / density props on `` (see the [Density & Detail guide](/guides/density/)):

| Prop | Type | Default | Description |
|---|---|---|---|
| `density` | `number` | `1` | Render this mesh at `density`× the scene resolution, in its own `
` |
| `fontSize` | `number \| string` | — | Explicit cell size (px or CSS length). Overrides `density` |
| `lineHeight` | `number` | — | Explicit cell line-height. Overrides `density` |
| `transparent` | `boolean` | `false` | See-through: doesn't occlude / isn't occluded |

Shadow prop on ``:

| Prop | Type | Default | Description |
|---|---|---|---|
| `shadow` | `GlyphShadowOptions` | `undefined` | Shadow-map config. `undefined` = shadows off. See [`GlyphShadowOptions`](/api/types#shadows) |

## End-to-end example

```tsx
import {
  GlyphPerspectiveCamera,
  GlyphScene,
  GlyphMesh,
  GlyphHotspot,
  GlyphOrbitControls,
  GlyphAxesHelper,
} from "@glyphcss/react";

const directionalLight = { direction: [0.5, 0.7, 0.5], intensity: 1 };
const ambientLight = { intensity: 0.4 };

export function App() {
  return (
    
      
        
        
        
          
            top
          
        
      
    
  );
}
```

## Re-exports from `@glyphcss/core`

`@glyphcss/react` re-exports a curated slice of `@glyphcss/core` — the polygon
factories (`cubePolygons`, `dodecahedronPolygons`, …), the `resolveGeometry`
registry, the math primitives, and the mesh parsers (`loadMesh`, `parseObj`,
`parseGltf`, `parseVox`, `parseStl`). It is not the *whole* surface: mesh ops
such as `decimatePolygons`, `dedupeOverlappingPolygons` and `recenterPolygons`,
plus the quaternion helpers, are only in `@glyphcss/core` — import those from
there directly.

## See also

- [Vue API](/api/vue) — same surface, idiomatic Vue
- [HTML API](/api/html) — `` custom elements
- [Headless API](/api/headless) — the imperative factory the bindings wrap


---

# Three.js Parity API

The `*/three` subpaths expose a Three-like authoring surface on top of glyphcss.
Use them when you are porting a Three.js scene, writing docs for coding agents,
or want familiar `PerspectiveCamera`, `Object3D`, `Vector3`, `lookAt`, and
radian-based mesh transforms.

This is still glyphcss: it rasterises polygons into a character grid. It does
not run Three.js at runtime, and it does not use WebGL. The parity API only
matches Three's parameter surface closely enough that the same scene math frames
the same object with the same projection, orientation, depth ordering, and light
direction/color/intensity.

## Imports

| Package | Import | Use |
|---|---|---|
| Core math | `@glyphcss/core/three` | Three-like classes and coordinate transforms |
| Vanilla / static | `glyphcss/three` | Core parity surface plus `compileScene`, `loadMesh`, and geometry helpers |
| React | `@glyphcss/react/three` | React components and parity classes |
| Vue | `@glyphcss/vue/three` | Vue components and parity classes |

## Conventions

The native glyphcss API uses glyphcss/voxcss conventions: Z-up scene math, camera
rotations in degrees, and `zoom` as CSS pixels per world unit.

The `*/three` subpaths intentionally use Three-style conventions:

| Value | `*/three` convention |
|---|---|
| Coordinates | Y-up authoring space |
| Mesh rotations | Radians, XYZ Euler |
| Cameras | Three-like `PerspectiveCamera(fov, aspect, near, far)` and `OrthographicCamera(left, right, top, bottom, near, far)` |
| Camera targeting | `camera.position.set(...)` + `camera.lookAt(...)` |
| Directional lights | Source vector from `light.target.position` toward `light.position`; color and intensity are preserved |
| Ambient lights | Color and intensity are preserved |

Internally, geometry is converted into glyphcss space with
`transformPolygonsToGlyph`. The axis conversion is right-handed, so polygon
winding and Lambert lighting stay correct.

GlyphCSS native directional lights store the source vector from the shaded
surface toward the distant light. `DirectionalLight.toGlyphDirectionalLight()`
converts the Three.js source/target pair into that native convention and
preserves the light color and intensity unchanged.
`AmbientLight.toGlyphAmbientLight()` also preserves color and intensity
unchanged.

Polygon arrays passed to `transformPolygonsToGlyph` or `` are
interpreted as Three/Y-up coordinates. If you already have native glyphcss
polygons with their own native transform, render them with the native API instead
of converting them again.

## Static / vanilla example

`glyphcss/three` is the smallest path for build-time output, tests, CLIs, and
agent-generated snippets. The scene below is authored with Three-like camera and
transform objects, then rendered by `compileScene`.

```ts
import {
  AmbientLight,
  DirectionalLight,
  Object3D,
  PerspectiveCamera,
  Vector3,
  compileScene,
  cubePolygons,
  transformPolygonsToGlyph,
} from "glyphcss/three";

const camera = new PerspectiveCamera(50, 16 / 9, 0.1, 100);
camera.position.set(3, 2, 5);
camera.lookAt(0, 0, 0);

const object = new Object3D();
object.position.set(0, 0.5, 0);
object.rotation.set(0, Math.PI / 4, 0);
object.scale.set(1, 1, 1);

const light = new DirectionalLight("#ffffff", 1);
light.position.set(3, 5, 4);
light.target.position.set(0, 0, 0);

const polygons = transformPolygonsToGlyph(
  cubePolygons({ center: [0, 0, 0], size: 1, color: "#66aaff" }),
  object,
);

const { html } = compileScene({
  polygons,
  camera,
  cols: 96,
  rows: 36,
  cellAspect: 2,
  mode: "solid",
  useColors: true,
  ambientLight: new AmbientLight("#ffffff", 0.35).toGlyphAmbientLight(),
  directionalLight: light.toGlyphDirectionalLight(),
});
```

## React example

The React parity components wrap the normal ``. Keep the scene and
controls from `@glyphcss/react`, and import the Three-like cameras / meshes from
`@glyphcss/react/three`.

```tsx
import { GlyphOrbitControls, GlyphScene } from "@glyphcss/react";
import {
  DirectionalLight,
  GlyphThreeMesh,
  GlyphThreePerspectiveCamera,
} from "@glyphcss/react/three";

const sun = new DirectionalLight("#ffffff", 1);
sun.position.set(3, 5, 4);
sun.target.position.set(0, 0, 0);

export function App() {
  return (
    
      
        
        
      
    
  );
}
```

`` accepts the same mesh source shortcuts as native
``: `polygons`, `src`, or `geometry`. It also supports
`castShadow`, `receiveShadow`, `density`, `fontSize`, `lineHeight`, and
`transparent`.

## Vue example

Vue mirrors the React parity surface.

```vue



```

## Core exports

All `*/three` subpaths share these core exports:

| Export | Purpose |
|---|---|
| `Vector3` | Minimal Three-like vector class used by cameras, objects, and lights |
| `Euler` | XYZ Euler rotation in radians |
| `Object3D` | `position`, `rotation`, `scale`, `up`, `lookAt`, and `localToWorld` |
| `PerspectiveCamera` | Three-like perspective camera that glyphcss can render with |
| `OrthographicCamera` | Three-like orthographic camera that glyphcss can render with |
| `DirectionalLight` | Three-like positioned light with `target`, convertible to glyphcss light options |
| `AmbientLight` | Ambient light helper, convertible to glyphcss light options |
| `threeToGlyphPoint` / `glyphToThreePoint` | Convert individual points between coordinate spaces |
| `threeToGlyphDirection` / `glyphToThreeDirection` | Convert direction vectors between coordinate spaces |
| `transformPointToGlyph` | Apply an `Object3D` transform to one point and convert it |
| `transformPolygonsToGlyph` | Apply an `Object3D` transform to a `Polygon[]` and convert it |

## When not to use it

Use the native API when you are building glyphcss-first scenes, voxel/diagram
views, or examples where degree-based `rotX` / `rotY` camera controls are more
direct. The parity API is for Three-shaped scene authoring and ports; it is not a
replacement for native glyphcss controls or custom elements.


---

# Core Types

All types below are exported from `@glyphcss/core` unless marked with `glyphcss`
(the vanilla package).

## Geometry primitives

| Type | Description | Used in |
|---|---|---|
| `Vec2` | `[u, v]` tuple — UV coordinates | `TextureTriangle.uvs`, polygon UVs |
| `Vec3` | `[x, y, z]` tuple — world-space point or vector | Vertices, directions, hotspot anchors |
| `Polygon` | N coplanar vertices + optional color/texture | Helper generators, mesh parsers |
| `TextureTriangle` | 3 vertices + 3 UV pairs + optional color | Parser-internal type for UV-mapped meshes |
| `WireframeEdge` | `from`/`to` Vec3 pair + optional weight/color | `buildRasterizeContext` wireframe mode (`glyphcss`) |

```ts
type Vec2 = [number, number];
type Vec3 = [number, number, number];

interface Polygon {
  vertices: Vec3[];
  color?: string;
  texture?: string;
  uvs?: Vec2[];
}

interface TextureTriangle {
  vertices: [Vec3, Vec3, Vec3];
  uvs: [Vec2, Vec2, Vec2];
  color?: string;
}

interface WireframeEdge {
  from: Vec3;
  to: Vec3;
  weight?: EdgeWeight;  // 1 | 2 | 3
  color?: string;
}
```

## Render mode and glyph mapping

| Type | Description | Used in |
|---|---|---|
| `RenderMode` | `"wireframe" \| "solid" \| "voxel" \| "ink"` | `RasterizeContextOptions.mode`, all framework props |
| `CharRamp` | `string[]` — index 0 = darkest, last = brightest | Solid-mode Lambert → glyph mapping |
| `EdgeWeight` | `1 \| 2 \| 3` — thin / normal / core | `WireframeEdge.weight` |

```ts
type RenderMode = "wireframe" | "solid" | "voxel" | "ink";
type CharRamp = string[];          // e.g. [" ", ".", ":", "-", "=", "+", "*", "#", "%", "@"]
type EdgeWeight = 1 | 2 | 3;
```

## Lighting

| Type | Description | Used in |
|---|---|---|
| `GlyphDirectionalLight` | Single distant light source | `GlyphSceneOptions.directionalLight` |
| `GlyphAmbientLight` | Uniform fill light | `GlyphSceneOptions.ambientLight` |

```ts
interface GlyphDirectionalLight {
  direction: Vec3;     // source vector from surface toward the distant light
  intensity?: number;  // default 1
  color?: string;      // hex, default white
}

interface GlyphAmbientLight {
  intensity?: number;  // default 0.4
  color?: string;      // hex, default white
}
```

## Shadows

| Type | Description | Used in |
|---|---|---|
| `GlyphShadowOptions` | Shadow-map configuration. `undefined` on the scene = shadows off | `GlyphSceneOptions.shadow`, `` |

```ts
interface GlyphShadowOptions {
  color?: string;      // shadow tint hex; default "#000000"
  opacity?: number;    // darkness 0..1 toward color; default 0.25
  lift?: number;       // depth bias — prevents self-shadow acne; default 0.05
  maxExtend?: number;  // accepted but NOT READ — the volume is fitted to the casters' own bounds
}
```

Shadow flags on meshes (`GlyphMeshTransform`, ``, ``):

| Field / prop / attribute | Default | Description |
|---|---|---|
| `castShadow` / `cast-shadow` | `false` | This mesh casts shadows onto `receiveShadow` surfaces |
| `receiveShadow` / `receive-shadow` | `false` | This mesh receives shadows. Both flags on the same mesh = self-shadow |

`GlyphGround` sets `receiveShadow=true` and `castShadow=false` by default.

Detail / density on meshes (`GlyphMeshTransform`, ``, ``) — see the [Density & Detail guide](/guides/density/):

| Field / prop / attribute | Type | Default | Description |
|---|---|---|---|
| `density` | `number` | `1` (shared grid) | Render this mesh at `density`× the scene resolution, in its own `
` |
| `fontSize` / `font-size` | `number \| string` | — | Explicit cell size (px or CSS length). Overrides `density` |
| `lineHeight` / `line-height` | `number` | — | Explicit cell line-height. Overrides `density` |
| `transparent` | `boolean` | `false` | See-through: doesn't occlude / isn't occluded (pops into its own `
`) |

Browser-only (detail layers measure the live cell size); works with any camera (orthographic, perspective, FPV).

## Grid and scene

| Type | Description | Used in |
|---|---|---|
| `GridSize` | `{ cols, rows, cellAspect }` | `buildRasterizeContext`, camera `project()` |
| `SceneContext` | Normalized polygons + scene bbox + warnings | The `.context` field of `buildSceneContext({ polygons })`'s `SceneContextBuildResult`. The rasterizer takes `RasterizeContext` from `buildRasterizeContext`, not this |

```ts
interface GridSize {
  cols: number;
  rows: number;
  cellAspect: number;  // cellH / cellW — typically ~2.0
}
```

## Hotspots

| Type | Description | Used in |
|---|---|---|
| `Hotspot` | A 3D anchor that projects to a 2D hitbox | `scene.addHotspot()`, `projectHotspots()` |
| `HotspotCell` | Projected result for one frame | `projectHotspots()` return value |

```ts
interface Hotspot {
  id: string;
  at: Vec3;
  size?: [number, number];  // cols × rows in cells; default [1, 1]
}

interface HotspotCell {
  id: string;
  col: number;
  row: number;
  depth: number;    // camera-space Z; use for z-index
  visible: boolean; // false when behind camera or off-grid
}
```

## Parse results

| Type | Description | Used in |
|---|---|---|
| `ParseResult` | Unified output of all polygon-emitting parsers | `parseObj`, `parseGltf`, `loadMesh` |
| `ParseAnimationClip` | Metadata for one animation in a glTF file | `ParseResult.animation.clips` |

```ts
interface ParseResult {
  polygons: Polygon[];
  animation?: ParseAnimationController;
  objectUrls: string[];
  dispose: () => void;    // revoke blob URLs — always call on unmount
  warnings: string[];
  metadata?: {
    triangleCount?: number;
    meshes?: string[];
    materials?: string[];
    animations?: ParseAnimationClip[];
    sourceBytes?: number;
  };
}

interface ParseAnimationClip {
  index: number;
  name: string;
  duration: number;   // seconds
  channelCount: number;
}
```

## Animation

| Type | Description | Used in |
|---|---|---|
| `GlyphAnimationMixer` | Drives one or more animation actions against a mesh target | `createGlyphAnimationMixer()` |
| `GlyphAnimationAction` | Per-clip playback state | `mixer.clipAction()` |

```ts
// Minimal usage — requires a glTF/GLB file with embedded animation clips.
// Replace "/character.glb" with the path to your own animated mesh.
import { createGlyphAnimationMixer, LoopRepeat, loadMesh } from "@glyphcss/core";

const { polygons, animation } = await loadMesh("/character.glb");
const mixer = createGlyphAnimationMixer(meshHandle, animation!);

const action = mixer.clipAction("walk");
action.setLoop(LoopRepeat, Infinity).play();

// In your animation loop (requestAnimationFrame):
mixer.update(deltaSeconds);
```


---

# Vue API

`@glyphcss/vue` is the Vue 3 binding for the ASCII paint backend. It mirrors
the React surface one-to-one — same component names, same prop shapes, same
defaults — with idiomatic Vue equivalents (composables in place of hooks,
`` for hotspot children, kebab-case attributes in templates).

## Install

```bash
pnpm add @glyphcss/vue
```

## Component tree

Camera wraps scene. Meshes, controls and hotspots are children of the
scene. Template attributes are kebab-case (`:rot-x`, `:rot-y`, `:auto-center`).

```vue

  
    
    
    
  

```

## Components

| Component | Role |
|---|---|
| [``](/components/glyph-scene) | Root container — owns the `
`, grid, glyph palette, lighting |
| `` | SSR/SSG — renders the compiled `
` with no runtime ([Compiling to Static](/guides/compile)) |
| `` | Default camera (alias for ``) |
| `` | Foreshortened projection — needs a `distance` |
| `` | Parallel projection — best for iso / voxel scenes |
| `` | Polygon registration. Pass `:polygons="…"`, `src="…"`, or `geometry="cube"` |
| [``](/guides/effects) | Scene-root retained appearance layer; exposes its stable core handle through a template ref |
| `` | Horizontal ground plane. `receive-shadow` defaults to `true`; `cast-shadow` defaults to `false` |
| `` | 3D-anchored DOM hotspot — projects to a screen cell |
| `` | Drag-rotate + wheel-zoom around the target |
| `` | Drag-pan + wheel-zoom across the target plane |
| `` | WASD + pointer-lock first-person camera |
| `` | Renders world axes as a mesh |
| `` | Visualises the directional light vector |

## Composables

| Composable | Returns |
|---|---|
| `useGlyphSceneContext()` | The scene + camera handles available to any descendant of `` |
| `useGlyphMesh(polygons, options?)` | Register a polygon list with the parent scene; returns `{ meshRef, loading }`. Accepts a plain array or a `ref`, and disposes the mesh on unmount |
| `useGlyphCamera()` | The camera handle from the surrounding camera component |
| `useGlyphAnimation(opts)` | Drive a `GlyphAnimationClip` from a mesh handle |

## `` shortcuts

`` accepts three mutually-exclusive geometry inputs, in descending
precedence: explicit `:polygons`, `src` URL, or `geometry` name.

```vue








```

`rotation` is an XYZ Euler triple in degrees, passed as `:rotation="[x, y, z]"`.

Shadow props on ``:

| Prop | Type | Default | Description |
|---|---|---|---|
| `cast-shadow` | `boolean` | `false` | This mesh casts shadows onto `receive-shadow` surfaces |
| `receive-shadow` | `boolean` | `false` | This mesh displays shadows from `cast-shadow` meshes. A mesh that is both casts and receives self-shadows |

Detail / density props on `` (see the [Density & Detail guide](/guides/density/)):

| Prop | Type | Default | Description |
|---|---|---|---|
| `density` | `number` | `1` | Render this mesh at `density`× the scene resolution, in its own `
` |
| `font-size` | `number \| string` | — | Explicit cell size (px or CSS length). Overrides `density` |
| `line-height` | `number` | — | Explicit cell line-height. Overrides `density` |
| `transparent` | `boolean` | `false` | See-through: doesn't occlude / isn't occluded |

Shadow prop on ``:

| Prop | Type | Default | Description |
|---|---|---|---|
| `:shadow` | `GlyphShadowOptions` | `undefined` | Shadow-map config. `undefined` = shadows off. See [`GlyphShadowOptions`](/api/types#shadows) |

## End-to-end example

```vue



```

## Re-exports from `@glyphcss/core`

`@glyphcss/vue` re-exports a curated slice of `@glyphcss/core` — the polygon
factories (`cubePolygons`, `dodecahedronPolygons`, …), the `resolveGeometry`
registry, the math primitives, and the mesh parsers (`loadMesh`, `parseObj`,
`parseGltf`, `parseVox`, `parseStl`). It is not the *whole* surface: mesh ops
such as `decimatePolygons`, `dedupeOverlappingPolygons` and `recenterPolygons`,
plus the quaternion helpers, are only in `@glyphcss/core` — import those from
there directly.

## See also

- [React API](/api/react) — same surface, idiomatic React
- [HTML API](/api/html) — `` custom elements
- [Headless API](/api/headless) — the imperative factory the bindings wrap


---

# GlyphCamera

import { Tabs, TabItem } from '@astrojs/starlight/components';

`` (alias for ``) is the outermost element in
a glyphcss tree. Wrap `` inside it. The renderer and the hit layer both
read from the same camera state — mutating `rotY` updates both simultaneously.

## Props — orthographic (`GlyphCamera` / `GlyphOrthographicCamera`)

| Prop | Type | Default | Description |
|---|---|---|---|
| `rotX` | `number` (degrees) | `65` | Tilt around X axis |
| `rotY` | `number` (degrees) | `45` | Spin around Y axis |
| `zoom` | `number` | `0.65` | CSS pixels per world unit |

## Props — perspective (`GlyphPerspectiveCamera`)

| Prop | Type | Default | Description |
|---|---|---|---|
| `rotX` | `number` (degrees) | `65` | Tilt around X axis |
| `rotY` | `number` (degrees) | `45` | Spin around Y axis |
| `zoom` | `number` | `0.65` | CSS pixels per world unit |
| `distance` | `number` | `0` | Camera pull-back. CSS pixels with CSS perspective; world units only in legacy `perspective={0}` mode |
| `perspective` | `number` | `32000` | CSS-perspective distance in virtual pixels (matches voxcss). Larger = flatter foreshortening; set `0` for legacy orbit projection |
| `stretch` | `number` | `1.0` | Extra X scale on top of `cellAspect` |

## Full examples


  

```tsx
import {
  GlyphPerspectiveCamera,
  GlyphOrthographicCamera,
  GlyphScene,
  GlyphMesh,
  GlyphOrbitControls,
} from "@glyphcss/react";
import { octahedronPolygons } from "@glyphcss/core";

const octa = octahedronPolygons({ center: [0, 0, 0], size: 1, color: "#ffcc44" });

// Perspective (foreshortened)
export function PerspectiveExample() {
  return (
    
      
        
        
      
    
  );
}

// Orthographic — parallel lines stay parallel (isometric style)
export function OrthoExample() {
  return (
    
      
        
        
      
    
  );
}
```

  
  

```vue



```

  
  

```ts
import {
  createGlyphPerspectiveCamera,
  createGlyphOrthographicCamera,
  createGlyphScene,
} from "glyphcss";
import { octahedronPolygons } from "@glyphcss/core";

const host = document.querySelector("#scene")!;

// Camera constructed first, then passed into the scene
const perspective = createGlyphPerspectiveCamera({
  rotX: 22,
  zoom: 50,
  distance: 3,
  stretch: 0.95,
});
const scene = createGlyphScene(host, { camera: perspective, mode: "solid" });

scene.add(octahedronPolygons({ center: [0, 0, 0], size: 1, color: "#ffcc44" }));

// Switch to orthographic:
const ortho = createGlyphOrthographicCamera({ rotX: 35, zoom: 50 });
scene.setOptions({ camera: ortho });

// Mutate camera state and re-render:
perspective.rotY = 45;
perspective.distance = 4;
scene.rerender();
```

  


## Notes

- The default perspective camera matches polycss/voxcss: `distance: 0`, `perspective: 32000`, `zoom: 0.65`.
- `rotX: 0` looks straight down the Z axis. Try `22` (degrees) for a slight downward
  tilt that gives the mesh visual weight.
- `stretch: 0.95` counteracts over-stretching from `cellAspect ≈ 2` on typical
  monospace fonts. Leave at `1.0` if you tighten `line-height` instead.
- Orthographic cameras ignore `distance` — use `zoom` to scale the mesh instead.
- `GlyphCamera` is the ergonomic default alias for `GlyphOrthographicCamera`. Use it
  for iso/diagrammatic scenes; use `GlyphPerspectiveCamera` explicitly for
  character or walkthrough scenes.
- Rotation units are **degrees**, matching voxcss/three.js. `rotX=65, rotY=45` is the
  classic isometric-ish viewpoint. `zoom` is **CSS pixels per world unit**.


---

# GlyphOrbitControls

import { Tabs, TabItem } from '@astrojs/starlight/components';

glyphcss ships three pointer-based control modes. All must be placed inside a
`` (React / Vue) or receive a scene handle (vanilla). The camera
component wraps the scene.

| Control | Interaction | When to use |
|---|---|---|
| `GlyphOrbitControls` | Left-drag orbits; wheel zooms | General-purpose — object inspection |
| `GlyphMapControls` | Left-drag pans; right-drag / Shift+left orbits; wheel zooms | Top-down maps, floor plans |
| `GlyphFirstPersonControls` | Pointer-lock look; WASD move, jump, crouch | Walk-through scenes |

## Orbit controls

`GlyphOrbitControls` mutates the camera-state object on drag; the rasterizer
reads it and writes one string per pointermove. With `interactiveDownscale` set,
the scene renders at reduced resolution for the duration of the gesture and
restores full detail on release.

### Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `drag` | `boolean` | `true` | Enable click-and-drag rotation |
| `wheel` | `boolean` | `true` | Enable scroll-to-zoom |
| `invert` | `boolean \| number` | `false` | Invert drag direction |
| `clampPitch` | `boolean` | `true` | Clamp vertical drag to ±90°. Set `false` for globe-style unrestricted tumbling |
| `animate` | `false \| { speed?, axis?, pauseOnInteraction? }` | `false` | Auto-rotation config. `axis` is `"x"` or `"y"` |


  

```tsx
import { GlyphCamera, GlyphScene, GlyphMesh, GlyphOrbitControls } from "@glyphcss/react";
import { icosahedronPolygons } from "@glyphcss/core";

const icosa = icosahedronPolygons({ center: [0, 0, 0], size: 1, color: "#44ffcc" });

export function App() {
  return (
    
      
        
        
      
    
  );
}
```

  
  

```vue



```

  
  

```ts
import { createGlyphCamera, createGlyphScene, createGlyphOrbitControls } from "glyphcss";
import { icosahedronPolygons } from "@glyphcss/core";

const host = document.querySelector("#scene")!;
const camera = createGlyphCamera({ rotX: 25 });
const scene = createGlyphScene(host, { camera, mode: "solid" });
scene.add(icosahedronPolygons({ center: [0, 0, 0], size: 1, color: "#44ffcc" }));

const controls = createGlyphOrbitControls(scene, {
  drag: true,
  wheel: true,
  animate: { axis: "y", speed: 0.5 },
});

// Cleanup:
// controls.destroy();
```

  


## Map controls

Left-drag pans the camera target. Right-drag (or Shift + left-drag) orbits. Wheel
zooms. Best for top-down maps or floor-plan scenes.

### Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `drag` | `boolean` | `true` | Enable pointer-drag pan |
| `wheel` | `boolean` | `true` | Enable scroll-to-zoom |
| `invert` | `boolean \| number` | `false` | Invert drag direction |
| `animate` | `false \| { speed?, axis?, pauseOnInteraction? }` | `false` | Auto-rotation. `axis` is `"x"` or `"y"` |


  

```tsx
import { GlyphCamera, GlyphScene, GlyphMesh, GlyphMapControls } from "@glyphcss/react";
import { cubePolygons } from "@glyphcss/core";

const cube = cubePolygons({ center: [0, 0, 0], size: 1, color: "#4488ff" });

export function MapDemo() {
  return (
    
      
        
        
      
    
  );
}
```

  
  

```vue



```

  
  

```ts
import { createGlyphCamera, createGlyphScene, createGlyphMapControls } from "glyphcss";
import { cubePolygons } from "@glyphcss/core";

const host = document.querySelector("#scene")!;
const camera = createGlyphCamera({ rotX: 35 });
const scene = createGlyphScene(host, { camera, mode: "solid", cols: 120, rows: 36 });
scene.add(cubePolygons({ center: [0, 0, 0], size: 1, color: "#4488ff" }));

const controls = createGlyphMapControls(scene, { drag: true, wheel: true });
```

  


## First-person controls

Pointer-lock mouselook, WASD / arrow planar move, Space jump, Ctrl crouch — each
axis independently toggleable. Good for walk-through environments and large scenes.
Click the scene to capture the pointer; Esc releases it.

**Requires a perspective camera.** `GlyphFirstPersonControls` throws if attached
to a scene with an orthographic camera. Use `` explicitly
(not ``, which aliases orthographic).

Distances are in world units — size `moveSpeed` / `eyeHeight` to your mesh's scale
(the gallery derives them from the model bbox).

### Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `lookEnabled` | `boolean` | `true` | Pointer-lock mouselook |
| `moveEnabled` | `boolean` | `true` | WASD / arrow planar movement |
| `jumpEnabled` | `boolean` | `true` | Space-bar jump arc |
| `crouchEnabled` | `boolean` | `true` | Ctrl crouch |
| `lookSensitivity` | `number` | `0.15` | Degrees per pixel of mouse |
| `invertY` | `boolean` | `false` | Invert vertical look |
| `moveSpeed` | `number` | `5` | World units per second |
| `jumpVelocity` | `number` | `7` | Initial jump velocity (units/s) |
| `gravity` | `number` | `18` | Gravity (units/s²) |
| `eyeHeight` | `number` | `1.7` | Standing eye height above ground |
| `crouchHeight` | `number` | `1` | Eye height while crouching |
| `groundZ` | `number` | `0` | World Z of the ground plane |
| `minPitch` / `maxPitch` | `number` | `5` / `175` | Pitch clamp (degrees) |


  

```tsx
import { GlyphPerspectiveCamera, GlyphScene, GlyphMesh, GlyphFirstPersonControls } from "@glyphcss/react";
import { cubePolygons } from "@glyphcss/core";

const floor = cubePolygons({ center: [0, -0.6, 0], size: 4, color: "#334455" });

export function FPVDemo() {
  return (
    
      
        
        
      
    
  );
}
```

  
  

```vue



```

  
  

```ts
import { createGlyphPerspectiveCamera, createGlyphScene, createGlyphFirstPersonControls } from "glyphcss";
import { cubePolygons } from "@glyphcss/core";

const host = document.querySelector("#scene")!;
const camera = createGlyphPerspectiveCamera();
const scene = createGlyphScene(host, { camera, mode: "solid", cols: 140, rows: 42 });
scene.add(cubePolygons({ center: [0, -0.6, 0], size: 4, color: "#334455" }));

const controls = createGlyphFirstPersonControls(scene, {
  moveSpeed: 3,
  eyeHeight: 1.2,
});
```

  



---

# GlyphHotspot

import { Tabs, TabItem } from '@astrojs/starlight/components';

`` renders a real, absolutely-positioned `
` over the ASCII render, tracking a 3D anchor through the live camera. The `
` is `pointer-events: auto` inside an otherwise transparent hit layer — events fire on it like any normal DOM element. ## Props | Prop | Type | Default | Description | |---|---|---|---| | `id` | `string` | required | Stable identifier for this hotspot | | `at` | `Vec3` | required | World-space anchor `[x, y, z]` | | `size` | `[number, number]` | `[1, 1]` | Hitbox size in character cells | | `onClick` | `MouseEventHandler` | — | Click handler | | `aria-label` | `string` | — | Accessibility label | | `className` | `string` | — | CSS class | | `children` | `ReactNode` | — | Rendered inside the `
` (use for tooltips/badges) | ## Tracking math On every render pass the anchor is projected through the current camera and the resulting cell is written straight to the element's inline `transform` — one assignment per hotspot, no DOM rebuild and no second projection. While the user drags, the same projection runs per pointermove, so the hotspot stays glued to its anchor through the whole gesture. ## Full examples ```tsx import { GlyphCamera, GlyphScene, GlyphMesh, GlyphOrbitControls, GlyphHotspot, } from "@glyphcss/react"; import { cubePolygons } from "@glyphcss/core"; const cube = cubePolygons({ center: [0, 0, 0], size: 1, color: "#4488ff" }); export function HotspotDemo() { return ( {/* Hotspot on the top face */} alert("top face clicked")} > Top {/* Hotspot on the right face */} alert("right face clicked")} > Right ); } ``` ```vue ``` ```ts import { createGlyphScene } from "glyphcss"; import { cubePolygons } from "@glyphcss/core"; const host = document.querySelector("#scene")!; const scene = createGlyphScene(host, { mode: "solid", cols: 100, rows: 30 }); scene.add(cubePolygons({ center: [0, 0, 0], size: 1, color: "#4488ff" })); const topHotspot = scene.addHotspot( { id: "top", at: [0, 0.5, 0], size: [5, 3] }, () => alert("top face clicked"), ); const rightHotspot = scene.addHotspot( { id: "right", at: [0.5, 0, 0], size: [5, 3] }, () => alert("right face clicked"), ); // Remove a hotspot when no longer needed: // topHotspot.remove(); ``` ## Visibility A hotspot whose projected cell is not visible (behind the camera, or occluded) is hidden with `display: "none"` rather than faded — the renderer stages the style and applies it in the same single write as the rest of the frame. There is no opacity keyframe to hook a CSS transition onto; animate the element yourself if you want a fade. ## Anchors `at` is a world-space `Vec3`. String anchors (`"vertex:42"`, `"face:roof"`, `"centroid"`) are **not** implemented — resolve the coordinate yourself from the polygon data and pass the result: ```ts const centroid = polygons .flatMap((p) => p.vertices) .reduce((a, v, _i, all) => [a[0] + v[0] / all.length, a[1] + v[1] / all.length, a[2] + v[2] / all.length], [0, 0, 0]); ``` --- # GlyphScene import { Tabs, TabItem } from '@astrojs/starlight/components'; import GlyphDemo from "../../../components/GlyphDemo.astro"; `` is the visual host. It owns the `
` output element, the sibling
hit layer, and the measured cell metrics the projection uses.

`` must be a child of a camera component (``,
``, or ``).



## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `mode` | `"wireframe" \| "solid" \| "voxel" \| "ink"` | `"solid"` | Render mode |
| `cols` | `number` | `80` | Grid width in character columns |
| `rows` | `number` | `24` | Grid height in character rows |
| `cellAspect` | `number` | `2.0` | Character cell height ÷ width |
| `glyphPalette` | `string` | `"default"` | Named glyph palette |
| `autoSize` | `boolean` | `false` | Measure the host element and derive `cols`/`rows` from it instead of using the fixed grid |
| `charMode` | `"ascii" \| "braille" \| "halfblock" \| "quadrant"` | `"ascii"` | Sub-cell character encoding. `"braille"` is wireframe-only; `"halfblock"` and `"quadrant"` are solid-only. No-op outside its mode. See [character encoding](/guides/render-modes/#character-encoding-charmode) |
| `wireframeJunctions` | `boolean` | `false` | Resolve wireframe corners and crossings to box-drawing glyphs (`┌┐└┘├┤┬┴┼─│`). ASCII wireframe only. See [junctions](/guides/render-modes/#box-drawing-junctions-wireframejunctions) |
| `hiddenLines` | `"show" \| "hide"` | `"show"` | Hidden-line removal for `wireframe` and `ink`: `"hide"` drops strokes a nearer surface covers. No-op in `solid`. See [hidden-line removal](/guides/render-modes/#hidden-line-removal-hiddenlines) |
| `solidWeightRamp` | `{ glyph: string; weight: number }[]` | `undefined` | Solid-only shading ramp that varies `font-weight` as well as glyph, ordered darkest → densest. Replaces the palette's solid ramp when set. See [font-weight ramp](/guides/render-modes/#font-weight-density-ramp-solidweightramp) |
| `colorTolerance` | `number` | `0` | Merge adjacent cells into one `` while their colors stay within this redmean distance (range `0`–`765`, not `0`–`255`) — fewer spans, faster paint, at the cost of color fidelity. `0` is off and byte-identical. `NaN`/negative values degrade to `0`; `+Infinity` merges every same-glyph run in a row. No-op under `glyphOutput: "semantic"` — semantic colors are exact class identifiers, not shaded appearance. See [spans, not just cells](/guides/performance/#spans-not-just-cells-colortolerance) |
| `colorEncoding` | `"spans" \| "atlas"` | `"spans"` | `"atlas"` encodes each `(glyph, color)` pair as a Private Use Area code point against a checked-in COLR/CPAL color font, so the render is one text node with zero ``s. `"spans"` is byte-identical to before the option existed. Falls back to spans, whole-scene, whenever the atlas cannot carry the frame. See [color encoding](/guides/color-encoding) |
| `atlasPalette` | `readonly string[]` | `undefined` | Pin the ordered `#rrggbb` palette that `colorEncoding: "atlas"` slots encode against — a brand ramp or a reproducible bake. Omitted, the scene derives and pools one itself, so this does **not** gate the atlas. Bounded by the atlas's slot count. See [the palette](/guides/color-encoding/#the-palette-atlaspalette) |
| `fontAtlas` | `GlyphFontAtlas` | `GLYPH_FONT_ATLAS` | Which color-font atlas to encode against — the universal one (212 glyphs / 30 palette slots) or `GLYPH_FONT_ATLAS_ASCII` (94 / 68), trading glyph coverage for color resolution. **Fixed at scene creation**; a later change is not forwarded. See [choosing an atlas](/guides/color-encoding/#choosing-an-atlas-fontatlas) |
| `smoothShading` | `boolean` | `false` | Gouraud shading from averaged vertex normals. Off by default — the faceted look is part of glyph's identity |
| `creaseAngle` | `number` | `60` | Max angle (degrees) between adjacent faces still smoothed together when `smoothShading` is on |
| `interactiveDownscale` | `number` | `1` | Render at `1/n` resolution while a control is dragging, full detail on release. Same on-screen size — this keeps high-density scenes inside the frame budget mid-gesture |
| `trackOpaqueCoverage` | `boolean` | `false` | Force a base-layer depth raster each render so `scene.getOpaqueCoverage()` can publish it. A scene only builds an occlusion id-map when it has an opaque detail mesh or a foreign mask, so a scene of plain base meshes needs this to feed another scene's `setForeignOcclusion`. Changes no output; costs one raster |
| `glyphOutput` | `"visible" \| "semantic"` | `"visible"` | `"semantic"` renders authored class labels instead of shaded glyphs and enables `scene.getGlyphSemanticCellFrame()`. Requires the `sceneManifest` + `dictionary` JS properties |
| `useColors` | `boolean` | `true` | Emit color spans in the output |
| `directionalLight` | `GlyphDirectionalLight` | — | Directional light for solid mode |
| `ambientLight` | `GlyphAmbientLight` | — | Ambient fill for solid mode |
| `shadow` | `GlyphShadowOptions` | `undefined` | Shadow-map config. `undefined` = off. Set alongside `castShadow`/`receiveShadow` on meshes |
| `transformCells` | `TransformCells` | `undefined` | Transform the completed cell grid before its single `
` write |
| `className` | `string` | — | CSS class on the outer host |
| `style` | `CSSProperties` | — | Inline styles on the outer host |
| `children` | `ReactNode` | — | ``, controls, hotspots |

### Vanilla-only scene options

`createGlyphScene` accepts a few options the React and Vue components do not
expose as props. `compileScene` accepts the first two (`doubleSided` and
`supersample`) as well:

| Option | Default | What it does |
|---|---|---|
| `doubleSided` | `false` | Shade back faces instead of culling them — needed for open meshes and flat planes viewed from behind |
| `supersample` | `1` | Rasterize at `n×` the cell grid and box-filter down, for coverage antialiasing. `charMode: "halfblock"`/`"quadrant"` force an even supersample of at least 2 internally |
| `depthEpsilon` | `0` | Depth-test tolerance for coplanar surfaces |
| `temporalBlend` | `0` | Reprojection TAA: blends the ramp index and RGB against the previous frame. `solidWeightRamp` and `charMode: "halfblock"`/`"quadrant"` are no-ops while it is active |

## Cell transforms and surface UVs

`transformCells` runs after rasterization, shading, and depth testing, immediately
before glyphcss stringifies the grid. The hook may mutate `grid.char` and
`grid.color`; glyphcss still performs one write to the scene's `
` for the
completed frame.

In solid mode, polygons with authored `uvs` also expose `grid.surfaceUv`. This is
an optional interleaved `Float32Array` containing the perspective-correct UV from
the depth-winning surface for each cell:

```ts
import { createGlyphScene } from "glyphcss";
import type { TransformCells } from "glyphcss";

const word = Array.from("HOLA");

const mapWordToSurface: TransformCells = (grid) => {
  const uv = grid.surfaceUv;
  if (!uv) return;

  for (let i = 0; i < grid.char.length; i++) {
    const u = uv[i * 2];
    const v = uv[i * 2 + 1];
    if (!Number.isFinite(u) || !Number.isFinite(v)) continue;

    grid.char[i] = word[((Math.floor(u * 12) % word.length) + word.length) % word.length];
  }
};

const scene = createGlyphScene(host, {
  camera,
  mode: "solid",
  transformCells: mapWordToSurface,
});
```

`surfaceUv` uses `[u0, v0, u1, v1, ...]`. Empty cells and cells whose winning
polygon has no UVs contain `NaN`/non-finite coordinates, so effects must check
both components. Values remain in the polygon's authored coordinate space;
they are not clamped to `0..1`, and may tile beyond that range. Because the
mapping follows the polygon UVs rather than screen rows and columns, patterns
rotate and foreshorten with the surface. Treat the grid buffers as
callback-scoped; use `rasterizeToCells` when they must outlive the synchronous
`transformCells` call.

For reusable or animated appearance, prefer a mounted
[`GlyphEffectLayer`](/guides/effects). Generic layers retain the geometry raster
and avoid re-projecting the mesh on parameter-only ticks; `transformCells`
remains the final application-specific escape hatch and runs after those layers.

## Full examples


  

```tsx
import {
  GlyphPerspectiveCamera,
  GlyphScene,
  GlyphMesh,
  GlyphOrbitControls,
  GlyphHotspot,
} from "@glyphcss/react";
import { cubePolygons } from "@glyphcss/core";

const cube = cubePolygons({ center: [0, 0, 0], size: 1, color: "#4488ff" });

export function App() {
  return (
    
      
        
        
           alert("top face")}
          />
        
      
    
  );
}
```

  
  

```vue



```

  
  

```ts
import {
  createGlyphPerspectiveCamera,
  createGlyphScene,
  createGlyphOrbitControls,
} from "glyphcss";
import { cubePolygons } from "@glyphcss/core";

const host = document.querySelector("#scene")!;

const camera = createGlyphPerspectiveCamera({ rotX: 25, zoom: 50, distance: 3 });
const scene = createGlyphScene(host, {
  camera,
  mode: "solid",
  cols: 100,
  rows: 30,
});

scene.add(cubePolygons({ center: [0, 0, 0], size: 1, color: "#4488ff" }));

scene.addHotspot(
  { id: "top", at: [0, 0.5, 0], size: [3, 2] },
  () => alert("top face"),
);

const controls = createGlyphOrbitControls(scene, { drag: true, wheel: true });

// Later, when done:
// controls.destroy();
// scene.destroy();
```

  
  

```html



  
    
    
  

```

  


## Shadows

Shadows are opt-in. Set `shadow` on `` to enable the shadow-map pass,
then flag individual meshes with `castShadow` and/or `receiveShadow`.
A mesh with both flags self-shadows. `` defaults to `receiveShadow=true`.


  

```tsx
import {
  GlyphPerspectiveCamera,
  GlyphScene,
  GlyphMesh,
  GlyphGround,
} from "@glyphcss/react";

const shadow = { color: "#000000", opacity: 0.25, lift: 0.05 };
const directionalLight = { direction: [0.5, 0.7, 0.5], intensity: 1 };

export function ShadowDemo() {
  return (
    
      
        
        
      
    
  );
}
```

  
  

```vue



```

  
  

```html

  
    
    
    
  

```

  


### `GlyphShadowOptions` fields

| Field | Type | Default | Description |
|---|---|---|---|
| `color` | `string` | `"#000000"` | Shadow tint hex color |
| `opacity` | `number` | `0.25` | Darkness 0..1 toward `color` |
| `lift` | `number` | `0.05` | Depth bias — prevents self-shadow acne on flat lit surfaces. A WORLD-UNIT length, so the default assumes a room-scale scene; a scene whose unit is (say) an Earth radius must set its own, or every shadow is biased away |
| `maxExtend` | `number` | `2000` | **Accepted but not read.** The light-space volume is fitted to the casters' own bounds; this value has no effect on any render |

## Lifecycle methods (vanilla)

| Method | Description |
|---|---|
| `scene.add(polygons, transform?)` | Register a mesh, returns a `GlyphMeshHandle` |
| `mesh.setPolygons(polygons)` | Replace a mesh's geometry in place |
| `mesh.setTransform(transform)` | Replace a mesh's transform |
| `mesh.dispose()` | Remove a mesh |
| `scene.addHotspot(opts, onClick?)` | Register a hotspot overlay, returns a `GlyphHotspotHandle` |
| `hotspot.setAt(at)` | Move a hotspot's 3D anchor without touching its element — the element, its listeners and anything you wrote on it survive, and only the projected position changes |
| `hotspot.remove()` | Remove a hotspot overlay |
| `scene.setOptions(partial)` | Update any scene option and trigger a re-render |
| `scene.getOptions()` | Return a snapshot of current options |
| `scene.rerender()` | Force an immediate re-rasterize |
| `scene.addEffectLayer(opts)` | Mount an ordered appearance program over the retained grid, returns a `GlyphEffectLayerHandle` (see [Glyph Effects](/guides/effects)). Changing the handle's `params` / `opacity` / `blend` / `order` / `enabled` recomposes without re-projecting the mesh |
| `scene.setInteracting(active)` | Tell the scene a gesture is in progress, so `interactiveDownscale` applies. The bundled controls call this for you — use it for custom interaction sources |
| `scene.getOpaqueCoverage()` | The last committed render's opaque per-cell coverage at the output grid — cells owned by the base grid or an opaque detail layer, foreign stamps excluded. `null` when that render built no occlusion id-map (see `trackOpaqueCoverage`). `solid` mode only |
| `scene.setForeignOcclusion(coverage)` | Consume another scene's `getOpaqueCoverage()` result: every layer of this scene blanks under the covered cells, so two scenes stacked over one host share a single occlusion domain. `null` clears. Re-publishing identical bytes costs a comparison, not a render. `solid` mode only |
| `scene.getGlyphSemanticCellFrame()` | Immutable snapshot of the last committed base grid's polygon → surface → instance → class lineage. `null` unless `glyphOutput: "semantic"` |
| `scene.destroy()` | Remove the scene DOM and clear all meshes |


---

# Core Concepts

## One render pass, one write

glyphcss does not build a DOM node per polygon, and it does not pre-bake frames.
Every camera or scene change runs a single render pass:

1. Walk all mounted meshes in scene order.
2. Transform polygon vertices through the camera to 2D projected positions.
3. Fill a `cols × rows` character grid — depth-testing overlapping polygons and
   picking a glyph per cell according to the render mode.
4. Join the grid into one string and assign it to `
.textContent`, **once**.

That last point is the invariant the whole renderer is built around: each render
cycle writes each `
` exactly once. No cell-by-cell DOM patching, no
`matrix3d`, no per-polygon elements. A scene with per-mesh detail layers writes
the base `
` plus one write per detail layer, and nothing else.

Interaction follows the same path rather than a special one: a control mutates a
single camera-state object, the rasterizer reads it, and one string is written.
Dragging can optionally render at reduced resolution via `interactiveDownscale`
and restore full detail on release — same on-screen size, fewer cells mid-gesture.

Because `rasterize` is pure — geometry + camera in, string out — the same pass
runs at build time or on a server. That is what `compileScene` and
`GlyphSceneStatic` use, and their output is byte-identical to the runtime render
for the same inputs.

## The hit layer

polycss-style "every polygon is a DOM node" doesn't fit ASCII rendering: the visible
output is a single character grid, not a set of clickable polygons. Instead, glyphcss
exposes a **sparse** hit layer: you opt-in to interactivity by registering hotspots
at specific 3D anchors.

```tsx
import { GlyphMesh, GlyphHotspot } from "@glyphcss/react";
import { dodecahedronPolygons } from "@glyphcss/core";

const shape = dodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#cc44ff" });


  
    Top
  

```

Each hotspot becomes a real `
` absolutely positioned at its projected cell. The rasterizer projects `Hotspot.at` through the same camera the grid was drawn with and returns a `HotspotCell` (col, row, depth, visible); the consumer moves the element with a single inline-style assignment per hotspot — no DOM rebuild, and no second projection that could disagree with the glyphs. Hotspots: - Render real DOM children (use them for tooltips, badges, hover affordances). - Fire normal DOM events (`onClick`, `onMouseEnter`, `onFocus`). - Get free CSS `:hover` and `:focus-visible` styling. - Inspect in DevTools like any other element. ## The camera contract The renderer and the hit layer **must** use the same `camera.project(v, ...)` call. This is enforced by both reading from a shared `GlyphCamera` handle: ```ts const camera = createGlyphPerspectiveCamera({ rotX: 25, rotY: 0, distance: 3, zoom: 50, stretch: 1.0, }); const ctx = buildRasterizeContext({ camera, grid, polygons, mode: "solid" }); // Renderer — one pass, one string const text = rasterize(ctx); // Hit layer — the SAME camera object, so it cannot disagree const cells = projectHotspots(hotspots, camera, grid.cols, grid.rows, grid.cellAspect); ``` Mutating `camera.rotY` and re-running both calls is all "animation" is here: there is no baked sequence to keep in sync, and nothing caches a projection that could go stale. If you ever find yourself threading a separate "current angle" through projection, stop: one camera-state object feeds both the grid and the hotspots, and that is exactly what keeps them from drifting apart. ## Cell measurement (the recurring footgun) `
` is `display: block`, so `getBoundingClientRect().width` returns the
**container width**, not the character width. Always measure on a hidden
`` instead. The probe should have the same
font properties as the `
` (same `font-family`, same `font-size`,
`line-height: normal`).

The measured cell matters because the projection uses its aspect ratio: with
`autoSize`, the scene re-measures the host cell and overwrites `cellAspect` from
it, so geometry stays correctly proportioned when the font or size changes. A
fixed-size scene (explicit `cols`/`rows`, no `autoSize`) has no such correction —
it keeps whatever `cellAspect` you gave it, which is why `compileScene` takes the
value explicitly.


---

# Coding agents

glyphcss ships a **skill** so AI coding agents — Claude Code, Cursor, Codex, and
anything that reads a `SKILL.md` — know how to render 3D models and primitive
shapes as **ASCII art** straight in the terminal. Ask *"render a cube in the
terminal"* and the agent runs `glyphcss cube --auto-center` and shows you the
colored output inline.

## Install the CLI

The skill drives the [`@glyphcss/compile`](/guides/compile/) CLI. Install it once:

```sh
npm i -g @glyphcss/compile   # the command is `glyphcss`
# or, no install:
npx @glyphcss/compile cube --auto-center
```

## Add the skill

Point your agent at the skill, either way:

- **From npm** — it's bundled with the package at
  `node_modules/@glyphcss/compile/SKILL.md`.
- **From the web** — fetch [**glyphcss.com/skill.md**](/skill.md) and drop it into
  your agent's skills folder. For **Claude Code** that's
  `.claude/skills/glyphcss/SKILL.md`:

  ```sh
  mkdir -p .claude/skills/glyphcss
  curl -sS https://glyphcss.com/skill.md -o .claude/skills/glyphcss/SKILL.md
  ```

Cursor, Codex, and other agents that support skill/rule files can use the same
`SKILL.md` — it's plain Markdown describing the CLI.

## What the agent can do

Once the skill is in place, natural-language requests map to CLI runs:

| You ask | The agent runs |
|---|---|
| "render a cube in the terminal" | `glyphcss cube --auto-center` |
| "show me this model as ASCII" | `glyphcss model.glb --auto-center` |
| "render a torus, rotated" | `glyphcss torus --rot-x 60 --rot-y 30 --auto-center` |
| "ascii-art these polygons" | `glyphcss --polygons-json '[…]'` |
| "save it as an HTML page" | `glyphcss model.obj -f full -o out.html` |

Input can be a **mesh file** (`.obj/.glb/.gltf/.vox/.stl`), a **primitive shape**
(`cube`, `sphere`, `icosahedron`, `torus`, `cone`, … — 44 shapes), or **custom
polygons** as JSON. In a terminal the output defaults to truecolor ANSI, so it
shows in color right in the agent's console.

## Example prompts

- *"Use glyphcss to render a dodecahedron and show it to me."*
- *"Render `./assets/ship.glb` as ASCII at 60 columns."*
- *"Generate ASCII art of a red triangle with glyphcss."*

See the [Compiling to Static](/guides/compile/) guide for the full CLI reference,
the Vite plugin, and the Node API.


---

# Color Encoding

import { Tabs, TabItem } from '@astrojs/starlight/components';

Colored output is normally emitted as `` runs — one span
per color run, often thousands per frame. Span count, not cell count, is what
gates frame rate for a busy colored scene: the browser's parse-HTML, style and
paint work over a large `
` costs more than the render pass that produced
it. [`colorTolerance`](/guides/performance/#spans-not-just-cells-colortolerance)
attacks that by *merging* runs.

**`colorEncoding: "atlas"` removes them entirely.** Instead of describing color
in markup, it encodes each `(glyph, color)` pair as a single character against a
checked-in COLR/CPAL color font, so the whole render is one plain text node with
no elements inside it at all. Measured on the original spike: **2.7–9.4× FPS**
and **20,948 DOM nodes → 9** at equal visual output — and rasterization got
*cheaper*, not dearer, despite there being more distinct glyphs to draw.

## How the encoding works

The atlas font declares every glyph once per palette slot, in the Basic
Multilingual Plane's Private Use Area. One code point therefore names both
things a cell needs:

```
codePoint = puaStart + paletteSlot × glyphCount + glyphIndex
```

The load-bearing detail is `paletteSlot`: a code point encodes a color's
**position** in the palette, never its value. Nothing in the text says
`#4488ff`. That lives in a `@font-palette-values` block whose `override-colors`
maps slot → color, so redefining what slot `7` resolves to recolors the entire
render (~0.4 ms, measured) without re-encoding a single cell. Blank cells stay a
literal space, and carry no color at all.

Everything else about the render is unchanged — same geometry pass, same depth
test, same one-write-per-`
` rule. Only the final encode step differs.

## Turning it on

Set `colorEncoding` to `"atlas"`. The `@font-face`, the per-scene
`@font-palette-values` block, and the `
`'s `font-family` / `font-palette`
are all wired for you.


  

```tsx
import { GlyphCamera, GlyphScene, GlyphMesh } from "@glyphcss/react";

export function Demo() {
  return (
    
      
        
      
    
  );
}
```

  
  

```vue



```

  
  

```ts
import { createGlyphCamera, createGlyphScene, resolveGeometry } from "glyphcss";

const camera = createGlyphCamera({ rotX: 62, rotY: 30 });
const scene = createGlyphScene(document.querySelector("#scene")!, {
  camera,
  mode: "solid",
  autoSize: true,
  colorEncoding: "atlas",
});

scene.add(resolveGeometry("icosahedron", { size: 1, color: "#4488ff" }));
```

  
  

```html

  
    
  

```

  


The default stays `"spans"`, and a scene that never asks for the atlas is
byte-identical to one written before the option existed — it injects no CSS,
allocates no palette, and never downloads the font.

## The palette: `atlasPalette`

A palette has far fewer slots than a shaded render has colors, so something has
to reduce one to the other. **`atlasPalette` is optional, and omitting it is the
normal case:** the scene then derives its own palette by median-cut
quantization, pooling the colors of the frames it has actually rendered and
refreshing when both a time gate (250 ms) and a drift gate pass. Cells whose
color has no exact slot encode to the nearest one by redmean distance, so a
render with hundreds of Lambert-shaded colors is reduced to fit rather than
refused.

That reduction is measured, not assumed (`bench/color-font-atlas-quantize.md`).
A Lambert ramp is close to one-dimensional in color space and quantizes better
than the `colorTolerance: 32` merge the site already ships; a photograph
sampled onto a quad is the genuinely hard case, where a small fraction of cells
land visibly off. If your scene is a photo, measure before switching.

Supply an array only to **pin** the palette — a fixed brand ramp, or a
reproducible build-time bake. It must be ordered `#rrggbb` strings, at least one
and at most the atlas's slot count; positions are what the encoding references,
so reordering it changes every code point.


  

```tsx
const brand = ["#0b0e14", "#1d2433", "#3d5a80", "#98c1d9", "#e0fbfc"];


```

  
  

```vue

```

  
  

```ts
scene.setOptions({ colorEncoding: "atlas", atlasPalette: brand });
scene.setOptions({ atlasPalette: undefined }); // back to the pooled palette
```

  
  

```html


  


```

  


## Choosing an atlas: `fontAtlas`

The Private Use Area is 6,400 code points, and every glyph in the atlas costs
one of them *per palette slot*. Glyph coverage and color resolution therefore
trade directly against each other, and glyphcss ships two atlases at two points
on that curve:

| Atlas | Glyphs | Palette slots | Covers |
|---|---|---|---|
| `GLYPH_FONT_ATLAS` (default) | 212 | 30 | Printable ASCII, the 24 Greek capitals, the solid ramp of **every** named `glyphPalette`, the `default` / `ascii` wireframe line tiers, `ink` mode's 10 oriented glyphs, and the 11 `wireframeJunctions` box-drawing glyphs |
| `GLYPH_FONT_ATLAS_ASCII` | 94 | 68 | Printable ASCII, and nothing else |

`6400 ÷ 212 = 30` against `6400 ÷ 94 = 68`. A scene that only ever draws ASCII
— a `detail`, `dense`, `default`, `ascii` or `hex` solid ramp, say — never
touches the universal set's Greek/braille/box-drawing axis, and 2.3× the slots
is where quantization error actually goes down. A scene using `ink` mode,
`wireframeJunctions`, or a non-ASCII solid ramp needs the universal atlas.

Neither atlas lists the space character, and neither needs to: a blank cell is
written as a literal `U+0020`, carrying no color and costing no slot.

Both are plain manifest objects re-exported from `glyphcss`, `@glyphcss/react`
and `@glyphcss/vue`. **`fontAtlas` is fixed at scene creation** — the scene's
`
` elements, its font-readiness probe and its palette CSS are all pinned to
one family for its lifetime, so `setOptions` is inert for it and a later prop
change is deliberately not forwarded. Remount the scene to switch atlases.


  

```tsx
import { GlyphScene, GLYPH_FONT_ATLAS_ASCII } from "@glyphcss/react";

{/* read at mount only — remount to change it */}

```

  
  

```vue



```

  
  

```ts
import { createGlyphScene, GLYPH_FONT_ATLAS_ASCII } from "glyphcss";

const scene = createGlyphScene(host, {
  camera,
  mode: "solid",
  colorEncoding: "atlas",
  fontAtlas: GLYPH_FONT_ATLAS_ASCII,
});
```

  
  

```html



```

  


## When it falls back to spans

The atlas either encodes the **entire** render or none of it. A mixed encoding
would still need spans for whatever fell outside the atlas, which defeats the
point — so this is always a whole-scene decision, never a per-cell one, and the
`
`'s font stack follows the encoding that frame actually produced rather
than the option you set. A fallback frame is never left painting in the atlas
face.

**Transient — it re-enables itself:**

- **The font hasn't arrived yet.** The ~49 KB WOFF2 is a lazily imported chunk,
  so a scene created with `colorEncoding: "atlas"` renders spans for its first
  frame or two and re-renders itself once the face is decoded. This is why a
  `"spans"` consumer never pays for the payload at all.
- **A non-blank cell with no usable `#rrggbb` color.** Nothing to assign a slot
  to. Not a configuration problem, so it doesn't stick.

**Sticky for the session or the scene:**

- **The engine can't do it.** glyphcss requires `CSS.supports("font-palette",
  "--x")` — the custom-ident form specifically, since `font-palette: normal |
  light | dark` would leave every cell in the font's baked hue ramp — and then
  rasterizes one atlas code point to a canvas and requires a *chromatic* pixel,
  because an engine that decodes the face but ignores COLR would otherwise paint
  a blank grid. Either check failing warns once and pins that document to spans.
- **A glyph outside the chosen atlas.** Once any output of a scene falls back
  for a glyph reason, that scene stays on spans until a `setOptions` call touches
  `colorEncoding`, `atlasPalette`, `mode` or `charMode`. The latch exists for
  content glyphcss cannot see in advance — an animated effect layer's data-driven
  ramp, or a `transformCells` hook — whose realized glyph set varies frame to
  frame and would otherwise make the encoding flicker. `charMode: "braille"`
  lands here too: its 256 dot patterns are outside both atlases by design.

**Permanent no-ops** — cases where a span-per-cell representation is
structurally required, and the option is simply ignored:

| Setting | Why |
|---|---|
| `charMode: "halfblock"` / `"quadrant"` | Two colors per cell; the atlas encodes one |
| `solidWeightRamp` actively selecting a weight | COLR/CPAL carries color, not `font-weight` |
| `glyphOutput: "semantic"` | Semantic output ignores every presentation option |
| `useColors: false` | No color axis to encode |

One wrinkle worth knowing in **wireframe** mode: each cell draws a random glyph
from its line-weight tier, so gating on the frame's realized glyphs would make
eligibility flip frame to frame. The decision is made instead from the palette's
*potential* glyph set, derived from configuration alone. Under the universal
atlas that means the `blocks`, `stars`, `arrows`, `braille`, `runes` and `math`
palettes render spans deterministically in wireframe — every one of them has a
line-tier glyph outside the atlas — while their **solid** ramps encode fine.

## Copy, paste, and find-in-page

**Find-in-page does not work under `"atlas"`.** The text in the DOM is Private
Use Area code points, not the glyphs on screen, so the browser's own search has
nothing to match. This is an accepted trade, not something worked around.

Copy and paste do work, but only through a decode step, and the decoder has to
know which atlas produced the text: both variants start at `U+E000` with
different glyph counts, so the same code point means different things in each.
The `font-family` glyphcss pins on each `
` is that key — per element, so a
selection spanning two scenes on different atlases still decodes correctly.

```ts
import { decodeGlyphAtlasText, glyphAtlasForFamily, GLYPH_FONT_ATLAS } from "glyphcss";

function readAscii(pre: HTMLPreElement): string {
  const atlas = glyphAtlasForFamily(pre.style.fontFamily) ?? GLYPH_FONT_ATLAS;
  return decodeGlyphAtlasText(pre.textContent ?? "", atlas);
}
```

`decodeGlyphAtlasText` passes non-atlas characters through untouched, so it is
safe to run unconditionally over any `
` — it is a no-op on ordinary
`"spans"` output. Colors are dropped; recovering them means resolving each
cell's slot through the scene's own `@font-palette-values` block.

## Static and compiled output

`compileScene` and `GlyphSceneStatic` accept all three options — they are pure
functions of geometry and camera, so a build-time bake reproduces the runtime
render exactly. Two differences matter:

1. **`atlasPalette` is required there.** The pooled quantizer belongs to a live
   scene; with no palette to encode against, a compiled `"atlas"` render quietly
   degrades to spans.
2. **They inject no CSS.** Being DOM-less, they have no document to inject into,
   so the embedder supplies the `@font-face` and `@font-palette-values` itself —
   with the *same* atlas passed to all three calls, or the output resolves its
   code points against the wrong glyph modulus.

```ts
import {
  compileScene,
  loadGlyphAtlasFontFaceCss,
  buildGlyphAtlasFontPaletteValuesCss,
  GLYPH_FONT_ATLAS_ASCII as atlas,
} from "glyphcss";

const palette = ["#0b0e14", "#3d5a80", "#98c1d9", "#e0fbfc"];

const { html } = compileScene({
  polygons,
  camera,
  mode: "solid",
  cols: 120,
  rows: 40,
  colorEncoding: "atlas",
  atlasPalette: palette,
  fontAtlas: atlas,
});

const css = [
  await loadGlyphAtlasFontFaceCss(atlas),
  buildGlyphAtlasFontPaletteValuesCss("--brand", palette, atlas),
  `.glyph-output{font-family:"${atlas.family}",monospace;font-palette:--brand}`,
].join("\n");
```

`loadGlyphAtlasFontFaceCss` inlines the WOFF2 as a `data:` URI, so the result is
self-contained with no extra font request. Unlike the runtime path it **rejects**
when the payload is unavailable: an export that shipped a `@font-face` with an
empty `src` would look correct in review and render tofu in the browser.

The frame-roll, interactive/CodePen and static-effect exporters do not carry
these options yet.

## Reference

| Option | Type | Default | Effect |
|---|---|---|---|
| `colorEncoding` | `"spans" \| "atlas"` | `"spans"` | `"atlas"` encodes `(glyph, color)` as PUA code points against the color font — one text node, zero ``s. `"spans"` is the byte-identical default. |
| `atlasPalette` | `readonly string[]` | — | Pin the ordered `#rrggbb` palette slots encode against. Omitted, the scene derives and pools one itself. Max = the atlas's slot count. |
| `fontAtlas` | `GlyphFontAtlas` | `GLYPH_FONT_ATLAS` | Which atlas to encode against. **Fixed at scene creation.** |

Supporting exports from `glyphcss`:

| Export | Purpose |
|---|---|
| `GLYPH_FONT_ATLAS` / `GLYPH_FONT_ATLAS_ASCII` | The two shipped atlas manifests (also re-exported from `@glyphcss/react` and `@glyphcss/vue`) |
| `glyphAtlasForFamily(fontFamily)` | Which atlas a `
`'s font stack names — the decoder's key |
| `decodeGlyphAtlasText(text, atlas?)` | PUA text → plain glyphs; passes anything else through |
| `decodeGlyphAtlasCodePoint(cp, atlas?)` | One code point → `{ glyph, paletteSlot }` |
| `glyphAtlasCodePoint(glyph, slot, atlas?)` | The forward mapping |
| `loadGlyphAtlasFontFaceCss(atlas?)` | `@font-face` text with the WOFF2 inlined; rejects if unavailable |
| `buildGlyphAtlasFontPaletteValuesCss(name, colors, atlas?)` | `@font-palette-values` text for a slot → color mapping |
| `glyphAtlasFontLoadState(atlas?)` | `"idle" \| "loading" \| "ready" \| "failed"` for the lazy payload |

## Notes & limits

- **Browser-only for the automatic wiring.** `createGlyphScene` (and therefore
  React, Vue and ``) injects and manages the CSS; the static path
  does not — see above.
- **The `@font-face` is document-global; the palette block is per scene.** Ten
  atlas scenes on one page share one font injection and one payload download,
  each with its own `@font-palette-values` ident. `scene.destroy()` removes the
  scene's own block.
- **Composes with `colorTolerance`, but there is nothing to gain.** Merging runs
  only matters for markup the atlas has already eliminated.
- **It does not reduce cell count.** Everything in
  [Performance](/guides/performance) about grid cells still applies — the atlas
  removes the DOM cost of color, not the cost of shading a big grid.


---

# Compiling to Static

glyphcss's renderer is **pure**: `rasterize(scene) → string` takes geometry +
camera + grid and returns the `
` text — no DOM, no WebGL. So a scene can be
rendered **at build time, on a server, or in a worker**, not just in the browser.
Because the render *is text*, it inlines straight into HTML with **zero runtime**.

There are two flavors:

- **Static** — a frozen `
` of ASCII. No JavaScript ships at all.
- **Interactive** — a self-contained snippet that ships only the control you
  declared (orbit / zoom / pan / fpv) plus a decimated mesh.

## Static compile

Everything is built on one pure function, `compileScene`, which reproduces
`createGlyphScene`'s exact render (same defaults) without a DOM:

```ts
import { compileScene } from "glyphcss";

const { html, inner, cols, rows } = compileScene({
  polygons,            // a Polygon[]
  cols: 80, rows: 24,  // library defaults
  // camera, mode, glyphPalette, useColors, lights… (same options as createGlyphScene)
});
// html === '
…ascii…
' ``` The output is **byte-identical to the runtime render** for the same inputs. ### Vite plugin The `@glyphcss/compile` package ships a Vite plugin that compiles a mesh import to its baked `
` **at build time**:

```ts
// vite.config.ts
import { glyphcssCompile } from "@glyphcss/compile/vite";

export default { plugins: [glyphcssCompile()] };
```

```ts
import dog from "./dog.glb?glyph&autoCenter=1&rotX=60&rotY=45&cols=80&rows=30";

document.querySelector("#app")!.innerHTML = dog; // the 
 string — no runtime
```

Query params map to the [options](#options) below. Works in any Vite pipeline —
Astro, vanilla Vite, or Vite-React (import the string and inject it).

### In Astro

Astro is built on Vite, so the plugin works once you register it:

```js
// astro.config.mjs
import { defineConfig } from "astro/config";
import { glyphcssCompile } from "@glyphcss/compile/vite";

export default defineConfig({ vite: { plugins: [glyphcssCompile()] } });
```

```astro
---
import dog from "../models/dog.glb?glyph&autoCenter=1&rotX=60";
---

```

But the most idiomatic Astro path needs **no plugin at all** — Astro frontmatter
runs in Node at build, so `compileFile` works there directly:

```astro
---
import { compileFile } from "@glyphcss/compile";
import { fileURLToPath } from "node:url";

const { html } = await compileFile(
  fileURLToPath(new URL("../models/dog.glb", import.meta.url)),
  { autoCenter: true, rotX: 60, rotY: 45 },
);
---

```

Both run at build and emit the `
` into the page HTML with **zero JS** — the
Astro equivalent of `GlyphSceneStatic`. A dedicated Astro integration isn't
needed; it would only add sugar (auto-registering the plugin and a typed
`` component).

### CLI

The universal escape hatch — works in any pipeline (Hugo, Eleventy, CI, a Makefile):

Install it (`npm i -g @glyphcss/compile` → the `glyphcss` command, or `npx
@glyphcss/compile …`), then:

```sh
glyphcss cube --auto-center              # a primitive shape → color ASCII
glyphcss dog.glb --auto-center           # a mesh file → ANSI color in the terminal
glyphcss --polygons-json '[{"vertices":[[0,0,0],[2,0,0],[1,2,0]],"color":"#f00"}]'
glyphcss dog.glb -f full -o dog.html     # full HTML document
```

**Input** is a mesh file (`.obj/.glb/.gltf/.vox/.stl`), a **primitive shape**
name (`cube`, `sphere`, `icosahedron`, `torus`, `cone`, … — 44 shapes), or
**custom polygons** (`--polygons FILE.json` / `--polygons-json '…'`).

Output **`-f, --format`** picks the encoding: `ansi` (truecolor terminal), `text`
(plain), `html` (a `
`), or `full` (HTML doc). The default depends on the
destination — **terminal → ansi**, `-o` file → html, piped → text. With no
`--cols`/`--rows` it **auto-fits** the grid + zoom to the content, cropped tight
(give just one and the other adapts to show the whole model).

### Use it from a coding agent

glyphcss ships a **skill** so Claude Code, Cursor, Codex, and other AI coding
agents can drive this CLI — ask *"render a cube in the terminal"* and the agent
runs `glyphcss cube --auto-center`. See **[Coding agents](/guides/coding-agents/)**
for setup.

### Node API

```ts
import { compileFile, loadMeshFromFile } from "@glyphcss/compile";

const { html, cols, rows } = await compileFile("dog.glb", { autoCenter: true });
```

`compileFile` loads the mesh from disk (reusing the library's `loadMesh` — format
dispatch, sibling `.mtl`, optimization) and hands it to `compileScene`.

### SSR / SSG components

React and Vue both export `GlyphSceneStatic`, which calls `compileScene` at render
time and outputs the `
`:

```tsx
import { GlyphSceneStatic } from "@glyphcss/react"; // or @glyphcss/vue


```

**Where it renders decides whether it's static.** Because `compileScene` runs at
render time, you only get a zero-runtime static `
` when the component is
rendered **at build or on the server**:

- ✅ **SSG / SSR** — an Astro island *without* a `client:` directive, a Next.js
  Server Component (or `getStaticProps` + `renderToString`), `vite-ssg`, Gatsby,
  Remix, `renderToStaticMarkup`. The `
` is baked into the HTML and, if not
  hydrated, **no glyphcss JS ships.**
- ❌ **Plain client-side React** (a default CRA / Vite `npm run build`) renders in
  the browser — `compileScene` executes client-side and glyphcss is in the bundle.
  You still get the `
` with no interactive machinery, but it isn't
  "compiled to static." For that, use the [Vite plugin](#vite-plugin) instead,
  which bakes the string at build in *any* Vite app.

Two caveats for true static output: the `polygons` must be available where it
renders (load them at build with `loadMeshFromFile` and pass them in — loading via
`loadMesh` in the browser reintroduces a runtime), and the island must not be
hydrated. For an interactive scene, use [`GlyphScene`](/components/glyph-scene).

### Options

Defaults are the **library** defaults (`createGlyphScene`). A loaded mesh is not
recentered or auto-fit unless you ask — pass `autoCenter` + a camera to frame it.

| Option | Query / CLI flag | Default |
|---|---|---|
| Camera angle | `rotX` `rotY` / `--rot-x` `--rot-y` | 65 / 45 |
| Zoom | `zoom` / `--zoom` | 0.65 |
| Projection | `projection=orthographic` / `--ortho` | perspective |
| Grid | `cols` `rows` `cellAspect` / `--cols` … | 80 / 24 / 2.0 |
| Render mode | `mode` / `--mode` | solid |
| Palette | `palette` / `--palette` | default |
| Colors | `colors=0` / `--no-colors` | on |
| Recenter mesh | `autoCenter=1` / `--auto-center` | off |
| Mesh optimize | `meshResolution` / `--mesh-resolution` | lossy |
| Smooth shading | `smoothShading` `creaseAngle` / `--smooth` | off / 60 |
| Back faces | `doubleSided` / `--double-sided` | culled |
| Supersample | `supersample` / `--supersample` | 1 |
| Semantic output | `glyphOutput` / `--glyph-output` (with `sceneManifest` + `dictionary`) | visible |

`charMode`, `hiddenLines`, `solidWeightRamp`, and `colorTolerance` are pure
functions of geometry + camera, so a static bake reproduces them exactly.
`colorTolerance` is the odd one out mechanically — it operates on the
already-rasterized cell grid as a post-hoc span merge, while the other three
change rasterization itself — but all four are equally reproducible at build
time. `compileScene` and `GlyphSceneStatic` (React + Vue) accept all four.
`@glyphcss/compile`'s CLI, Vite plugin and `compileFile` do **not** accept
them yet; call `compileScene` directly if you need them in those pipelines.
`buildGlyphFramesExport`, `buildGlyphInteractiveExport`, and
`buildGlyphFieldSynthStaticExport` (`@glyphcss/effects`) accept none of the
four either — a `colorTolerance` value set through the `/synth` page's UI (it
persists to the URL) is silently dropped by that page's CodePen export.

`wireframeJunctions` and per-mesh `density`/`transparent` are **runtime-only**
in every path — the static bake takes a flat polygon list and cannot represent
per-mesh detail layers.

> **Textures:** per-cell texture sampling needs browser image decoding, so the
> static compile renders from material / vertex colors.

## Interactive export

The static path produces a frozen frame. To ship interaction, declare which
interactions you want and `buildGlyphInteractiveExport` (pure, browser-safe)
produces a self-contained snippet — glyphcss from a CDN + the mesh inlined:

```ts
import { buildGlyphInteractiveExport, glyphCodepenPrefill } from "glyphcss";

const { html, pen, polygonCount } = buildGlyphInteractiveExport(polygons, {
  interactions: ["orbit", "zoom"],
  autoCenter: true,
});
// pen → { html, css, js } ready for a CodePen prefill (glyphCodepenPrefill)
```

An optional `effect: { id, params, blend?, timeScale? }` mounts a live **stock**
`@glyphcss/effects` layer in the snippet: it adds a second CDN import, resolves
the effect by id at runtime, and — when `timeScale > 0` — appends a small
`requestAnimationFrame` loop driving `params.time`. Only stock effects work here;
a custom `defineGlyphEffect` cannot cross the CDN boundary. `glyphcss` itself
never imports `@glyphcss/effects`, so that dependency only points one way.

The **capability manifest** (`interactions`) drives two things:

1. **Runtime** — only the declared control is imported, so the snippet
   tree-shakes. An orbit-only export ships less than an fpv one.
2. **Mesh** — [`decimatePolygons`](/api/headless) simplifies the geometry to a
   budget tied to the interaction (coarse for orbit; finer when zoom / fpv let
   the camera approach). The ASCII grid is coarse, so sub-cell geometry is
   invisible anyway.

| `interactions` | Ships |
|---|---|
| `[]` | static scene, no control |
| `["orbit"]` | orbit control, coarse mesh |
| `["orbit","zoom"]` | + wheel zoom, finer mesh |
| `["pan","zoom"]` | map controls |
| `["fpv"]` | first-person controls, finest mesh |

The CLI exposes the same via `--interactions orbit,zoom`.

### Static encoding

When emitting a fully static `
`, `encodeStaticGlyphHtml(inner, mode)` chooses
how colors and whitespace are encoded — the best choice is model-dependent:

| Mode | What it does |
|---|---|
| `classes` *(default)* | dedupes colors into `.cN{…}` rules + class spans, trims trailing spaces — smallest for typical dense, many-color renders |
| `grid` | CSS-grid-places each run by column/row — no literal spaces in the markup; wins for sparse renders with big gaps |
| `inline` | one `style="color:…"` per run — simplest |

## Baked frames (rotate, no mesh)

For orbit-class motion you can do better than shipping a mesh + runtime: pre-bake
a turntable of frames and cycle them with **pure CSS** — no mesh, no glyphcss, no
JS at all.

```ts
import { buildGlyphFramesExport } from "glyphcss";

const { html, pen } = buildGlyphFramesExport(polygons, {
  frameCount: 36,       // 10° steps over 360°
  autoCenter: true, rotX: 65,
  cols: 120, rows: 48,  // fixed grid; frames stack into one 
});
// pen.css holds a `@keyframes … steps(36)` loop; pen.js is empty
```

Each frame is a faithful `compileScene` render (colors baked per face for textured
meshes). The gallery's CodePen export exposes this as **Static → Rotate**.

Trade-offs: discrete angles (smoothness ∝ `frameCount`), fixed resolution, and
payload = `frameCount × frame`. It only covers orbit (1 axis) — pan / fpv have too
many camera states to bake, so those still use the [interactive](#interactive-export)
mesh + runtime path.

## Effect-only static export (field synth)

For a scene whose camera and mesh are fixed and where only a **field-synth**
texture animates, `buildGlyphFieldSynthStaticExport` bakes the static base grid
plus each covered cell's resolved domain coordinate once, then ships a tiny
hand-written field-synth evaluator that recomputes the pattern every frame —
**zero imports, zero CDN, no `glyphcss` at runtime**.

```ts
import { buildGlyphFieldSynthStaticExport } from "@glyphcss/effects";

const { html, pen } = buildGlyphFieldSynthStaticExport(polygons, {
  params,          // the live field-synth patch
  blend,           // the layer's REAL blend, not the definition's defaultBlend
  loopSeconds: 8,
  cols: 120,
  rows: 40,
});
```

Payload is fixed regardless of loop length and the motion is continuously
smooth — unlike a frame roll, whose payload grows with `frameCount`. Two bakes
shrink it further for the common flat-surface case: when every covered cell's
domain coordinate fits an affine function of `(col, row)`, the per-cell
coordinate table (most of the payload) collapses to six fitted scalars; and
when the effect covers every cell with `blend: "replace"` at opacity 1, the
base grid is skipped too. A curved or partially covered surface keeps the full
table.

Field synth only: another effect id would need its own exported coordinate
resolver plus a hand-written port of its per-cell math, since there is no way to
ship an arbitrary `GlyphEffectProgram.evaluate()` without shipping the effect
runtime with it.

This exporter ports field synth's 2D machinery — layers, `duty`, `phase`,
per-voice `angleN`/`originUN`/`originVN`, `subcellRes: "2x4"`/`"ink"`, and the
[SDF fields and `step` wave](/guides/effects#sdf-fields-gyroid-menger-sierpinski)
(`gyroid`/`menger`/`sierpinski`, at real-renderer exact parity) — but rejects
the [volumetric branch and carve/xray](/guides/effects#volumetric-fields-space-object)
explicitly (`space: "object"`, `render: "carve"`/`"xray"`, or an active voice
using `linearZ`/a nonzero `originWN`): a per-cell-per-frame march is a
different export design this affine-fit/coordinate-table bake can't fake. It
also rejects a `program` option
([program-as-data](/guides/effects#program-builder-and-program-as-data)) up
front, before the flat-param merge/bake — an unbounded field has no schema
this bake can serialize. The `originWN` reject is waived for an active SDF
voice, since that family reads `originW` even in the 2D branch. Check
`isGlyphFieldSynthStaticExportSupported(params)` before calling; a
volumetric or carve/xray patch (with no `program`) exports through the
[interactive export](#interactive-export) instead, which ships and evaluates
the live effect runtime rather than a baked approximation.


---

# Creating Shapes

import { Tabs, TabItem } from '@astrojs/starlight/components';

This guide covers building `Polygon[]` arrays by hand, using the built-in
geometry helpers from `@glyphcss/core`, and generating procedural meshes from math
functions. All examples are self-contained — copy any block and it will run.

## From scratch: a tetrahedron

A tetrahedron has 4 vertices and 4 triangular faces. The simplest approach: write
the vertices as a `Vec3[]` and the face indices by hand, then map to polygons.

```ts
import { createGlyphCamera, createGlyphScene } from "glyphcss";
import type { Polygon, Vec3 } from "@glyphcss/core";

// 4 vertices of a regular tetrahedron (circumradius ≈ 1).
const s = 1 / Math.sqrt(3);
const verts: Vec3[] = [
  [ s,  s,  s],  // 0
  [-s, -s,  s],  // 1
  [-s,  s, -s],  // 2
  [ s, -s, -s],  // 3
];

// 4 CCW-from-outside triangular faces.
const faceIndices: [number, number, number][] = [
  [0, 2, 1],
  [0, 1, 3],
  [0, 3, 2],
  [1, 2, 3],
];

const polygons: Polygon[] = faceIndices.map(([a, b, c]) => ({
  vertices: [verts[a], verts[b], verts[c]],
  color: "#aaffcc",
}));

// Render it.
const host = document.querySelector("#scene")!;
const camera = createGlyphCamera({ rotX: 25 });
const scene = createGlyphScene(host, { camera, mode: "solid", cols: 80, rows: 24 });
scene.add(polygons);
```

The resulting `Polygon[]` is a flat array — no scene graph, no hierarchy.
Each element has `vertices: Vec3[]` and an optional `color` hex string.
The rasterizer fan-triangulates N-gons internally.

## Built-in helpers

`@glyphcss/core` ships geometry generators for all common shapes. Each returns
`Polygon[]` — pass directly to `scene.add()` or to the `GlyphMesh` `polygons` prop.

### Platonic solids

#### `tetrahedronPolygons`

4 triangular faces. `size` is the circumradius.

```ts
import { tetrahedronPolygons } from "@glyphcss/core";

const polys = tetrahedronPolygons({
  center: [0, 0, 0],
  size: 1,
  color: "#ff6644",
});
// Returns Polygon[] — 4 triangular polygons.
```

#### `cubePolygons`

6 square faces. `size` is the edge length.

```ts
import { cubePolygons } from "@glyphcss/core";

const polys = cubePolygons({
  center: [0, 0, 0],
  size: 1,
  color: "#4488ff",
});
// Returns Polygon[] — 6 quad polygons.
```

#### `octahedronPolygons`

8 triangular faces. `size` is the half-extent (distance from center to each pole).

```ts
import { octahedronPolygons } from "@glyphcss/core";

const polys = octahedronPolygons({
  center: [0, 0, 0],
  size: 1,
  color: "#ffcc44",
});
// Returns Polygon[] — 8 triangular polygons.
```

#### `dodecahedronPolygons`

12 pentagonal faces. `size` is the circumradius. Vertices follow the golden-ratio
form; winding is CCW from outside, same as three.js `DodecahedronGeometry`.

```ts
import { dodecahedronPolygons } from "@glyphcss/core";

const polys = dodecahedronPolygons({
  center: [0, 0, 0],
  size: 1,
  color: "#cc44ff",
});
// Returns Polygon[] — 12 pentagonal polygons.
```

#### `icosahedronPolygons`

20 triangular faces. `size` is the circumradius.

```ts
import { icosahedronPolygons } from "@glyphcss/core";

const polys = icosahedronPolygons({
  center: [0, 0, 0],
  size: 1,
  color: "#44ffcc",
});
// Returns Polygon[] — 20 triangular polygons.
```

### Utility shapes

#### `planePolygons`

A single axis-aligned quad. `axis` picks the perpendicular direction (0=YZ, 1=XZ,
2=XY); `size` is the half-extent. Note `offset` defaults to `size * 2`, so the
quad is placed in the +A/+B corner rather than centred — pass `offset: 0` if you
want it on the origin.

```ts
import { planePolygons } from "@glyphcss/core";

const polys = planePolygons({
  axis: 1,      // quad lies in the XZ plane
  size: 0.4,
  offset: 0,    // centred on the origin (default is size * 2)
  color: "#ffffff",
});
// Returns Polygon[] — 1 quad polygon.
```

#### `ringPolygons`

A flat annulus (ring) perpendicular to a chosen axis. Made of `segments` quads
around the circle.

```ts
import { ringPolygons } from "@glyphcss/core";

const polys = ringPolygons({
  axis: 1,           // ring perpendicular to Y axis
  radius: 1.2,
  halfThickness: 0.05,
  segments: 32,
  color: "#ff4488",
});
// Returns Polygon[] — 32 quad segments forming the annulus.
```

#### `axesHelperPolygons`

Three thin colored cuboids along world X (red), Y (green), Z (blue). Mirrors the
three.js `AxesHelper` gizmo.

```ts
import { axesHelperPolygons } from "@glyphcss/core";

const polys = axesHelperPolygons({
  size: 2,
  thickness: 0.02,
  negative: false,   // only positive halves
  xColor: "#ff3a3a",
  yColor: "#3aff3a",
  zColor: "#3a8aff",
});
// Returns Polygon[] — 18 quads (6 per axis bar × 3 axes).
```

## Combining shapes

Stack multiple meshes in one scene by calling `scene.add()` for each, or by
composing multiple framework components.


  

```tsx
import {
  GlyphCamera,
  GlyphScene,
  GlyphMesh,
  GlyphOrbitControls,
} from "@glyphcss/react";
import { cubePolygons, octahedronPolygons, axesHelperPolygons } from "@glyphcss/core";

// Build each shape once (outside render).
const cube = cubePolygons({ center: [-1.5, 0, 0], size: 0.8, color: "#4488ff" });
const octa = octahedronPolygons({ center: [0, 0, 0], size: 0.8, color: "#ffcc44" });
const axes = axesHelperPolygons({ size: 1.5 });

export function App() {
  return (
    
      
        
        
        
        
      
    
  );
}
```

  
  

```vue



```

  
  

```ts
import { createGlyphCamera, createGlyphScene } from "glyphcss";
import { cubePolygons, octahedronPolygons, axesHelperPolygons } from "@glyphcss/core";

const host = document.querySelector("#scene")!;
const camera = createGlyphCamera({ rotX: 25 });
const scene = createGlyphScene(host, { camera, mode: "solid", cols: 120, rows: 36 });

scene.add(cubePolygons({ center: [-1.5, 0, 0], size: 0.8, color: "#4488ff" }));
scene.add(octahedronPolygons({ center: [0, 0, 0], size: 0.8, color: "#ffcc44" }));
scene.add(axesHelperPolygons({ size: 1.5 }));
```

  


## Mesh from a math function

Generate arbitrary surfaces with a `for` loop. This example builds a wavy plane
by pushing two triangles per grid cell. You can also push quads (4 vertices) —
the rasterizer fan-triangulates them automatically.

```ts
import { createGlyphCamera, createGlyphScene } from "glyphcss";
import type { Polygon, Vec3 } from "@glyphcss/core";

const COLS = 20;
const ROWS = 20;
const SIZE = 2;

function waveY(x: number, z: number, t = 0): number {
  return Math.sin(x * 3 + t) * 0.15 + Math.cos(z * 2.5 + t) * 0.1;
}

function makeGrid(t = 0): Polygon[] {
  const polygons: Polygon[] = [];

  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      const x0 = (c / COLS - 0.5) * SIZE;
      const x1 = ((c + 1) / COLS - 0.5) * SIZE;
      const z0 = (r / ROWS - 0.5) * SIZE;
      const z1 = ((r + 1) / ROWS - 0.5) * SIZE;

      const v00: Vec3 = [x0, waveY(x0, z0, t), z0];
      const v10: Vec3 = [x1, waveY(x1, z0, t), z0];
      const v01: Vec3 = [x0, waveY(x0, z1, t), z1];
      const v11: Vec3 = [x1, waveY(x1, z1, t), z1];

      polygons.push({ vertices: [v00, v10, v11], color: "#33aaff" });
      polygons.push({ vertices: [v00, v11, v01], color: "#2299ee" });
    }
  }
  return polygons;
}

const host = document.querySelector("#scene")!;
const camera = createGlyphCamera({ rotX: 25 });
const scene = createGlyphScene(host, { camera, mode: "solid", cols: 100, rows: 30 });
const handle = scene.add(makeGrid());
```

Each cell produces two triangular polygons sharing the quad diagonal `v00→v11`.
To animate the wave, call `handle.setPolygons(makeGrid(newTime))` after each
camera-end event — do not call it every frame.


---

# Density & Detail

import { Tabs, TabItem } from '@astrojs/starlight/components';

By default every mesh in a scene shares **one** character grid — the same glyph
resolution everywhere. That keeps the whole scene a single `
` write per
frame. But sometimes you want a *hero* mesh to carry far more detail than the
backdrop. glyphcss lets you bump the **density** of individual meshes, and choose
whether a mesh occludes the rest.

## How density is defined

The render is a `cols × rows` grid; **density is how many of those cells land on
your model** — more cells = finer detail. Cell size comes from the font:

```
cell width  ≈ font-size × monospace-advance    (≈ 0.6)
cell height =  font-size × line-height
```

So a smaller cell ⇒ more cells ⇒ more glyphs on the model. `density` is just the
ergonomic form of that: **`density: 3` makes a mesh's cell `1/3` the scene's, so
it renders at 3× the resolution** — isotropically, at the same on-screen size.

## Per-mesh `density`

Set `density` on a mesh and it **pops out into its own silhouette-fitted,
translated `
`** rendered at that resolution. Everything without `density`
stays in the shared base grid. Omitted (or `1`) = shared grid.


  

```tsx
import { GlyphCamera, GlyphScene, GlyphMesh, GlyphOrbitControls } from "@glyphcss/react";

export function Demo() {
  return (
    
      
        
                {/* shared grid */}
                  {/* 4× detail */}
      
    
  );
}
```

  
  

```vue



```

  
  

```ts
import { createGlyphCamera, createGlyphScene, resolveGeometry } from "glyphcss";

const camera = createGlyphCamera({ rotX: 62, rotY: 30 });
const scene = createGlyphScene(document.querySelector("#scene")!, { camera, mode: "solid", autoSize: true });

scene.add(resolveGeometry("cube", { size: 1 }), { position: [-3, 0, 0] }); // shared grid
scene.add(resolveGeometry("icosahedron", { size: 1 }), { density: 4 });    // 4× detail
```

  
  

```html

  
    
    
    
  

```

  


## Overriding with `fontSize` / `lineHeight`

`density` is the recommended knob, but you can drop to the raw cell metrics when
you want **anisotropic** cells (e.g. denser rows than columns). `fontSize` and
`lineHeight` set the mesh's `
` cell directly and **override `density`** when
both are present.

- `fontSize` — overall cell scale (a number is px, or any CSS length string). Both axes.
- `lineHeight` — cell *height* only → vertical density and cell aspect ratio.


  

```tsx
{/* density wins normally; fontSize/lineHeight override it */}
              {/* 4px cell */}
 {/* taller-res, anisotropic */}
```

  
  

```vue


```

  
  

```ts
scene.add(polys, { fontSize: 4 });
scene.add(polys, { fontSize: "5px", lineHeight: 0.6 });
```

  
  

```html


```

  


## Transparency & occlusion

A mesh in the shared grid always occludes (one depth buffer). Once meshes live in
their **own** `
` layers, glyphcss resolves which one wins per cell with a
shared camera-depth pass — opaque meshes correctly occlude each other across
layers (works with colored output, and costs nothing when no detail mesh exists).

Set **`transparent: true`** to make a mesh see-through — it neither occludes
others nor is occluded (an x-ray / blueprint look). Because that requires its own
layer, `transparent` also pops the mesh out of the shared grid. Default is
`false` (opaque, occludes).


  

```tsx
                 {/* opaque: occludes */}
      {/* x-ray: shows through */}
```

  
  

```vue


```

  
  

```ts
scene.add(dodeca, { density: 3 });                 // opaque
scene.add(icosa,  { density: 3, transparent: true }); // x-ray
```

  
  

```html


```

  


## Per-mesh ramp, lighting and render mode

Three more per-mesh options force a mesh into its own layer for the same
structural reason `density` does — the shared grid is rasterized **in one pass**,
so a mesh that wants its own ramp, its own ambient or its own render mode needs
its own `
`:

- **`glyphPalette`** — this mesh shades from a different named palette than the
  scene. Solid-mode ramps only; `charMode`, junctions and `solidWeightRamp` stay
  scene-level. An unknown name falls back to the library default, exactly like
  the scene-level option.
- **`ambientIntensity`** — this mesh's layer shades under
  `{ ...scene.ambientLight, intensity }`, so both its glyph choice and its texel
  tint follow that value. The ambient **colour** and the key light stay
  scene-level.
- **`mode`** — this mesh rasterizes in its own render mode (`wireframe`,
  `solid`, `voxel`, `ink`). A map is the motivating case: terrain reads as
  `solid` while an overlay reads as `ink`. Unlike the two above, separation is
  conditional — declaring the mode the scene is **already** rendering in keeps
  the mesh in the shared grid, byte-identical and one pass, because a mode is a
  small closed enum that can be compared exactly. The comparison is re-made
  every render, so a `setOptions({ mode })` that matches lets the mesh rejoin
  the base grid on the next frame.

Set any one of them alone and the mesh renders in its own `
` at the **base**
cell size — no extra detail, just a separate pass. Each distinct mode is a full
extra rasterizer pass, so reach for `mode` per layer, not per mesh.

Occlusion is unchanged by `mode`: a mode-separated **opaque** mesh still claims
its full footprint in the shared id-map, so an outline mode (`wireframe`, `ink`)
blanks the base cells under its silhouette while painting only edges. Add
`transparent` alongside it for the x-ray layering an outline over other geometry
usually wants.


  

```tsx

```

  
  

```vue

```

  
  

```ts
scene.add(polys, { glyphPalette: "dense", ambientIntensity: 0.8, mode: "ink", transparent: true });
```

  
  

```html

```

  


## Shaping an opaque mesh's occlusion claim

Cross-layer occlusion resolves per cell from a shared id-map, and by default a
layer claims a cell where its nearest surface point-samples into it. Three
per-mesh options shape that claim. All three apply to **opaque** detail meshes
only (a `transparent` mesh opts out of occlusion entirely), and all three default
to today's behaviour:

| Option | Default | What it does |
|---|---|---|
| `occlusionPriority` | `0` | Occlusion class. A higher class claims a cell over every lower one **regardless of depth**; depth only competes within a class. `1` is a foreground layer no scene geometry can occlude, a negative class is a background layer any mesh occludes. An unclaimed cell is claimable by any class |
| `occlusionClaim` | `"alpha"` | Claim shape. `"alpha"` claims only the cells whose sampled texel is opaque, so a sprite's transparent margin stops blanking the layer beneath. `"geometry"` claims the whole triangle footprint — a solid plate under partial-alpha artwork |
| `occlusionContourPx` | — | Coverage-aware claim: any output cell containing this mesh's ink claims, plus a margin in **screen px** stamped around that ink — the way to give fine artwork a clean ground. `0` is the tightest possible claim. Only converts base-layer or unclaimed cells — never steals from another detail mesh. The reduced map is still quantized to output cells, so the ground the layer beneath loses is too. Costs a finer id-map raster of the whole scene — see below |

`occlusionContourPx` re-rasters the id-map at 4× per axis, for **every** group in
the scene, whenever any mesh carries the option — measured at 2.4–3.1× the plain
id-map pass on a ~2,000-triangle scene, 7.5× at `supersample: 2`, and paid in
full even while the contour mesh is off-screen. Reach for it when a mesh's
alpha contour genuinely needs to drive the claim, not by default.

The margin is a **screen** reach, not a count of cells, so it stays visually
uniform on a cell that is taller than it is wide: at a 8×16px cell,
`occlusionContourPx={16}` buys one output row and two output cols of clean
ground on each side. Size it in cell heights — one cell height is a tight, clean
margin. The reach caps at `6 / supersample` output cells, so margins beyond a
few cells are out of range once supersampling is on.


  

```tsx
{/* an overlay layer scene geometry can never cover */}

{/* a sprite that keeps a clean margin of ground around its ink */}

```

  
  

```vue


```

  
  

```ts
scene.add(overlay, { density: 2, occlusionPriority: 1 });
scene.add(sprite,  { density: 2, occlusionContourPx: 16, occlusionClaim: "alpha" });
```

  
  

```html


```

  


## Works in any camera — including first-person

Detail meshes are rendered **in place** at higher resolution (real world
positions, scaled zoom, an offset projection center), not faked with a transform.
So detail + cross-layer occlusion stay correct under **every** camera:

- **Orthographic** — the representative glyphcss camera (iso / diagrammatic scenes).
- **Perspective** — foreshortening of detail meshes is correct as you orbit or zoom.
- **First-person (FPV)** — you can **walk through a scene** (`createGlyphFirstPersonControls` / ``) and hero meshes stay sharp and correctly occlude as you move. Walking *into* a mesh is safe — the detail grid is clamped to the viewport, so getting close never blows up the render.

No flags or special handling — set `density` (and optionally `transparent`) on a
mesh and it behaves the same whether the scene is orbited, flown, or walked.

## Reference

| Option | Type | Default | Effect |
|---|---|---|---|
| `density` | `number` | `1` (shared grid) | Render this mesh at `density`× the scene resolution, in its own `
`. |
| `fontSize` | `number \| string` | — | Explicit cell size (px or CSS length). **Overrides `density`.** |
| `lineHeight` | `number` | — | Explicit cell line-height (vertical density / aspect). **Overrides `density`.** |
| `transparent` | `boolean` | `false` | See-through — doesn't occlude / isn't occluded. Pops the mesh into its own `
`. |
| `glyphPalette` | `string` | scene's | Per-mesh solid ramp. Pops the mesh into its own `
` (the shared grid shades against one ramp). |
| `ambientIntensity` | `number` | scene's | Per-mesh ambient intensity. Pops the mesh into its own `
` (the shared grid is lit under one ambient). |
| `mode` | `"wireframe" \| "solid" \| "voxel" \| "ink"` | scene's | Per-mesh render mode. Pops the mesh into its own `
` **only when it differs** from the scene's mode (the shared grid is rasterized in one pass under one mode). |
| `occlusionPriority` | `number` | `0` | Occlusion class — a higher class claims id-map cells regardless of depth. Opaque detail meshes only. |
| `occlusionClaim` | `"alpha" \| "geometry"` | `"alpha"` | Claim only texel-opaque cells, or the full triangle footprint. Opaque detail meshes only. |
| `occlusionContourPx` | `number` | — | Coverage-aware claim with a screen-px margin of clean ground around the ink. Never steals from another detail mesh. Costs a 4×-per-axis id-map raster. Opaque detail meshes only. |

Precedence: explicit `fontSize`/`lineHeight` → `density` → shared grid.

## Notes & limits

- **Browser-only.** Detail layers measure the live cell size, so they need
  layout — they don't apply during SSR / static rendering.
- **Any camera** (ortho / perspective / FPV) — see the section above.
- **Cost is where you'd expect.** Each detail mesh is one extra `
` render
  per frame; the cross-layer occlusion pass only runs when an opaque detail mesh
  is present. Meshes left in the shared grid are unchanged. Use detail
  sparingly — a few hero meshes over a low-res backdrop, not everything.
- **Smooth dragging at high resolution.** A very small cell means many glyphs
  to shade, stringify, and repaint every frame, and cost scales roughly
  quadratically with density — dragging can stutter. Set
  **`interactiveDownscale`** on the scene (e.g. `2`) to render coarser *while
  a control is dragging* and snap back to full detail on release, at the same
  on-screen size. Keep the render font ≥ ~6px, or lean on
  `interactiveDownscale`, for fluid interaction.
- **Static compile / export.** `compileScene`, `GlyphSceneStatic`, the CLI/Vite
  plugin, and the interactive/CodePen export work from a flat polygon list, so
  per-mesh detail layers aren't represented there. For a static whole-scene
  resolution, scale the render font-size instead.


---

# Glyph Effects

import { Tabs, TabItem } from '@astrojs/starlight/components';

Glyph Effects change the **raster appearance** of a scene after geometry has
been projected and depth-tested. A parameter-only update reuses the retained
glyph frame — no polygon is transformed or rasterised again. Effects stay
inside the normal render transaction: no extra DOM nodes, and every affected
`
` is written at most once.

Install the optional catalog alongside the binding you use:

```bash
pnpm add @glyphcss/effects glyphcss
```

The catalog contains `matrixRain`, `flowText`, `scan`, `wipe`, `scramble`,
`glitch`, `noiseDissolve`, `ripple`, and `fieldSynth`.

## Mount an effect


  

```ts
import { createGlyphScene } from "glyphcss";
import { GlyphEffects } from "@glyphcss/effects";

const scene = createGlyphScene(host, { mode: "solid", autoSize: true });
const rain = scene.addEffectLayer({
  effect: GlyphEffects.matrixRain,
  blend: "replace",
  params: {
    glyphs: "HOLA",
    speedMin: 5,
    speedMax: 12,
    trail: 14,
    density: 0.55,
  },
});

function frame(now: number) {
  rain.params.time = now / 1000;
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
```

  
  

```tsx
import { useEffect, useRef } from "react";
import { GlyphEffectLayer, type GlyphEffectLayerHandle } from "@glyphcss/react";
import { GlyphEffects } from "@glyphcss/effects";
import type { GlyphEffectParamsOf } from "glyphcss";

function Rain() {
  const ref = useRef>>(null);

  useEffect(() => {
    let raf = 0;
    const frame = (now: number) => {
      if (ref.current) ref.current.params.time = now / 1000;
      raf = requestAnimationFrame(frame);
    };
    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, []);

  return (
    
  );
}
```

Place `` inside the surrounding ``.

  
  

```vue



```

  
  

```html



```

`` must be a child of ``. Effect definitions
are JavaScript values, so configuration is property-based rather than
executable JSON in attributes.

  


## Composition and mapping

How a layer combines with the base render:

- `blend: "replace"` makes the effect the texture. Cells the effect leaves
  empty hide that part of the surface — Matrix rain describes the model with
  the falling strands alone.
- `blend: "over"` keeps the shaded glyph texture and paints only active effect
  cells on top of it.
- `target: "surfaces"` limits writes to visible rendered geometry.
- `target: "viewport"` can also author the base output's background.
- `target: mesh` or `target: [meshA, meshB]` limits writes to that mesh (or
  meshes) only — see [Targeting specific meshes](#targeting-specific-meshes)
  below.

### Targeting specific meshes

Pass the handle returned by `scene.add(...)` (or an array of handles) as
`target` to apply a layer to specific geometry instead of the whole scene —
a normal render plus one weird effect object, without touching anything
else:

```ts
import { createGlyphScene } from "glyphcss";
import { GlyphEffects } from "@glyphcss/effects";

const scene = createGlyphScene(host, { mode: "solid", autoSize: true });

const ground = scene.add(groundPolygons);
const cube = scene.add(cubePolygons);

const carve = scene.addEffectLayer({
  effect: GlyphEffects.fieldSynth,
  target: cube, // only the cube's own cells — the ground is untouched
  blend: "replace",
  params: {
    space: "object",
    render: "carve",
    field1: "menger", wave1: "step", freq1: 3, amp1: 1, iter1: 3,
    amp2: 0,
    combine: "min",
    glyphs: " .:-=+*#%@",
    color: "#ffcf5a",
  },
});

function frame(now: number) {
  carve.params.time = now / 1000;
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
```

The target normalizes to an immutable set of mesh ids at mount — it cannot
be changed afterward (`setOptions({ target: ... })` with a different mesh set
throws; an equivalent set, e.g. the same handles in a different array, is a
no-op). To retarget, remove the layer and add a new one. A mesh removed from
the scene later simply stops matching — the layer degrades to inactive, it
does not error. Targeting a mesh outside `solid` mode is also a documented
no-op rather than a throw, since only solid mode tracks a per-cell winning
mesh.

**Ordering note:** a later scene-wide (`target: "surfaces"`/`"viewport"`)
layer still composites *over* an earlier targeted layer's output, including
painting into any holes a targeted `render: "carve"` cut — that's the normal
layer-order semantics working as designed, not a bug specific to targeting.

Mesh identity also matters inside a single targeted carve layer: [ink-over-carve and braille-over-carve](#ink-over-carve-and-braille-over-carve)
never bridge a contour or Braille dot across a boundary between two different
target meshes, even when they're coplanar and share a normal — the same
per-cell winning-mesh data `target` itself reads.

### Coordinate space (`space`)

`space` picks the coordinate system the pattern is evaluated in:

| Value | Mapping |
|---|---|
| `"auto"` | Authored, perspective-correct UVs when the mesh has them; otherwise the generated surface mapping; otherwise projected scene coordinates. |
| `"surface"` | Always the generated per-face mapping, even when authored UVs exist. |
| `"scene"` | Projected 2D scene coordinates. |
| `"object"` | A volumetric field in the mesh's own 3D frame — see [`space: "object"`](#space-object--volumetric-mapping) below. |

`flowText` and `scan` default to `"auto"`. `matrixRain` defaults to
`"object"` — a volumetric field is matrix rain's natural form.

A matrix-rain preset tuned before `"object"` became the default should set
`space: "auto"` explicitly to keep its old look; `scale` in particular means
something different under `"object"`.

`scale` controls pattern frequency without changing scene glyph density.

### Generated surface mapping

When no authored UVs exist, each face derives its own flow frame:

- `down` is world `-Z` projected into the face's tangent plane. Slopes flow
  downhill, coplanar triangles agree, and differently oriented faces derive
  different directions.
- A face exactly perpendicular to `-Z` has no downhill direction, so it gets
  a deterministic pseudo-random in-plane direction instead.
- `up` reverses the generated flow; `left` and `right` use its perpendicular
  lane axis.

If surface fields are unavailable, the effect falls back to projected scene
coordinates.

### Text glyphs

Text effects accept any printable, single-cell character set: `HOLA`, `⣿⠿⠷`,
`ᚠᚢᚦ`, `←↑→↓`. Combining marks, emoji, and double-width glyphs are rejected —
they would break the fixed cell grid.

### Matrix rain behavior

On surfaces, each strand flows and reads in the face's own frame, so the word
travels at the strand's direction and speed even on sheared or foreshortened
faces. Active trail cells emit full coverage; `density` and `trail` create the
sparse look, so a word never dithers into isolated glyphs.

`colorMode` keeps the model's original colors or switches to a single
monochrome `color`. In monochrome, each strand lights a bright `headColor`
head and fades its tail toward black — the classic falling-code gradient —
still modulated by the surface shade, so lighting, shadows, and the 3D
silhouette stay visible.

### `space: "object"` — volumetric mapping

`"auto"` and `"surface"` give every polygon its own 2D parameterization. A
cube's caps and walls can never fully agree on one flow, and a face turned
edge-on to its projection plane smears. `space: "object"` stops painting the
pattern per face: it becomes a **field filling the mesh's volume**, sampled at
each covered cell's position in the mesh's own pre-transform 3D frame. Faces
are windows into the same body of rain — they agree by construction, and the
field turns with the mesh.

Matrix rain uses this volumetric form directly, which is why it defaults to
`"object"`. The mesh-local frame is Z-up: strand phase runs along `Z` for
`down`/`up` and along `Y` for `left`/`right`, and lanes are indexed by the
remaining axes — so a cap cell and an adjacent wall cell continue the same
strand across their shared edge. A cell whose sample sits near a lane boundary
fades briefly instead of popping to another strand as the mesh rotates.

`scan` and `flowText` have no 3D form of their own, so under `"object"` they
use a **triplanar** fallback: each cell projects onto whichever axis-aligned
plane its face normal most faces, with a narrow blend across transitions.

Two practical notes:

- `"object"` needs the retained `objectPosition` field, which only exists in
  `solid` mode. In wireframe/voxel modes it degrades to the same
  surface/scene fallback `"surface"` uses — never a silent empty render.
- Under `"object"`, `scale` is a 3D field frequency, not a 2D UV frequency.
  Re-tune it after switching mapping.

## Stock effect parameters

Every stock effect also takes `time` (the clock you drive), and the mapping
params `space` / `scale` where noted in [Composition and mapping](#composition-and-mapping).
`direction` is `"up" | "down" | "left" | "right"`.

| Effect | Parameters (with defaults) |
|---|---|
| `matrixRain` | `glyphs: "HOLA"`, `direction: "down"`, `space: "object"`, `speedMin: 5`, `speedMax: 12`, `trail: 14`, `density: 0.55`, `seed: 1`, `colorMode`, `color: "#00ff66"`, `headColor: "#d8ffe4"` |
| `flowText` | `glyphs: "HOLA"`, `direction: "right"`, `speed: 6` (cells/s) |
| `scan` | `speed: 10` (cells/s), `width: 3` (cells), `spacing: 28` (cells), `color: "#ffffff"` |
| `wipe` | `progress: 0.5`, `softness: 0.04`, `direction`, `invert: false` |
| `scramble` | `glyphs: "@#$%&*+=?"`, `amount: 0.35`, `rate: 10` (Hz), `seed: 1` |
| `glitch` | `glyphs: "#%/=+!?"`, `amount: 0.28`, `rate: 12` (Hz), `bandSize: 4` (rows), `seed: 1`, `color: "#ff4fd8"` |
| `noiseDissolve` | `progress: 0.5`, `softness: 0.08`, `scale: 0.22`, `seed: 1` |
| `ripple` | `glyphs: "*+"`, `speed: 6`, `frequency: 0.5`, `width: 0.18`, `amount: 0.85`, `color: "#72d9ff"` |

`wipe` and `noiseDissolve` are **masks**: they write coverage only, driven by
`progress` rather than `time` — that is how you get a determinate progress bar
rather than a loop. Mount them with `blend: "replace"`; under `"over"` the
masked-out region stays lit, so nothing appears to fill.

Reading a schema at runtime rather than from this table:

```ts
import { getGlyphEffect, defaultGlyphEffectParams, GlyphEffectCatalog } from "@glyphcss/effects";

const scan = getGlyphEffect("scan");            // by id, or iterate GlyphEffectCatalog
const defaults = defaultGlyphEffectParams(scan); // every param at its default
scan.parameterSchema.width;                      // { kind, default, min, max, step, unit, label }
```

## Field synth

`fieldSynth` is a small composable synth: up to nine oscillators, each a
spatial **field** sampled through a **waveform**, combined into one scalar and
mapped to a glyph ramp and color. It is the glyph-grid analogue of a fragment
shader — interfering a handful of oscillators is where the emergent moiré,
plaid, sonar, and lattice patterns come from.

### Voices

Each of the nine voices (`field1`/`wave1`/`freq1`/`speed1`/`amp1` through
`field9`/…/`amp9`) is independent:

- `fieldN` — the spatial field. `radial` measures distance from the origin,
  `angular` the angle around it, `spiral` combines both;
  `linearX`/`linearY`/`diagonal` are ramps; `noise` is a 3D value-noise field
  that evolves in place over `time` rather than sliding. `linearZ` exists only
  under the volumetric branch — see [below](#volumetric-fields-space-object).
  `gyroid`/`menger`/`sierpinski` are a separate SDF family — see
  [below](#sdf-fields-gyroid-menger-sierpinski).
- `waveN` — the waveform: `sin`, `triangle`, `saw`, `square`, or the
  non-periodic `step` (`+1` at or above its argument's zero, else `−1`).
- `freqN` — spatial frequency, in cycles per unit.
- `speedN` — animation rate, in cycles per second.
- `ampN` — a **mix weight**, not a signal gain. Each active voice blends the
  running result toward `combine(result, voice)` by its own `ampN`. `amp: 0`
  skips the voice entirely; a low `ampN` gently mixes it in instead of
  crushing the field.
- `dutyN` (`0..1`, default `0.5`) — the square wave's high fraction (other
  waveforms ignore it).
- `phaseN` (cycles, default `0`) — added to that voice's wave argument. Voice
  origins don't shift linear fields at all (`linearX`/`linearY`/`diagonal`
  ignore the origin; only `angleN` and the radial/angular/spiral centers read
  it), so `phase` is how you shift one: a middle-third band selector is
  `wave: "square", duty: 1/3, phase: -1/3`.

### Combine, gain, bias

`combine` (`add` / `multiply` / `max` / `min` / `difference`) folds active
voices pairwise, in voice order. `gain` (contrast) and `bias` (brightness)
then map the combined scalar to `0..1`:
`value = clamp01(bias + gain * combined * 0.5)`.

`combine: "argmax"` is the exception — it is **categorical**. It reports
*which* voice won a cell, not by how much, so every cell in a region gets one
flat level (and, with `voiceColors`, the winning voice's own color). That is
what makes hard-edged tilings reachable: three plane waves 60° apart under
`argmax` produce a cube tessellation no value-folding operator can express.
Under `argmax`, `amp` becomes a comparison weight — a quiet voice simply
loses everywhere rather than blending in.

### Placement: scale and origin

`scale` sets pattern frequency. `originU`/`originV` place the center that
`radial`, `angular`, and `spiral` measure from.

Each voice can also carry its own placement on top of the global values:

- `angleN` (degrees, default `0`) rotates that voice's sampling frame about
  its own origin, turning the three fixed linear fields into one steerable
  plane wave — two gratings a few degrees apart is where fine moiré lives.
  `radial` is rotation-invariant; `angular`, `spiral`, and `noise` respond.
- `originUN`/`originVN` (default `0`) offset that voice's center from the
  global origin, so two `radial` voices can sit on **different** centers —
  the classic interference figure.

On generated surface coordinates (no authored UVs), origin resolves **per
face**: `originU: 0.5, originV: 0.5` centers the pattern on each visible
face's own bounds rather than on one fixed world point.

### Output: ramp, color, lighting

- `glyphs` — a **ramp** indexed by the mapped `0..1` value (dark → dense),
  not a random character pool. See [`GlyphRamps`](#glyphramps), or author one
  like `" .:-=+*#%@"`. A leading space gives the ramp a true blank dark step.
- `color` / `colorB` / `gradient` — a two-color gradient across the mapped
  value. `gradient: 0` is solid `color`; `1` is a full blend to `colorB`.
- `voiceColors` (default `false`) — replaces the gradient with a per-voice
  blend: each active voice's own `colorN` is mixed in by that voice's
  contribution.
- `lit` (`0..1`, default `1`) — modulates the output color by the surface's
  Lambert shade, so scene lighting reads through the texture. `0` is
  flat/unlit.

`subcellRes` selects how the field becomes glyphs:

| Value | Rendering |
|---|---|
| `"1x1"` (default) | One ramp-indexed glyph per cell. |
| `"2x4"` | Thresholds eight subcells into a Braille dot pattern (`U+2800` block) — finer shape, still one glyph per cell. Ignores the ramp. |
| `"ink"` | Contours the field instead of shading it, the way `mode: "ink"` outlines geometry. Ignores the ramp; emits plain-ASCII strokes. |

At `"ink"`, `inkLevels` (`1..12`, default `4`) sets how many evenly spaced
contour levels are drawn. A cell inks where a level crosses between it and a
neighbour, with the stroke oriented perpendicular to the local gradient;
plateaus and interiors stay empty. An oscillating field contours densely, so
legibility comes from *fewer* levels, not more.

The `"2x4"` dot offsets are exact under `space: "scene"`; under
`"auto"`/`"surface"` they are reconstructed from neighboring cells and can
shear on genuinely curved surfaces.

`fieldSynth` shares the same `space: "auto" | "surface" | "scene" | "object"`
mapping described [above](#composition-and-mapping).

### Volumetric fields (`space: "object"`)

Under `space: "object"`, field synth takes its own 3D branch rather than
reusing the 2D formulas with `z` fixed at `0` — most fields are genuinely
different in the two branches:

- `linearZ` — the third axis projection. Only meaningful in 3D.
- `diagonal` is `(x + y + z) / √3` here, vs. `(x + y) / √2` in 2D — the 2D
  formula is untouched so existing presets don't shift.
- `radial` measures spherical distance from the voice origin.
- `angular` and `spiral` stay evaluated in the XY plane; `z` is ignored.
- `noise` becomes a 4D hash (adding `z` to the existing `x, y, time` lattice)
  so a volumetric noise voice animates instead of freezing.

Voice origins gain `originWN` (default `0`) for the third axis, alongside the
existing `originUN`/`originVN`. `angleN` keeps its 2D meaning — rotation about
Z. `scale` is a 3D field frequency under `"object"`, the same caveat matrix
rain's own volumetric mode carries: a value tuned for `"auto"`/`"surface"`
does not carry over 1:1.

`subcellRes: "2x4"` and `"ink"` still work volumetrically, finite-differencing
neighboring cells' resolved 3D coordinate, for `render: "paint"`. Under
`render: "carve"` both are legal too, but through a different mechanism —
[ink-over-carve and braille-over-carve](#ink-over-carve-and-braille-over-carve),
below — since a march has no stable neighbor *coordinate* to difference, only
a per-cell hit/hole result. `render: "xray"` still rejects both: an
accumulated transmittance integral has no per-cell hit point for either
volumetric subcell mode to read.

### SDF fields (`gyroid`, `menger`, `sierpinski`)

Every field above is a 1D wave sampled along some scalar projection of space
— constant along its own level sets, terrain always ruled or revolved. The
SDF family breaks that: `gyroid`, `menger`, and `sierpinski` are genuine
implicit surfaces, so a single voice already carries real 2D/3D relief with
no `combine` needed.

They also read placement differently from every other field. `originUN`/
`originVN`/`originWN` **translate the sampled point** before evaluation —
the opposite of a linear field (which ignores origin) or radial/angular/
spiral (which read origin only as a distance-from-center anchor) — since an
implicit surface has no other way to align itself to its host mesh. `phaseN`
correspondingly becomes an **iso-level offset**: it erodes or dilates the
solid rather than sliding it.

- `gyroid` — a smooth periodic implicit
  (`sin(2πx)cos(2πy) + sin(2πy)cos(2πz) + sin(2πz)cos(2πx)`), `freq`-normalized
  so `freq` means cycles per domain unit like every other field. Its sign
  means "which labyrinth half," not inside/outside solid.
- `menger` / `sierpinski` — the signed distance to the depth-`iterN`
  approximation of a Menger sponge / corner-tetra Sierpinski fractal: the
  union of solid boxes (menger) or corner tetras (sierpinski) kept after
  `iterN` rounds of subdivision. This is an **exact** union distance (computed
  by a pruned recursive descent over the same kept-child tree the fractal's
  own membership rule walks), not a distance-estimator approximation — a true
  limit-set SDF is positive almost everywhere and would carve to nothing.
  `iterN` (integer `1..4`, default `3`) is capped at 4 because carve/xray's
  march resolution caps at 256 steps; `menger` at `iter: 4` already needs
  ~162 steps on a unit chord, and `iter: 5` would need ~486 and render
  guaranteed false holes. **`iter: 3` and up costs noticeably more** to
  march/integrate at full resolution — turn on `interactiveDownscale` while
  dragging camera controls on a deep menger/sierpinski patch.

Both fractal fields assume the unit cube `[0, 1]³` as their domain window,
matching the `pyramid` stage's own (uncentered) authoring box — see the
Sierpinski pyramid preset below. In the 2D branch, every SDF field evaluates
at `z = 0` (a slice through the volume).

The non-periodic `step` waveform exists mainly for this family: every SDF
preset relies on "inside the solid → `step` → `+1` → density above the
default bias → solid," which a periodic wave can't express directly. `step`
is legal on any field, though — a `linearX` + `step` voice is a half-space.

### Voice layers

Voices can opt into one of three **layers** (`layer1..9`, default `1`), each
with its own intra-layer combine, threshold, invert, and blend into the next
layer:

- `layerCombineL` — how that layer's own voices fold (default: the patch's
  `combine`).
- `layerThresholdOnL` / `layerThresholdL` (`-3..3`, default off / `0`) — when
  on, the layer's folded value collapses to `+1` above the threshold or `-1`
  below it.
- `layerInvertL` — negates the layer's value (after thresholding, if on).
- `layerBlendL` (`add`/`multiply`/`max`/`min`/`difference`, default
  `multiply`) and `layerAmpL` (`0..1`, default `1`) — how the layer enters the
  stack, one level up from how voices enter a layer.

Layers exist because a flat fold of voices — every voice is a 1D wave along
one axis — cannot express a **per-scale** rule like a Menger sponge's ("hole
if at least two axes are in their middle third at the *same* scale"). A
threshold per layer, one layer per scale, is what makes that expressible; a
single flat `combine` cannot mix a per-scale threshold into the fold.

A single-layer patch with threshold and invert off and amp `1` is identical
to the pre-layers flat fold — existing patches and presets are unaffected.
`argmax` stays single-layer: a multi-layer patch is only valid if every
populated layer's *effective* combine (its own override, or else the patch's
`combine`) resolves to something other than `argmax`.

### Carve mode

`render: "paint"` (default) is everything above. `render: "carve"` raymarches
the field through the mesh's own volume and turns it into interior structure —
holes, walls, hollow chambers — instead of a surface texture. `render: "xray"`
(below) shares the same march but reports a transmittance brightness instead
of a first hit. Both require the volumetric branch (`space: "object"`); xray
is `subcellRes: "1x1"` only, while carve also supports `"2x4"` and `"ink"` —
see [Ink-over-carve and braille-over-carve](#ink-over-carve-and-braille-over-carve)
below.

Per cell, carve marches from the mesh's entry point to its exit point along
the view ray, sampling the same combined field paint uses. `marchSteps`
(default `48`, max `256`) sets the sampling density; a chord with fine
enough content near the cap raises this automatically so thin walls don't
flicker in and out under rotation. Where the march finds no solid sample, the
cell renders nothing; the recommended mount is `blend: "replace"` at full
opacity, so a hole shows the page background — the "hollowed-out" look. Under
`blend: "over"`, the base surface still shows through a hole instead.

An interior hit fades with depth — `marchFade` (default `1`) controls how
quickly a wall recedes into shadow as the march goes deeper — so a
near-surface wall reads brighter than one buried further in.

This layer/duty/phase/volumetric machinery is what a Menger-membership carve
is built from (`space: "object"`, `render: "carve"`, three layers of three
axis voices each — a scale-3, then scale-9, then scale-27 lattice,
`min`-blended so solid means "solid at every scale") on a plain cube mesh —
no sponge geometry, no baked frames needed. `fieldSynth` ships one preset
built from this same recipe — **Sierpinski pyramid** (base-2 constants, two
layers instead of three) — tuned for the dedicated `pyramid` stage the
[`/synth`](/synth) page offers alongside its cube/sphere/tetrahedron shapes —
an uncentered corner-tetra whose window matches the recipe's own `[0, 1]³`
assumption. The recipe's own `square`-wave axis voices (not the
`menger`/`sierpinski` SDF fields) drive its Nyquist floor, and that floor is
duty-aware: `freq / min(duty, 1 − duty)` (two samples per narrowest band)
rather than a flat `2 × freq` — a three-layer, scale-27 Menger recipe's finest
band needs only 94 steps under the fixed formula, well inside the 256-step
cap; an earlier, un-fixed estimate put it past ~281 and out of carve's budget
entirely.

### Ink-over-carve and braille-over-carve

`subcellRes: "ink"` and `"2x4"` both work under `render: "carve"` — a
different mechanism from their `render: "paint"` form, since carve has no
stable per-cell field coordinate to finite-difference, only a per-cell march
result.

- **`"ink"`** contours the march's hit/hole boundary instead of shading every
  hit cell — an outline of the sponge/fractal interior rather than a filled
  one. A new param, `inkSpacing` (domain units, default `0.25`), sets contour
  spacing in absolute terms (not a fraction of the observed depth range, which
  would make contours crawl frame-to-frame under orbit). Every silhouette
  edge — a hit cell next to a hole, or next to a cell on a *different* target
  mesh — is always inked as a rim; interior cells ink where a multiple of
  `inkSpacing` falls between two neighboring march depths. `inkLevels` is a
  no-op here (2D `subcellRes: "ink"` keeps using it).
- **`"2x4"`** marches 8 sub-rays per cell, one per Braille dot position,
  registering a dot wherever its own sub-ray hits — finer silhouette detail
  than `"1x1"`'s one glyph per cell. One color per cell (the center sub-ray's
  hit color, or the first hitting sub-ray's).

Both respect [mesh targeting](#targeting-specific-meshes): a contour or dot
mask never bridges across a boundary between two different target meshes,
even when they're coplanar and share a normal.

### Sphere tracing

For programs that are provably distance fields — a single layer, every active
voice `menger` or `sierpinski` with `wave: "step"` and `amp: 1`, `combine`
`"min"`, no layer threshold — carve automatically switches from fixed-step
marching to sphere tracing: stepping by the field's own reported distance
instead of a fixed grid, converging faster on thin or deep recursive detail.
It falls back to an ordinary fixed-step scan of the remaining segment when a
ray stalls near an off-axis feature or runs into its own step budget, so it
never finds *fewer* hits than fixed-step marching would — only the same or
more, at the same shaded ramp step. Measured roughly 1.8-1.9× faster than
fixed-step marching on a deep (`iter: 3`) recursive scene. A single SDF voice
(`menger` or `sierpinski`, `wave: "step"`, `amp: 1`, `combine: "min"`, no
layer threshold) is what qualifies — the shipped Sierpinski pyramid preset
above is built from linear recipe voices instead and never qualifies, so it
stays on the fixed-step path unchanged. No preset currently ships built from a
genuine SDF voice, so no shipped preset exercises sphere tracing directly —
the mechanism itself remains fully implemented and tested independent of any
preset. No params control this — it applies automatically whenever a mounted
carve patch qualifies.

### Xray mode

`render: "xray"` is carve's sibling: instead of stopping at the first solid
sample, it **integrates** density along the whole chord and reports how much
of it a viewer sees through. Per cell: transmittance
`T = exp(-xrayGain * integral)`, brightness `B = 1 - T`. `xrayGain` (default
`4`, range `0..16`) is xray's own contrast knob — `marchFade` doesn't apply
here, since carve's own default fade barely dims a solid unit chord and `0`
means opposite things in the two modes (no fade in carve, fully invisible in
xray).

A cell with `B` under `1/255` renders nothing; anything at or above it gets
**full coverage**, regardless of the resolved color's alpha — a translucent
xray color is a stylistic choice, not a transparency signal to dither through.
`voiceColors` has no meaning under xray (an integral has no single winning
voice) and is hidden in the UI. A degenerate chord (no exit point, or zero
length) also renders nothing — unlike carve's paint-at-entry fallback, since a
bright rim around a transmittance volume would contradict the mode.

**Absorption reads near-binary fields best.** A smoothly oscillating field
integrates to roughly its own `bias` over any long-enough chord — the
structure averages into fog. Shaping a voice with a layer threshold (turning
it into a flat two-level field) before feeding it to xray reads its actual
structure instead; the shipped **Breathing gyroid** preset does exactly this.

### Animated volumetrics

Every volumetric preset's `speedN` is exactly the same time axis every 2D
preset's own animation already uses (`raw*freq - time*speed + phase`, or for
the SDF voice family `sdfRaw - time*speed + phase`) — nothing new is needed to
animate a carve/xray patch, just a nonzero `speedN` on an already-volumetric
recipe. One shipped preset turns this on: **Breathing gyroid** animates the
gyroid xray recipe's one voice — the implicit surface deforms in place rather
than sliding along a single axis (a gyroid's three-term sum has no one "flow
axis" the way a linear voice does), reading as an organic pulse rather than a
directional sweep.

A `wave: "step"` SDF voice (the only wave [sphere tracing](#sphere-tracing)
qualifies) can animate the same way, but stays a one-way erosion/bloom rather
than a looping breathe cycle: `synthWave`'s `step` case is explicitly
non-periodic (`+1` when `t >= 0`, else `-1`, no wraparound), so raising
`speed*time` over time only ever SHRINKS the solid region — an inherent
trade-off of staying inside the sphere-tracing predicate rather than a tuning
mistake. A consumer wanting a repeating loop resets or wraps `time` itself.

### Example

```ts
import { createGlyphScene } from "glyphcss";
import { GlyphEffects } from "@glyphcss/effects";

const scene = createGlyphScene(host, { mode: "solid", autoSize: true });
const synth = scene.addEffectLayer({
  effect: GlyphEffects.fieldSynth,
  blend: "replace",
  params: {
    field1: "radial", wave1: "sin", freq1: 4, speed1: 0.6, amp1: 1,
    field2: "angular", wave2: "saw", freq2: 6, speed2: 0.3, amp2: 1,
    combine: "multiply",
    glyphs: " .:-=+*#%@",
    color: "#ffcf5a",
    colorB: "#ff4fa3",
    gradient: 0.6,
  },
});

function frame(now: number) {
  synth.params.time = now / 1000;
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
```

`fieldSynth` also ships curated named presets (`Sunburst`, `Ring pulse`,
`Plaid weave`, `Sonar ping`, `Lattice`, `Vortex`, `Lava`, `Static rain`,
`Moiré rings`, `Checkerboard`, `Warp core`, `Bubbles`, `Aurora`, `Zebra`,
`Kaleidoscope`, `Halftone`, `Weave`, `Pulse grid`, `Nebula`, `Cube tiles`,
`Ink cells`, `Sierpinski pyramid`, `Breathing gyroid`,
`Menger (cssGraphics)`) as `{ name, params }` pairs — quick starting points
for `params`. `Cube tiles` and `Ink cells` exercise `argmax` and the ink
contour mode; `Sierpinski pyramid` and `Menger (cssGraphics)` exercise the
volumetric branch, voice layers, and carve together, on the `pyramid` and
`cube` stages respectively; `Breathing gyroid` exercises the SDF family,
`render: "xray"`, and volumetric animation together — see
[Animated volumetrics](#animated-volumetrics). These three volumetric
presets are also individually exported by object identity
(`GlyphSierpinskiPyramidPreset`, `GlyphBreathingGyroidPreset`,
`GlyphCssGraphicsMengerPreset`, alongside `GlyphCubeTilesPreset`), for a
consumer that needs to key off a specific preset rather than its display
name.

### Program builder and program-as-data

Two more ways to author a field, beyond the flat `field1..9`/`layer1..9`
params:

- **`buildGlyphFieldProgram({ domain, layers: [{ voices: [...] }] })`**
  (`@glyphcss/effects`) fills in every internal default from a pleasant
  `voices: [...]` shape — a nicer authoring surface for the same program the
  flat schema compiles down to, with no 9-voice cap.
- **Program-as-data**: pass a fully-formed program straight to a layer via
  the `program` option, instead of `params`:

  ```ts
  import { buildGlyphFieldProgram } from "@glyphcss/effects";

  const program = buildGlyphFieldProgram({
    domain: "3d",
    layers: [{
      voices: [{ field: "menger", wave: "step", freq: 3, iter: 3 }],
      combine: "min",
    }],
  });

  scene.addEffectLayer({
    effect: GlyphEffects.fieldSynth,
    program, // an unbounded field, not limited to 9 voices
    params: { space: "object", render: "carve", glyphs: " .:-=+*#%@" }, // still governs space/render/march/output
  });
  ```

  When `program` is set, field-synth ignores its own per-voice/per-layer
  params entirely — `params` still controls `space`/`render`/`marchSteps`/
  output mapping (ramp, color, `lit`, and so on). `validateGlyphFieldProgram`
  (also exported) shape-checks an arbitrary value before you hand it to
  `evaluateFieldProgram` yourself, if you're building the program by other
  means. `program` is **immutable after mount** — pass a different one and
  the layer's `setOptions` throws; remove and re-add the layer instead.
  React/Vue and `` all accept a `program` prop/property,
  applied only at creation. It's an API-first feature: `program` is not
  URL-persistable, and the [`/synth`](/synth) page and the static exporter
  (below) don't support it.

  `program` has a colour-stack sibling, **`colorProgram`**: field-synth's
  independent colour voice stack (its own `min`/`max`/etc.-combined layers of
  voices, evaluated separately from geometry) accepts its own program-as-data
  payload the same way — opaque, forwarded unchanged, validated once at mount
  via `validateGlyphFieldProgram`, and immutable after mount. Pass it
  alongside `program` (not instead of it) when both geometry and colour are
  program-authored; mirrored the same way across React/Vue/``.

### `GlyphRamps`

Named character ramps (dark → dense) for `glyphs`-style params:

```ts
import { GlyphRamps } from "@glyphcss/effects";

scene.addEffectLayer({
  effect: GlyphEffects.fieldSynth,
  params: { glyphs: GlyphRamps.Blocks },
});
```

| Name | Ramp |
|---|---|
| `Fade` | `" .:-=+*#%@"` |
| `Blocks` | `" ░▒▓█"` |
| `Shades` | `" .·:;+=xX#"` |
| `Dots` | `" .·•●"` |
| `Binary` | `" 01"` |
| `ASCII` | `" .,:;i1tfLCG08@"` |
| `Hatch` | `" .-+=#"` |
| `Stars` | `" .+*✦★"` |
| `Digital` | `" .:i\|1oX#"` |

### Font-calibrated ramps

`GlyphRamps` are authored guesses, eyeballed against one font. Change family
or weight and the gradient bands unevenly. `calibrateGlyphRamp` measures
instead: it paints each candidate glyph on a canvas in the given font, sorts
by real ink coverage, collapses visually identical steps, and returns a ramp
that is perceptually linear for **that font**.

```ts
import { calibrateGlyphRamp } from "@glyphcss/effects";

const { ramp } = calibrateGlyphRamp({
  font: { family: "ui-monospace, Menlo, monospace", size: 32 },
  steps: 12,
});
// ramp is a plain string, e.g. " .'`,:;+=xXo#8@" (exact glyphs depend on
// the resolved font) — drop it in anywhere a ramp is used today.
```

`measureGlyphInkCoverage(glyph, { font })` is the underlying per-glyph
measurement, exported separately.

Both are **browser-only** — the measurement needs a Canvas 2D context. Off the
DOM (SSR, a worker, a test), pass `canvasFactory` to supply any
Canvas-2D-compatible surface, such as
[`@napi-rs/canvas`](https://github.com/Brooooooklyn/canvas)'s `createCanvas`.

The result is a **plain string**, the same shape as `GlyphRamps` — data, not a
live measurement. It slots in anywhere a ramp does, including static compile:

```ts
import { compileScene, WIREFRAME_PALETTES } from "glyphcss";
import { calibrateGlyphRamp } from "@glyphcss/effects";

const { ramp } = calibrateGlyphRamp({ font: { family: "Menlo, monospace" }, steps: 12 });
WIREFRAME_PALETTES.myCalibrated = { ...WIREFRAME_PALETTES.default!, solid: ramp.split("") };

compileScene({ polygons, mode: "solid", glyphPalette: "myCalibrated" });
```

The gallery's Rendering dock exposes this as the **Calibrated** glyph palette
option — pick it to switch the live scene to a ramp measured in the gallery's
own font.

### Design it live

The [`/synth`](/synth) page is a dual-sidebar modular synth built on
`fieldSynth`: voice cards — with live previews that stay static until
hovered — grouped into collapsible per-layer groups (blend mode, mix,
threshold, invert) in the voice sidebar, plus Stage / Mix / Output / Lighting
docks and a live preset gallery. A single **Mapping** dropdown is the whole
2D/3D control (it just sets `space`; there's no separate mode toggle), and
once a volumetric stage is active an optional camera auto-orbit is available.
The full patch persists to the URL, so a design can be shared or pasted
straight into `params`; a two-tier repair gate means an invalid or
older-schema URL still hydrates to a working page instead of throwing — known
bad combinations get their offending keys reset to defaults, and anything the
repair table doesn't recognize resets the whole effect patch rather than
leaving the page blank.

## Use Anime.js directly

The layer handle deliberately exposes a stable flat parameter object. Anime.js
can target it without a GlyphCSS adapter:

```ts
import { animate } from "animejs";

animate(layer.params, {
  time: 60,
  duration: 60_000,
  loop: true,
  ease: "linear",
});
```

Anime owns time, easing, timelines, springs, and playback. GlyphCSS owns the
spatial program, retained frames, composition, and final write.

## Current boundary

The runtime accepts `baseColor`, `baseShade`, `depth`, `normal`,
`objectPosition`, `worldPosition`, and `uv0` program inputs, plus the base
glyph and coverage buffers. Hard surface requirements need `solid` mode;
`optionalRequirements` let a program request retained fields while still
providing a wireframe/voxel fallback. Layers compose in order with
`over`/`replace` blends and `surfaces`/`viewport`/mesh-handle targets (see
[Targeting specific meshes](#targeting-specific-meshes)).

Not implemented yet — these reject explicitly: scene-image sampling/
displacement, shader-like scratch graphs, and surface-key / UV-footprint
fields.

Effects can cross into exports in two cases: the
[interactive export](/guides/compile#interactive-export) mounts a stock effect
by id in its CDN snippet, and
[`buildGlyphFieldSynthStaticExport`](/guides/compile#effect-only-static-export-field-synth)
bakes an effect-only, static-camera field-synth scene with zero runtime.
Other static compile paths do not evaluate effects.

`buildGlyphFieldSynthStaticExport` supports layers, `duty`, `phase`,
per-voice `angleN`/`originUN`/`originVN`, `subcellRes: "2x4"`/`"ink"`, and —
since slice 2 — the SDF fields (`gyroid`/`menger`/`sierpinski`, with `iterN`)
and the `step` waveform, all at real-renderer exact parity, all 2D. It
rejects, with a specific error naming the reason: `space: "object"` (the
volumetric branch), `render: "carve"`/`"xray"`, a `program` option
([program-as-data](#program-builder-and-program-as-data) — an unbounded
authoring surface with no schema this baked coordinate-table/affine-fit
exporter can serialize), and an active voice (`ampN > 0`) using `linearZ` or
a nonzero `originWN` — all semantics a per-cell coordinate bake can't fake,
since carve/xray in particular need a march per cell per frame and a program
has no flat-param shape to merge and bake. The `originWN` reject is waived
for an active SDF
voice: `gyroid`/`menger`/`sierpinski` genuinely read `originW` even in the 2D
branch (it's part of their translation contract), so that combination exports
normally. Call `isGlyphFieldSynthStaticExportSupported(params)` to check
before exporting rather than duplicating this list; the `/synth` page's
CodePen button uses it to decide which export to offer. A volumetric or
carve/xray patch still exports through the
[interactive export](/guides/compile#interactive-export) — it ships and
evaluates the live effect runtime instead of a baked approximation.


---

# Hit Layer Interactivity

The glyphcss hit layer is **sparse** — you opt-in to interactivity by registering
hotspots at specific 3D anchors. No polygon-per-DOM-node overhead.

## Why sparse

A polycss-style "one DOM node per polygon" model gives you per-polygon events but
balloons the DOM for large meshes (a 10k-triangle GLB → 10k `
`s). glyphcss takes the opposite trade: a single `
` for visuals, and only the hotspots you
explicitly register become DOM nodes.

For most real apps this is what you want. You don't need click handlers on every
roof shingle; you need them on the front door.

## Authoring hotspots

```tsx
import { GlyphCamera, GlyphScene, GlyphMesh, GlyphHotspot } from "@glyphcss/react";
import { octahedronPolygons } from "@glyphcss/core";

const shape = octahedronPolygons({ center: [0, 0, 0], size: 1, color: "#ffcc44" });


  
    
       alert("top vertex")}>
        Top
      
       alert("bottom vertex")}>
        Bottom
      
    
  

```

Each `` becomes:

```html
Top
``` The height is `size[1] × cellAspect` ch — 2ch at the default aspect — so the box covers a whole character cell. `left`/`top` are reassigned inline each render from the same camera projection that produced the character grid, anchored at the **cell centre** — the `.glyph-hotspot` rule applies `transform: translate(-50%, -50%)`. There is no `role`; add your own semantics if the hotspot is interactive. ## Anchors `at` is a world-space `Vec3`. String anchors like `"vertex:42"` are not implemented — compute the coordinate from your polygon data and pass it. ## Visibility A hotspot whose projected cell is not visible is hidden with `display: "none"`, staged and applied in the same single write as the rest of the frame — not faded via opacity. Animate the element yourself if you want a transition. --- # Loading Meshes glyphcss ships parsers for four mesh formats. All return a `Polygon[]` that the rasterizer consumes: | Format | Parser | Notes | |---|---|---| | Wavefront OBJ | `parseObj(source: string)` | + optional MTL for vertex colors | | glTF / GLB | `parseGltf(buffer: ArrayBuffer)` | embedded textures supported | | MagicaVoxel `.vox` | `parseVox(buffer: ArrayBuffer)` | natural fit for `voxel` render mode | | STL | `parseStl(buffer: ArrayBuffer)` | binary + ASCII; no color data in the format | Or use the format-detecting wrapper: ```ts import { loadMesh } from "glyphcss"; // Replace "/cottage.glb" with the path to your own file. const { polygons, dispose } = await loadMesh("/cottage.glb"); ``` > The URL `/cottage.glb` is a placeholder — substitute the path to your own asset. > For runnable examples that don't require a file, see [Creating Shapes](/guides/creating-shapes). ## Choosing a render mode | Mode | When | |---|---| | `wireframe` | Geometric meshes, lattices, anything that should look like line art | | `solid` | Smooth-shaded surfaces — Lambert shading mapped to a glyph ramp (` .:-=+*#%@`) | | `voxel` | VOX models — one glyph per voxel face, depth-sorted | | `ink` | Illustration-style outlines — silhouette + crease edges only, oriented glyph per contour direction | `mode` is a **scene** option, not a mesh prop — every mesh in a scene renders through the same mode: ```tsx {/* Replace placeholder paths with paths to your own assets. */} ``` ## Built-in geometries For the common case of "I just want a rotating shape", glyphcss ships generators: ```tsx ``` These render through the scene's `mode` like any other mesh — `solid` by default. ## Procedural meshes For shapes that don't come from a file, build `Polygon[]` arrays directly in JavaScript. The `@glyphcss/core` package ships generators for all the Platonic solids plus utility shapes (rings, planes, axes gizmos). See the full walkthrough — with copy-paste examples for every helper and a parametric wavy-plane example — in [Creating Shapes](/guides/creating-shapes). --- # Performance ## Cost dominator: grid cells, not polygons The glyphcss renderer scales with **grid cells**, not polygon count. A 10k-triangle GLB and a unit cube cost the same per frame, because both rasterize into the same `cols × rows` `Uint8Array` stamp. Frame size: - 80×24 grid (~tiny): ~2,000 cells, < 1ms per frame - 160×48 grid (typical): ~7,500 cells, ~3ms per frame - 240×72 grid (large): ~17,000 cells, ~6ms per frame That cost is paid per render pass — one projection over all polygons plus one `textContent` assignment — and a pass runs only when the camera or scene actually changes. Between changes there is no work at all: the `
` just sits there.

Cost scales roughly with cell count, so it is **quadratic in density**: halving
the cell size quadruples the cells. That is what `interactiveDownscale` exists
for — render at `1/n` resolution while a control is dragging and restore full
detail on release.

## When to drop hotspot count

The hit layer costs `O(hotspots)` per render pass — one projection and one
inline-style assignment each. That is cheap, but it is paid on every pass, so a
few hundred hotspots in one scene start to show.

For "highlight every vertex" UIs, consider:
- Group nearby vertices into one logical hotspot.
- Render hotspots only for the front-facing hemisphere (skip backface verts).
- Hide hotspots you do not need rather than mounting and re-projecting them.

## Spans, not just cells: `colorTolerance`

Colored output is emitted as `` runs, and span count — not cell count —
is what gates frame rate for a busy, continuously-animating scene: the
browser's own raster/paint/parse-HTML work over a large `
` costs far
more than the render pass that produces it. `colorTolerance` (default `0`,
off) merges adjacent cells into one run while their colors stay within a
redmean colour distance of each other, trading a little color fidelity for
far fewer spans.

The win depends entirely on scene content — it is a **1.2x–9.1x lever, not a
flat multiplier** (measured unquantized→best across `bench/color-tolerance.md`'s
six presets, excluding the already-flat Cube tiles case, which gains nothing
at 1.0x by design). Flat, hard-edged output (per-face color, shade ramps,
carved solids) wins enormously; smooth noisy fields win modestly because
their spans are dominated by genuine per-cell colour variation no merge
policy can invent coherence around; an already-flat scene gains nothing and,
just as importantly, does not regress. Raising tolerance lowers span count on
every real scene measured, but this is **observed behavior, not a guarantee**
— see `bench/color-tolerance.md` for the measured six-preset table, the live
FPS delta, and the (rare, small) counterexample where a larger tolerance can
land the next run's anchor at a slightly worse starting color.

Set it on `` / ``. Range is `0`–`765` (redmean, not `0`–`255` RGB) —
`765` merges essentially everything, `24`–`128` is the useful band for most
scenes.

## Avoid steady-state JS

If you find yourself reaching for `requestAnimationFrame` to update the scene
every frame, stop. The single-write render pass is designed to make that
unnecessary. Two
recipes that come up:

**"I want the scene to react to scroll position":** map scroll to `camera.rotY`
(**degrees** — glyphcss uses degrees everywhere, not radians) and call
`scene.rerender()`. Throttle to whatever frame rate the grid size affords; a
coarse grid can take every scroll event, a dense one wants a debounce.

**"I want a continuous color pulse on a hotspot":** put the pulse on the hotspot's
`
` directly. The hotspot div is real DOM — an inline `transform` pulse on it works the same as any other CSS animation. --- # Render Modes import { Tabs, TabItem } from '@astrojs/starlight/components'; glyphcss has four render modes. Each maps the same projected mesh to a different glyph layout. | Mode | Best for | Cost | |---|---|---| | `wireframe` | Geometric meshes, lattices, line art | Scales with edge count | | `solid` | Smooth-shaded surfaces from real mesh files | Scales with triangle count × grid cells | | `voxel` | MagicaVoxel `.vox` files, chunky low-poly | Scales with visible voxel faces | | `ink` | Illustration-style outlines (silhouette + crease), sphere/torus contours | Scales with triangle-edge count | ## `wireframe` Bresenham-rasterizes each edge into a `Uint8Array` stamp with three weight tiers, then maps the stamp to glyphs: | Weight | Glyph palette | Used for | |---|---|---| | 1 (thin) | `·⋅∙˙·⋅∙` | Spokes, inner lattice, decorative scaffolding | | 2 (normal) | `╋╬┼╳◆◇◊▲△▼▽◈⬡⬢∴∵⊥⊕⊗⊙⊚⊛` | Main cage edges | | 3 (core) | `✦✧✩◉⊙◎` | Focal accents, "sun" centre | ```tsx import { GlyphCamera, GlyphScene, GlyphMesh, GlyphOrbitControls } from "@glyphcss/react"; import { icosahedronPolygons } from "@glyphcss/core"; const icosa = icosahedronPolygons({ center: [0, 0, 0], size: 1, color: "#44ffcc" }); export function WireframeDemo() { return ( ); } ``` ```ts import { createGlyphCamera, createGlyphScene } from "glyphcss"; const camera = createGlyphCamera({ rotX: 25 }); const scene = createGlyphScene(document.querySelector("#scene")!, { camera, mode: "wireframe", cols: 80, rows: 24, }); // Custom wireframe edges with explicit weights: import { buildRasterizeContext, rasterize } from "glyphcss"; const grid = { cols: 80, rows: 24, cellAspect: 2.0 }; const ctx = buildRasterizeContext({ camera, grid, wireframe: [ { from: [1, 0, 0], to: [-1, 0, 0], weight: 2 }, // bold edge { from: [0, 1, 0], to: [0, -1, 0], weight: 1 }, // thin spoke ], mode: "wireframe", }); const text = rasterize(ctx); ``` Best for: geometric meshes, lattices, iconic visuals. ### Character encoding (`charMode`) `charMode` selects the character encoding used to draw `wireframe` output. `"ascii"` (default) is the ramp/rule-glyph encoding above. `"braille"` instead packs a 2×4 subcell dot grid into every output cell using Unicode Braille Patterns (`U+2800`..`U+28FF`), giving up to 8 independent "pixels" per glyph — visibly smoother diagonal and curved edges than a single ASCII rule glyph per cell. `charMode: "braille"` is a documented no-op in `solid`, `voxel`, and `ink` modes: braille dot coverage is binary, so it cannot carry a Lambert shade ramp, a voxel face-normal glyph, or ink's oriented direction glyph. Those modes always render ASCII regardless of this option. ```tsx ``` ```ts const scene = createGlyphScene(document.querySelector("#scene")!, { camera, mode: "wireframe", charMode: "braille", cols: 80, rows: 24, }); ``` Mirrored as `char-mode="braille"` on `` and `charMode` on `@glyphcss/react`/`@glyphcss/vue`'s ``. ### Box-drawing junctions (`wireframeJunctions`) By default, each wireframe edge picks its cell glyphs independently from the active palette (weight tier → random glyph), so two edges meeting in the same cell — a corner, a T-junction, a crossing — render whichever edge happened to rasterize last. On an axis-aligned mesh (a cube face-on, a voxel grid, an iso diagram) this visibly breaks corners and joints. `wireframeJunctions: true` (default `false`, `charMode: "ascii"` only) runs a second pass over the finished wireframe cells: every near-axis-aligned edge accumulates which of a cell's four sides (N/E/S/W) carry a line, and any cell with a non-zero side mask resolves to the matching glyph from the fixed `┌┐└┘├┤┬┴┼─│` box-drawing set instead of the random pick — so the joint reads as ONE glyph consistent with every edge touching it. An edge counts as near-axis-aligned when its two projected endpoints round to the same output row or column; a diagonal-dominant edge contributes nothing to the mask and keeps the default slope-glyph behavior. ```tsx ``` ```ts const scene = createGlyphScene(document.querySelector("#scene")!, { camera, mode: "wireframe", wireframeJunctions: true, cols: 80, rows: 24, }); ``` Mirrored as `wireframe-junctions` on `` and `wireframeJunctions` on `@glyphcss/react`/`@glyphcss/vue`'s ``. ### Hidden-line removal (`hiddenLines`) The wireframe path has no depth reference by default: edges draw in mesh order, and a contested cell resolves by edge weight, not by which edge is nearer the camera. A farther edge — another mesh's far side, an extruded side wall behind a front face — can paint over a nearer one. This shows up as cross-object bleed-through, braille strokes mixing with a farther stroke's dots, or a darker `sideColor` edge overwriting a brighter front-face color. `hiddenLines: "hide"` (default `"show"`) depth-tests every wireframe stroke against a solid surface prepass, with a slope-scaled bias so a smooth surface's own silhouette isn't eaten by the surface it outlines. It applies to both `charMode: "ascii"` and `"braille"`, and is a documented no-op in `solid` mode (already depth-buffered per cell). `ink` mode also wires `hiddenLines: "hide"`, fixing the same defect on extruded text and other closed meshes. Its test is identity-based: a stroke is never occluded by its own local surface neighborhood, only by genuinely different geometry. Silhouettes survive intact, while an extrusion's back and side walls stop painting over its own front face — and a wall only partly behind a cap hides just the covered portion. ```tsx ``` ```ts const scene = createGlyphScene(document.querySelector("#scene")!, { camera, mode: "wireframe", hiddenLines: "hide", cols: 80, rows: 24, }); ``` Mirrored as `hidden-lines` on `` and `hiddenLines` on `@glyphcss/react`/`@glyphcss/vue`'s ``. Also accepted by `compileScene`/`GlyphSceneStatic` (unlike `wireframeJunctions`, which is runtime-only) — it's a pure function of geometry and camera, exactly like `charMode`, and fixes a genuine occlusion defect a static bake shouldn't reproduce. ## `solid` Triangle scan-fill, with Lambert shading per cell mapped to a ramp: ``` " .:-=+*#%@" ``` Darker glyphs for cells facing away from the directional light, brighter glyphs for cells facing it. Shading is **flat** by default — one normal per polygon, so facets stay visible. Set `smoothShading` (with `creaseAngle`, default 60°) to interpolate per-cell normals from averaged vertex normals instead. ```tsx import { GlyphCamera, GlyphScene, GlyphMesh, GlyphOrbitControls } from "@glyphcss/react"; import { dodecahedronPolygons } from "@glyphcss/core"; const dodeca = dodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#cc44ff" }); export function SolidDemo() { return ( ); } ``` ```ts import { createGlyphCamera, createGlyphScene } from "glyphcss"; import { dodecahedronPolygons } from "@glyphcss/core"; const camera = createGlyphCamera({ rotX: 25 }); const scene = createGlyphScene(document.querySelector("#scene")!, { camera, mode: "solid", cols: 100, rows: 30, directionalLight: { direction: [0.5, 0.7, 0.5], intensity: 1 }, ambientLight: { intensity: 0.4 }, }); scene.add(dodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#cc44ff" })); ``` Best for: smooth-shaded surfaces from real mesh files. ### Two-color cells (`charMode: "halfblock"`) `charMode: "halfblock"` is the solid-mode mirror of wireframe's `"braille"`: braille buys SHAPE resolution at one color per cell, halfblock buys COLOR resolution at coarser (block) shape. Instead of picking one glyph from the shade ramp per cell, it packs two independently colored subcells — top and bottom — into a single cell using `▀`/`▄`/`█`, for 2× vertical color resolution. Because a monospace cell is already roughly twice as tall as it is wide, each half lands close to square — a better pixel grid than the ramp glyph it replaces. An empty cell never paints a background: `█` is used only when a cell's top and bottom both resolve to the same color (a common case on flat-shaded surfaces, cheaper markup than two colors), `▀`/`▄` when only one half is covered, and `▀` with `background-color` set to the bottom color only when both halves are covered with genuinely different colors. ```tsx ``` ```ts const scene = createGlyphScene(document.querySelector("#scene")!, { camera, mode: "solid", charMode: "halfblock", cols: 80, rows: 24, }); ``` `charMode: "halfblock"` is a documented no-op outside `solid` mode, and also a no-op when combined with a `transformCells` hook or active `temporalBlend` reprojection — both already expect the single-color-per-cell output. Mirrored as `char-mode="halfblock"` on `` and `charMode` on `@glyphcss/react`/`@glyphcss/vue`'s ``. ### Four-quadrant cells (`charMode: "quadrant"`) `charMode: "quadrant"` generalizes `"halfblock"` from a 1×2 (top/bottom) subcell split to a full 2×2 split — TL/TR/BL/BR — buying BOTH shape and color resolution at the same two-colors-per-cell markup cost. It picks from all 16 Unicode quadrant/half/full-block glyphs (space, `▘▝▖▗▀▄▌▐▚▞█`, and the three-quadrant glyphs `▛▜▙▟`), which cover every possible binary 2×2 coverage pattern exactly — halfblock's `▀`/`▄` are just two of these 16 masks. A partially-covered cell (1-3 of the 4 regions) renders the exact coverage mask — 14 distinct partial silhouettes, instead of halfblock's fixed top/bottom rounding — with one averaged color and no background (a background would have to paint an uncovered region, which never happens). A fully-covered cell either collapses to a single-color `█` (same color on all four regions) or splits into a genuine two-tone glyph: the 4 regions partition into a "high"/"low" luminance group against their mean, and the matching mask renders with `color` = the high group and `background-color` = the low group. ```tsx ``` ```ts const scene = createGlyphScene(document.querySelector("#scene")!, { camera, mode: "solid", charMode: "quadrant", cols: 80, rows: 24, }); ``` `charMode: "quadrant"` shares every eligibility rule `"halfblock"` has: a documented no-op outside `solid` mode, and no-op when combined with a `transformCells` hook or active `temporalBlend` reprojection. Mirrored as `char-mode="quadrant"` on `` and `charMode` on `@glyphcss/react`/`@glyphcss/vue`'s ``. ### Font-weight density ramp (`solidWeightRamp`) `solidWeightRamp` adds a second density axis to `solid` shading: CSS `font-weight`, alongside the usual glyph-shape ramp. Bold does not change monospace advance width (verified across common stacks, including browser-synthesized bold), so a weight-bearing span can never desync the character grid. The ramp is measurement data, not a plain string — a list of `(glyph, weight)` steps ordered darkest → densest by real ink coverage, produced by `@glyphcss/effects`'s `calibrateWeightedGlyphRamp`: ```ts import { calibrateWeightedGlyphRamp } from "@glyphcss/effects"; const { steps } = calibrateWeightedGlyphRamp({ font: { family: "ui-monospace, monospace", size: 32 }, steps: 24, weights: [400, 700], }); const solidWeightRamp = steps.map(({ glyph, weight }) => ({ glyph, weight: Number(weight) })); ``` When set, `solidWeightRamp` **replaces** `glyphPalette`'s solid ramp — shading picks both a glyph and a weight from the calibrated steps, buying more distinguishable shading buckets and a darker dark end than glyph shape alone provides. ```tsx ``` ```ts const scene = createGlyphScene(document.querySelector("#scene")!, { camera, mode: "solid", solidWeightRamp, cols: 80, rows: 24, }); ``` `undefined` (the default) leaves the render byte-identical. Documented no-op outside `solid` mode, under `charMode: "halfblock"` or `"quadrant"` (their two-color encodings have no font-weight span), and during active `temporalBlend` reprojection. Mirrored as a `solidWeightRamp` JS property on `` (the ramp is data, not an attribute) and `solidWeightRamp` on `@glyphcss/react`/`@glyphcss/vue`'s ``. Also accepted by `compileScene` — the ramp is plain step data, so a weighted-ramp render bakes at build time too. ## `voxel` One glyph per voxel face, depth-sorted. Natural fit for MagicaVoxel `.vox` files where the source data is already cell-aligned. ```tsx import { GlyphCamera, GlyphScene, GlyphMesh, GlyphOrbitControls } from "@glyphcss/react"; import type { Polygon } from "@glyphcss/core"; // `polygons` comes from parseVox() — load the file outside the component. // Replace "/tree.vox" with your own MagicaVoxel file path. export function VoxelDemo({ polygons }: { polygons: Polygon[] }) { return ( ); } ``` ```ts import { createGlyphCamera, createGlyphScene } from "glyphcss"; import { parseVox } from "@glyphcss/core"; // Replace "/tree.vox" with the path to your own MagicaVoxel file. const voxBuffer = await fetch("/tree.vox").then((r) => r.arrayBuffer()); const { polygons } = parseVox(voxBuffer); const camera = createGlyphCamera({ rotX: 25 }); const scene = createGlyphScene(document.querySelector("#scene")!, { camera, mode: "voxel", cols: 80, rows: 24, }); scene.add(polygons); ``` Today `voxel` routes through the wireframe rasterizer rather than owning a separate path, so it renders cell-aligned geometry as edges. A dedicated voxel color → glyph-density mapping does not exist yet. Best for: voxel art, chunky low-poly aesthetics. ## `ink` Oriented silhouette + crease outline mode: detects view-dependent silhouette edges (front-facing/back-facing sign flip across a shared triangle edge) and fixed dihedral-angle crease edges, chains them into contours, smooths the screen-space tangent along each chain, and picks a glyph — `_ / | \ - ‾ ▏ ▕` — that traces the local contour direction. Interior cells stay empty; hatching and texture fills are a future effect-layer concern, not baked into this mode. ```tsx import { GlyphCamera, GlyphScene, GlyphMesh, GlyphOrbitControls } from "@glyphcss/react"; import { spherePolygons } from "@glyphcss/core"; const sphere = spherePolygons({ center: [0, 0, 0], size: 1, color: "#44ffcc" }); export function InkDemo() { return ( ); } ``` ```ts import { createGlyphCamera, createGlyphScene } from "glyphcss"; import { spherePolygons } from "@glyphcss/core"; const camera = createGlyphCamera({ rotX: 25 }); const scene = createGlyphScene(document.querySelector("#scene")!, { camera, mode: "ink", cols: 80, rows: 24, }); scene.add(spherePolygons({ center: [0, 0, 0], size: 1, color: "#44ffcc" })); ``` `charMode`, `wireframeJunctions`, and `solidWeightRamp` are documented no-ops in `ink` mode — it always picks its own fixed oriented-glyph set as plain ASCII (and has no shade ramp for `solidWeightRamp` to extend). `hiddenLines` IS wired in `ink` — see [Hidden-line removal](#hidden-line-removal-hiddenlines) above. Best for: illustration-style previews, product-shot outlines, sphere/torus/organic silhouettes where a filled ramp or a dense wireframe cage reads as noise. ## Shadows Shadows use a shadow-map technique: the rasterizer renders a depth pass from the light's perspective, then compares each cell's depth against it. Set `shadow` on `` to enable, then opt individual meshes in with `castShadow` and `receiveShadow`. A mesh with both flags self-shadows. `` defaults to `receiveShadow=true`. ```tsx import { GlyphPerspectiveCamera, GlyphScene, GlyphMesh, GlyphGround, } from "@glyphcss/react"; const shadow = { color: "#000000", opacity: 0.25, lift: 0.05 }; const directionalLight = { direction: [0.5, 0.7, 0.5], intensity: 1 }; const ambientLight = { intensity: 0.35 }; export function ShadowDemo() { return ( ); } ``` ```ts import { createGlyphPerspectiveCamera, createGlyphScene } from "glyphcss"; import { dodecahedronPolygons, planePolygons } from "@glyphcss/core"; const camera = createGlyphPerspectiveCamera({ rotX: 45, rotY: 30, zoom: 50, distance: 5 }); const scene = createGlyphScene(document.querySelector("#scene")!, { camera, mode: "solid", cols: 100, rows: 30, directionalLight: { direction: [0.5, 0.7, 0.5], intensity: 1 }, ambientLight: { intensity: 0.35 }, shadow: { color: "#000000", opacity: 0.25, lift: 0.05 }, }); const caster = scene.add( dodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#4488ff" }), { castShadow: true, receiveShadow: true }, ); const ground = scene.add( planePolygons({ axis: 1, size: 5, offset: 0, color: "#444444" }), { position: [0, -0.5, 0], receiveShadow: true }, ); ``` Shadow fields (`GlyphShadowOptions`): | Field | Default | Description | |---|---|---| | `color` | `"#000000"` | Shadow tint color | | `opacity` | `0.25` | Darkness 0..1 | | `lift` | `0.05` | Depth bias — prevents self-shadow acne on flat lit surfaces. A WORLD-UNIT length, so the default assumes a room-scale scene; a scene whose unit is (say) an Earth radius must set its own, or every shadow is biased away | | `maxExtend` | `2000` | **Accepted but not read.** The light-space volume is fitted to the casters' own bounds; this value has no effect on any render | ## Built-in `geometry` attribute The `` custom element and the `GlyphMesh` component accept a `geometry` string shortcut for any name in `GlyphGeometryName` — a public registry of ~44 shapes in `@glyphcss/core` (every Platonic, Archimedean and Catalan solid, plus `sphere`, `cylinder`, `cone`, `torus`, `pyramid`, `prism` and more), resolved by the exported `resolveGeometry`. An unknown name throws `Unknown geometry`. A few of the most-used values: | Value | Shape | Notes | |---|---|---| | `"cuboctahedron"` | Cuboctahedron | Default demo shape | | `"icosahedron"` | Icosahedron, edge-only wireframe | 20 faces | | `"cube"` | Plain cube wireframe | 6 faces | ```tsx // Built-in preset (no import needed): // Equivalent procedural form (more control): import { icosahedronPolygons } from "@glyphcss/core"; ``` --- # Introduction import GlyphDemo from "../../components/GlyphDemo.astro"; **glyphcss** turns **3D models into ASCII art**. It rasterizes meshes (OBJ, glTF, GLB, STL, MagicaVoxel `.vox`) into a character grid rendered as plain text in a single `
` — no WebGL, no canvas, no per-polygon DOM nodes. Use it in the browser
(vanilla, React, or Vue), or render straight to your terminal with the CLI.
JavaScript runs a single render pass whenever the camera or scene changes — and
nothing at all in between.

It's the successor to **[polycss](https://github.com/apresmoi/polycss)**, the
CSS-transform polygon paint engine — same scene/camera API, but the paint backend
emits ASCII text instead of a DOM node per polygon.



## The mental model

A glyphcss scene is **one `
` per scene plus a sparse `
` hit layer**. The `
` is rewritten in a single `textContent` write per render pass — no
pre-baked frames, no per-polygon DOM nodes, no CSS transforms. Hotspots (one per registered 3D anchor) are projected
through the same camera and repositioned with one inline-style assignment each,
so they cannot drift out of step with the glyphs.

## Tradeoffs

| | glyphcss | CSS polygon renderers |
|---|---|---|
| Cost dominator | grid cell count | polygon count |
| Per-polygon DOM events | opt-in via `` | ✓ (one node per polygon) |
| Render styles | wireframe, Lambert-shaded solid, voxel, ink contour | smooth-shaded polygons |
| Per-frame JS work | none while idle; one pass per camera/scene change | none (CSS-driven) |
| Visual style | ASCII / glyph art | smooth-shaded |

glyphcss is the right tool when you want **iconic, terminal-aesthetic 3D** with
**handpicked interactivity** — not when you need photorealistic texture mapping.

## What's next

- [Quickstart](/quickstart) — npm-installable demo in 30 seconds.
- [Core Concepts](/core-concepts) — the render pass, the hit layer, the camera contract.
- [Gallery](/gallery) — tune live, see the renderer at work.
- [Maps](/maps/overview) — render real geographic data: terrain, borders, contours, OpenStreetMap buildings.


---

# Camera & Navigation

The widget owns one `requestAnimationFrame` motion loop. Input handlers update
state synchronously and only the *render* is deferred, so a drag, an inertial
glide, a `flyTo` and a projection transition all issue **at most one render per
displayed frame**.

## Gestures

| Gesture | Sheet projection | Orbit projection (globe) |
|---|---|---|
| Drag | Pan, clamped in world space | Orbit the camera |
| Wheel | Zoom | Zoom |
| **Ctrl+drag** or **right-drag** | Orient: vertical pitches, horizontal turns | Same |
| Click | `click` event with `lngLat` | Same, `null` past the limb |

### Touch

Google Maps' vocabulary, on MapLibre's thresholds.

| Gesture | Does |
|---|---|
| One finger, drag | Pan / orbit |
| Two fingers, **pinch** | Zoom, anchored on the midpoint |
| Two fingers, **twist** | Bearing — the picture turns with the fingers |
| Two fingers, **drag together vertically** | Tilt; up raises the pitch |
| **Double-tap** | Zoom in one level, about the tapped point |
| **Two-finger tap** | Zoom out one level |
| **Double-tap, hold, drag** | One-handed zoom; down zooms in |

Pan, pinch and twist compose, so one movement can zoom into a corner while
straightening the map. The tilt is **exclusive**: once a two-finger drag is
recognised as a pitch it stays one for the rest of the stroke, and one that
was not can never become one — without that lock, no hand is steady enough to
tilt without also zooming and turning. Lifting one finger hands the stroke
back to the other as an ordinary pan — including when a third finger joined
and left in between.

The widget sets `touch-action: none` on the host (and restores your own inline
value on `destroy()`), because otherwise the browser claims a pan or a pinch
for the page before script sees it. It sets it only when it has a gesture to
claim: with `drag`, `wheel` and `tilt` all off, the host is left alone so a map
embedded in a scrolling article does not swallow the swipe that scrolls it.

An interrupted gesture (`pointercancel` — a system gesture, a palm rejection)
cleans up and never navigates: it completes no tap, arms no double-tap, and
leaves nothing behind for the next press to finish.

Each has an opt-out, and each defaults to `true`:

```ts
createGlyphMap(host, {
  view, projection,
  controls: { drag: true, wheel: true, tilt: false },
});
```

`controls.tilt` is its own surface, independent of `drag` — a map that pins its
centre may still want the reader to look across it. It covers **both axes** of
the orient stroke, because it is one press and one stroke; a diagonal drag under
two separate opt-outs would do half of what the hand asked. While it is enabled
the widget suppresses the host's context menu, so the right-button half is
usable.

There is no separate touch flag. These three are **capabilities**, not
devices: `wheel` is zoom (notches, the pinch, both tap zooms, the one-handed
drag-zoom), `tilt` is orient (the mouse stroke's two axes, the two-finger
pitch, the twist), and `drag` is pan. With `drag: false` a pinch still zooms —
about the centre, since the centre is pinned — and so does the one-handed
double-tap-drag, which is a zoom and not a pan.

## Span, and the cover rule

```ts
map.setView({ center: [8.54, 47.37], span: 0.06 });
map.getView();
map.getMaxSpan();
map.fitBounds({ west: 5.9, east: 10.5, south: 45.8, north: 47.8 });
```

`minSpan` (default `0.001`) is the floor. The **ceiling** is `getMaxSpan()`, and
it is live — read it, do not cache it:

- On a **sheet** projection it is the **cover** limit: the widest view that still
  fills the viewport on both axes, so no page background is ever visible around
  the map's edges. It moves with the host's shape, the camera `tilt`, the
  projection, and (where a projection's scale varies across its domain) the view
  centre. Panning is clamped in world space so the visible *window* stays inside
  the projection's extent, not just the centre.
- On an **orbit** projection there is no cover limit — a globe legitimately
  floats in space — and it is the projection's domain width.

An explicit `maxSpan` **opts out of the cover rule entirely**, for both the span
and the pan clamp. "Overview margin around the whole projection" is exactly what
cover exists to remove, so the two cannot both hold and the explicit request
wins.

Where a map genuinely cannot fill an axis, that axis is **centred** rather than
pinned to an edge — one fixed point, so a drag against it settles instead of
oscillating.

## Tilt

`tilt` is a pitch **about the surface point under the view centre**, with the
pivot distance equal to the camera's altitude — the Google Earth / Cesium model.

```ts
map.setTilt(55);
map.getTilt();      // the APPLIED pitch
map.getMaxTilt();   // the live ceiling
```

`map.project(map.getView().center)` therefore lands at the centre of the grid at
every pitch and every span, and two very different feels fall out of one rule
with no threshold: zoomed out the pivot is far below the camera relative to the
view, so pitching swings the globe and the limb enters tangentially; zoomed in
the pivot is directly beneath, so pitching reads as raising your head off the
ground — which is what makes 3D buildings legible.

The default differs by projection kind, and so does what the number means:

- A **sheet** has no view-driven base orientation, so `tilt` *is* the total
  `camera.rotX`. Default `40`.
- An **orbit** projection has one — `cameraForCenter(lon, lat)` — that `tilt`
  *adds* to. Default `0`, head-on.

`getMaxTilt()` is the **horizon angle at the view's own scale**,
`asin(R / (R + h))` for the frame's world half-height `h`, capped at
`GLYPH_MAP_MAX_TILT` (85). That gives roughly 21° at a 360° span — where 80°
would aim past the limb at empty space — about 50° at 40°, and the cap by city
scale. A sheet has no limb, so its ceiling is the cap at every span.

The clamp is **non-destructive**: the request is remembered unclamped, so zooming
back in restores the full pitch, while `getTilt()` reports what the camera
actually has.

## Bearing

`bearing` is the compass heading, in degrees, that points **up** on screen. `0`
(the default) is north up; `90` puts east up — MapLibre's convention, so the
picture turns counter-clockwise as the number grows.

```ts
map.setBearing(137);
map.getBearing();   // normalized to [0, 360)
```

It is a rotation about the **surface normal at the pivot** — the same point
`tilt` pitches about — and **not** a roll about the view axis. The two are
identical at zero pitch and diverge exactly where this feature exists: a
view-axis roll *tips the horizon* on a pitched camera, and no map product does
that. Turning about the pivot's local up swings the camera around a cone at
constant pitch, so the horizon stays level and only the heading changes. In
glyphcss's own frame the composition reads `RotX(tilt) · RotZ(−bearing) ·
RotX(trueRotX) · RotZ(rotY)`: navigate, then turn, then pitch.

At bearing `0` **no camera matrix is installed at all** and the render is
bit-for-bit what it was before the feature existed — the same string, the same
`project()` cells, the same `getMaxSpan()`. At any other heading the widget
installs it through `GlyphCamera.mat`/`useMat`, glyphcss's public rotation
override, so `rotX`/`rotY` keep their meanings underneath.

Two consequences worth knowing: a drag still pans the way the picture *looks*
(the pixel delta is rotated back through the bearing before it becomes
navigation), and a sheet's cover ceiling **tightens**, because a turned viewport
is a rotated rectangle whose reach along each world axis is
`w|cos b| + h|sin b|` — worst at 45°.

### The orient gesture

One stroke, both axes, no axis lock:

| Axis | Rate | Constant |
|---|---|---|
| Vertical → pitch | 0.5°/px | `GLYPH_MAP_TILT_DRAG_DEG_PER_PX` |
| Horizontal → bearing | 0.8°/px | `GLYPH_MAP_BEARING_DRAG_DEG_PER_PX` |

Both are MapLibre's own rates. **Dragging right increases the bearing**, turning
the picture anti-clockwise, so the near ground — the lower half of a pitched
picture, the half the hand is on — follows the hand.

Neither angle carries inertia, and the pitch clamps live to `getMaxTilt()` as
the zoom changes it. The gesture accumulates from the **applied** pitch, so it
has no dead travel against a lowered ceiling, while the remembered request
survives a zoom out and back.

## Flights

```ts
await map.flyTo({ center: [8.54, 47.37], span: 0.06 }, { durationMs: 1200 });
await map.flyTo({ bounds: { west: 5.9, east: 10.5, south: 45.8, north: 47.8 } });
```

A flight target is a centre and/or a span, **or** a bounds box to frame (the same
framing `fitBounds` computes) — not both. `durationMs: 0` applies the target
instantly.

`bow` is how far the flight may zoom **out** at mid-arc, as a multiple of the
larger endpoint span; `1` flies a straight log-span interpolation with no bow.
The bow exists because a long ground move at a city span is a blur — pulling out
and back is how a reader keeps their bearings.

`setProjection` is the other flight; see
[Projections](/maps/projections#animated-transitions).

## Waiting for the settled frame

```ts
map.setView({ center: [8.54, 47.37], span: 0.02 });
await map.idle();
const settled = map.scene.output.textContent;
```

Everything the widget does is fire-and-forget. A view change *arms* a 180 ms
debounce; the sweep it then issues fetches three tiers of tiles in phases; each
landing tile re-plants whatever stands on it — extrusions, markers, a contour's
mosaic — which dispatches more work; a flight or a projection blend runs on the
motion loop. Nothing awaits any of it, so a caller that wants to **read** the
settled picture has nothing to wait on but a guess at a duration, and a guess is
wrong in both directions: it idles on a fast machine and reads a half-built frame
on a loaded one.

`map.idle()` is the widget's own account of being done: no armed sweep, no
dispatched or queued layer update, no pending re-plant, no owed motion frame, no
flight, no projection blend. Call it **after** the mutation that provokes the work
— a pending debounce counts as busy, so the sweep it will issue is included. On a
settled map it resolves on the next event-loop turn; on a destroyed map,
immediately. It does not time out: a widget that never goes quiet is a real hang,
and answering "settled" would be the same lie a fixed sleep tells.

The counterpart of MapLibre's `once("idle")` / `loaded()`.

## Street-level walk mode

`setWalk` drops the camera to eye height and hands it a perspective lens. It is
the one mode that is *not* a map view.

```ts
map.setWalk({});                    // enter with every default
map.setWalk({ far: 900, sky: false, collision: false });
map.getWalk();                      // GlyphMapWalkState, or null
map.setWalk(null);                  // leave
```

Walk mode is **not** a `createGlyphMap` option — it is only reachable through the
handle, after mount.

### It needs an orbit projection

```ts
map.setWalk({});
// RangeError: walk mode needs a projection navigated by orbiting the camera …
```

`setWalk` throws a `RangeError` when the projection declares no
`cameraForCenter`/`centerForCamera`, i.e. on any flat sheet — where a metre of
height and a metre of ground are different world units. Capability-gated, never
`projection.id`.

### It does not gate on zoom

`setWalk` pins `view.span` to the horizon (`2 * far` converted to degrees)
**regardless of the span you were at**, and it does not refuse a call made from
a whole-world view. `GLYPH_MAP_WALK_MAX_ENTRY_SPAN_DEG` (`0.05`, about 5.6 km)
is the span walk mode is *meant* to be entered from, and enforcing it is the
application's job:

```ts
import { GLYPH_MAP_WALK_MAX_ENTRY_SPAN_DEG } from "@glyphcss/maps";

if (map.getView().span <= GLYPH_MAP_WALK_MAX_ENTRY_SPAN_DEG) map.setWalk({});
else map.flyTo({ span: GLYPH_MAP_WALK_MAX_ENTRY_SPAN_DEG });
```

Enter from too far out and you land on terrain with no street data around you —
a blank walk, not an error.

### Options

| Option | Default | Constant |
|---|---|---|
| `eyeHeight` | `1.7` m | `GLYPH_MAP_WALK_EYE_HEIGHT_M` |
| `fov` | `56`° horizontal | `GLYPH_MAP_WALK_FOV_DEG` |
| `near` | `0.5` m | `GLYPH_MAP_WALK_NEAR_M` |
| `far` | `600` m | `GLYPH_MAP_WALK_FAR_M` |
| `speed` | `6` m/s | `GLYPH_MAP_WALK_SPEED_M_PER_S` |
| `maxPitch` | `84`° either side of horizontal | `GLYPH_MAP_WALK_MAX_PITCH_DEG` |
| `collision` | `true` | — |
| `sky` | `true` | — |

`far` is the local horizon: it bounds both the tile footprint and the picture,
and it is the sky dome's own radius. `speed` is deliberately not the ~1.4 m/s
anatomical pace — a real one is tedious on a 600 m horizon.

### Controls

| Input | Effect |
|---|---|
| `W`/`A`/`S`/`D`, arrow keys | Move — held keys accumulate into a normalized axis, so a diagonal is not faster |
| `Shift` (held) | Run, ×3 (`GLYPH_MAP_WALK_RUN_MULTIPLIER`) |
| `G` (held) | Ghost — pass through buildings for as long as it is down |
| Mouse move under pointer lock | Look |
| Drag | Look, where pointer lock is unavailable or refused (touch) |
| `Esc` | Release pointer lock |
| Wheel | Nothing — zoom is meaningless at eye height |

Pointer lock is requested on the first mouse `pointerdown`. Losing window focus
clears held keys, the run flag and the ghost flag, so a dropped `keyup` cannot
leave the walker sprinting forever.

Pitch is clamped to `90 ± maxPitch` — `getMaxTilt()` reports the neck ceiling
while walking, and `getWalk().pitch` reports pitch above the horizontal with `+`
looking up.

### Collision

The widget wires collision itself. You do not build an index: it maintains one
from the mounted **`fill-extrusion`** layers, invalidates it when their tile set
changes, and rebuilds it lazily. Only extrusion footprints are ever solid —
`fill` layers (landuse, water, parks) are always walkable.

A blocked step **slides** along the wall tangent rather than stopping dead, and a
walker already inside a footprint is never trapped. With nothing mounted the
resolver returns the requested destination verbatim.

The pieces are exported if you want the model outside the widget:

```ts
import {
  glyphMapWalkFootprints,
  createGlyphMapWalkCollisionIndex,
  glyphMapWalkResolveStep,
  GLYPH_MAP_WALK_BODY_RADIUS_M,        // 0.3 m
} from "@glyphcss/maps";

const index = createGlyphMapWalkCollisionIndex(glyphMapWalkFootprints(features));
const next = glyphMapWalkResolveStep({ index, from, to, radiusM: GLYPH_MAP_WALK_BODY_RADIUS_M });
```

### The sky

Walk mode mounts a **sky dome** — a hemisphere of geometry plus a glyphcss
appearance program that paints a horizon-to-zenith gradient, quantized to
`GLYPH_MAP_SKY_BANDS` (32) steps, with a sun disc where a light direction is
known.

It mounts only when three things hold: walking, `sky !== false`, and the scene is
in `solid` mode (the appearance program cannot run in the others, so it is not
mounted there at all rather than mounted inert). `sky: false` mounts **no dome
and no program** — not a hidden one, zero extra polygons.

The dome's light direction is the scene's real sun if one is set, otherwise the
scene's own `directionalLight.direction`. A **headlight** is deliberately
excluded: a headlight is a statement about the viewer, not about the world, and a
sky lit by one would put the sun wherever you happened to be looking.

The dome is re-centred on the walker as they move — rebuilt rather than
translated — and rebuilt when the ground elevation or the scene mode changes.

### Leaving

`setWalk(null)` restores `view`, `tilt`, `bearing` and the camera **verbatim** to
what was captured on entry, and the rendered text is byte-for-byte the pre-walk
render. The sky dome is disposed, not hidden: the polygon count returns to its
baseline.

Calling `setWalk` again while already walking **reconfigures in place** — no
restore, no re-capture. A `setProjection` while walking leaves walk mode instead
of blending through it.


---

# Layers

A map is an **ordered list of layers**. Declare them at construction, or mutate
the list afterwards:

```ts
const id = map.addLayer({ type: "background", color: "#05070c" });
map.addLayer({ type: "raster", source: reliefProvider }, id);  // insert before `id`
map.moveLayer(id);                                             // move to the end
map.removeLayer(id);
```

`addLayer` returns the layer's id — the one you passed as `layer.id`, or a
generated one. Every layer type takes an optional `id`.

## The vocabulary

The names and roughly the semantics are MapLibre's, so a reader who knows a
style spec knows this list. What differs is **how** a layer reaches the screen,
and that split matters more here than it does in a GPU renderer:

| Type | Reaches the screen as | Takes |
|---|---|---|
| `background` | The scene output's CSS background colour | `color`, `density` |
| `raster` | A real 3D relief mesh from elevation tiles | see [below](#raster) |
| `line` | A post-raster **stamp** into the cell grid | `source`, `sourceLayer`, `filter`, `color`, `padCells`, `density` |
| `contour` | A post-raster stamp, marched from an elevation field | see [below](#contour) |
| `fill` | A draped or flat polygon mesh | see [below](#fill) |
| `fill-extrusion` | An extruded polygon mesh with walls and a cap | see [below](#fill-extrusion) |
| `symbol` | A positioned DOM label | see [below](#symbol) |
| `circle` | A positioned DOM dot | `source`, `sourceLayer`, `filter`, `radius`, `radiusProperty`, `radiusScale`, `color`, `density` |
| `glyph` | A post-raster **stamp** of a point mark, and its label | see [below](#glyph) |
| `heatmap` | A density relief mesh | `source`, `sourceLayer`, `filter`, `radius`, `weightProperty`, `colors`, `bounds`, `height`, `threshold`, plus appearance |
| `model` | A mesh from polygons you supply | `polygons`, `attribution`, plus appearance |

**Mesh-backed** layers (`raster`, `fill`, `fill-extrusion`, `heatmap`, `model`)
are geometry: they project, depth-test, and can cast and receive shadows.
**Stamped** layers (`line`, `contour`, `glyph`) own no geometry — they are
written into an output grid after rasterization and depth-tested against
whatever surface already won each cell. **DOM** layers (`symbol`, `circle`)
mount positioned elements over the `
`.

That split explains most of the asymmetries below: only mesh-backed layers carry
`renderMode`/`glyphPalette`, and only mesh-backed layers can *receive* a shadow.

## Sources

Every layer except `background` and `model` takes a `source`.

- `raster` takes a `GlyphMapGeoTile` (mounted once) or a `GlyphMapProvider` — a
  tile pyramid the widget sweeps, caches, culls and debounces for you.
- `contour` takes a `GlyphMapField` (a fixed, already-sampled snapshot) or the
  same `GlyphMapProvider` type, re-derived into a field per visible tile.
- Every vector layer takes a `GlyphMapVectorSource`: a static
  `GlyphMapVectorFeatureCollection`, or a `GlyphMapVectorProvider`.

A **static collection** is the right shape for a dataset small enough to fetch
whole — a few thousand features rather than a pyramid — and it carries its own
provenance, so the credit follows the layer:

```ts
const cables: GlyphMapVectorFeatureCollection = {
  features,  // { properties, geometryType, rings }
  attribution: [{ name: "TeleGeography", license: "CC BY-NC-SA 3.0", url: "https://www.submarinecablemap.com" }],
};

const id = map.addLayer({ type: "line", source: cables, color: "#e8a33d" });
map.getAttributions();   // → [{ name: "TeleGeography", … }]
map.removeLayer(id);
map.getAttributions();   // → []
```

`geometryType` decides how the rings are read (`"point"` — one coordinate per
ring; `"line"` — one open polyline per ring; `"polygon"` — one closed ring each,
or `polygons` for hole groups). The [maps workbench](/maps)'s **Datasets** card
mounts five such collections — submarine cables, data centres, dams and Natural
Earth's land and marine regions — as `line`, `circle`, `symbol` and `fill`
layers with no package-side machinery at all.

### Filtering features

Every vector-source layer takes `filter`, a plain predicate applied **after**
`sourceLayer` (and instead of it for a static collection, which has no
source-layer grouping):

```ts
map.addLayer({
  type: "line",
  source: osm,
  sourceLayer: "transportation",
  filter: (f) => f.properties?.class === "motorway",
});
```

A predicate rather than a match spec, because a provider-backed source's tiles
arrive *after* mount — a caller cannot pre-split features it has not received.
Omitting `filter` keeps every feature and is byte-identical.

## Appearance: `density`, `renderMode`, `glyphPalette`

Three options recur, and they mean the same thing everywhere.

### `density`

For a **mesh-backed** layer, `density` is glyphcss's per-mesh density: the layer
pops into its own silhouette-fitted `
` rendered at that multiple of the
scene's glyph resolution.

For a **stamped** layer it picks *which grid* the stroke lands in, and the
default is the interesting case:

- `undefined` or `1` — the layer has no resolution preference, so it stamps into
  **every** grid the scene produces and follows the annotated surface's own
  density. Sharpen a border where it crosses terrain by raising the *terrain*
  layer's density.
- any other value — the layer wants its own independent resolution. It gets a
  meshless full-viewport overlay grid at that density, with its own geometry
  depth pass, and stamps only there, so the same stroke never renders twice at
  two resolutions.

### `renderMode`

A map is not one picture in one mode: terrain reads as `solid` while an
administrative overlay reads as `ink`.

```ts
map.addLayer({ type: "raster", source: reliefProvider });               // the scene's mode
map.addLayer({ type: "fill", source: adminProvider, renderMode: "ink" });
```

Omitted — **or set to the mode the scene is already in** — the layer stays in the
shared base grid: one pass, byte-identical. A genuinely different mode is a full
extra rasterizer pass, so it is a per-*layer* choice. `wireframe` and `ink`
layers are additionally mounted `transparent`, because those modes paint edges
only and an opaque claim over the whole footprint would erase the terrain the
outline is drawn over.

### `glyphPalette`

The same argument one axis over: which **characters** carry the shade, never
which colours they are painted in. It composes with — never replaces — a
`raster` layer's `colors`, which is the elevation-band colour ramp.

```ts
map.addLayer({ type: "fill-extrusion", source: osm, glyphPalette: "blocks" });
```

Naming the ramp the scene is already on is free: the option is not forwarded at
all, so the layer stays in the base pass. That escape lives in this package
because two *equal* names always resolve to one ramp, while glyphcss cannot
compare in general.

`line`, `contour`, `glyph`, `symbol` and `circle` carry neither `renderMode` nor
`glyphPalette`, and never will — the first two are strokes stamped after
shading, the last two are DOM.

## `raster`

The terrain layer. Elevation tiles in, a shaded relief mesh out.

```ts
import { GlyphMapClassifiers } from "@glyphcss/maps";

map.addLayer({
  type: "raster",
  source: reliefProvider,
  classifier: GlyphMapClassifiers.etopo1V1,
  colors: ["#0b1d3a", "#14356b", "#2a55a8", "#3a6b30", "#5a7a30", "#8a7050", "#a89070", "#c0a080", "#e0c0a0", "#ffffff"],
  minElevation: 0,          // land only
  density: 2,
});
```

- `classifier` — how elevations become bands. See
  [Terrain](/maps/terrain#classifiers).
- `colors` — one colour per band.
- `minElevation` / `maxElevation` — an **elevation window** in metres. Omitted =
  unbounded and byte-identical. See
  [Terrain](/maps/terrain#the-terrain-elevation-window).
- `padCells` — screen-space padding, in output cells, a provider tile's bounds
  must be within to stay mounted. Ignored for a single-tile source.

## `contour`

Isolines marched from an elevation field and stamped into the grid.

```ts
map.addLayer({
  type: "contour",
  source: reliefProvider,
  levels: { interval: 500 },   // or 12, or [0, 500, 1000, 2000]
  minElevation: 0,
  color: "#94a3b8",
  labels: true,
  labelEvery: 5,
});
```

`levels` takes three shapes, and they behave differently on purpose:

- **a count** (`12`) — distributed across the elevation window intersected with
  the mosaic's own range. Without a window an ETOPO1 count spends most of its
  lines on the abyssal plains.
- **an explicit array** — used as given, clipped to the window, never
  renumbered.
- **`{ interval }`** — every absolute multiple of `interval`, clipped. Absolute,
  so the lines stay at fixed elevations instead of crawling on every pan.

`minElevation`/`maxElevation` are an elevation window, and two numbers rather
than a land/sea mode: `minElevation: 0` is land, `maxElevation: 0` is sea,
`0`–`2000` is the foothills, and no "land" ever has to be defined. An empty
window renders nothing and never throws.

`labels: true` prints elevations **on** the lines: index contours only (every
`labelEvery`-th line on the absolute ladder, default `5`, the USGS convention),
each number sitting in a gap in its own line, placed where the contour runs
near-horizontal and locally straight, and arbitrated by the same declutter pass
the `symbol` layer uses. Labels are off by default and allocate nothing when
off.

`map.getContourFieldRange(id)` reports the field's own data range, which is what
a UI needs to bound its floor/ceiling controls.

## `fill`

A polygon wash. The one option worth understanding is `drape`.

```ts
map.addLayer({ type: "fill", source: water,   color: "#1e6fd9" });                  // draped (default)
map.addLayer({ type: "fill", source: landuse, color: "#4a5a3a", drape: "flat" });   // datum overlay
```

- **`"surface"`** (the default) projects every cap vertex at the ground under its
  own lon/lat, so the wash lies on the relief. Sampled per *vertex*, not once per
  polygon — a park spanning a valley would otherwise float one end and bury the
  other.
- **`"flat"`** is a datum overlay: coplanar with every other flat layer, never
  wrapping a ridge, never eaten by the relief. Usually what an administrative or
  landcover tint you are *reading* wants.

`drape` also takes a **predicate**, `(feature) => "surface" | "flat"`, because
the ocean is the one water body a DEM cannot place: over the sea the terrain *is*
bathymetry, so draping an ocean polygon builds the sea surface on the sea floor.
The shipped OpenMapTiles water row carries one:

```ts
import { glyphMapOpenMapTilesWaterDrape } from "@glyphcss/maps";

map.addLayer({
  type: "fill",
  source: osm,
  sourceLayer: "water",
  colorProperty: "class",
  drape: glyphMapOpenMapTilesWaterDrape,   // ocean flat, lakes draped
});
```

Colour is either flat (`color`) or per-feature: `colorProperty` names a feature
property and `colors` maps its values to colours.

With no ground available at all, `"surface"` and `"flat"` render identically,
cell for cell.

## `fill-extrusion`

Buildings. Two rules carry most of the weight.

```ts
map.addLayer({
  type: "fill-extrusion",
  source: osm,
  sourceLayer: "building",
  heightProperty: "render_height",
  baseOffsetProperty: "render_min_height",
  height: 8,               // fallback when the feature has no height
  heightScale: 1,
  color: "#8a8f9c",
  colorVariation: 0.5,
  facade: true,
});
```

**Height is true metres, and is exempt from the terrain's `exaggeration`.** The
ground a structure stands on is wherever the exaggerated relief puts it, so only
what is measured *up* from that ground is exempt. Reverse that and every
building is buried; exempt neither and a 20 m building draws 480 m tall at
`exaggeration: 24`. `baseOffsetProperty` is a structure measurement in true
metres above the ground (OSM's `min_height`), not a terrain elevation.
`heightScale` multiplies the same metre count, for a stylised skyline.

**The ground is the terrain under the footprint**, probed once per polygon group
at its outer ring's mean lon/lat — a structure is rigid, so one ground per piece
keeps the cap planar and the walls planar quads. Extrusions on a static source
are re-planted when the mounted tile set changes, so a building mounted before
its terrain landed does not stay at the datum.

Two appearance options exist because an untextured flat-roofed box is one Lambert
value per face, so a block of them at eye height is two tones and a wedge:

- **`facade`** — window bays across and floor bands up each wall, derived from
  the wall's own metres. One small generated tile, tiled by UV wrap; the widget
  registers the texture itself and never fetches anything.
- **`colorVariation`** — a deterministic per-feature tone around `color`, seeded
  from the feature's own identity.

Both default off and are byte-identical there.

## `symbol`

Text labels, mounted as DOM over the grid.

```ts
map.addLayer({
  type: "symbol",
  source: osm,
  sourceLayer: "place",
  textProperty: "name",
  priorityProperty: "rank",
  minPriority: 0,
  textAnchor: "left",
  textOffset: [1, 0],
  color: "#ffe8b8",
});
```

`textProperty` names the property to read; `text: (feature) => string` is the
escape hatch for a composed label. `priorityProperty`/`minPriority` drive the
declutter arbiter, which is greedy and reserves the box where a label **lands**,
not where its point is.

**Placement.** `textAnchor` is MapLibre's `text-anchor` vocabulary and semantics
— `center` (the default), `left`, `right`, `top`, `bottom` and the four corners,
where `left` puts the label's left edge on the point so it reads out to the
right. `textOffset` is `[x, y]` in **cells** with `y` down, applied on top of the
anchor, because an anchor alone puts the label's edge exactly *on* the point and
a name anchored `left` of a dot has its first character inside the dot. Omitting
both — or `textAnchor: "center"` with `textOffset: [0, 0]` — writes no transform
at all and is byte-identical.

**Wrapping.** A label longer than `GLYPH_MAP_LABEL_WRAP_CELLS` (20 characters) is
broken onto up to `GLYPH_MAP_LABEL_WRAP_MAX_LINES` (3) **balanced** lines, at
word boundaries only, centred on each other and on the feature's point. The
arbiter measures the wrapped block, so a long name no longer reserves a strip
across a third of the frame. The rule is exported if you want it for your own
text:

```ts
import { glyphMapWrapLabel } from "@glyphcss/maps";

glyphMapWrapLabel("Region de Magallanes y de la Antartica Chilena");
// → ["Region de", "Magallanes y de la", "Antartica Chilena"]
```

**What gets labelled.** Points, and **lines**. A line gets one anchor: the
arc-length midpoint of its longest part, with longitude weighted by `cos(lat)`
so the midpoint is a ground midpoint. Lines are labelled because a vector schema
ships an elongated feature's name as the path a curved-text renderer would run
the name along — OpenMapTiles' `water_name` does, and every lake in it is a line
— while a character grid has no curved text. A **polygon is not labelled**: its
anchor is a pole of inaccessibility, a different algorithm, and a point on the
boundary would be a wrong answer rather than no answer.

```ts
import { glyphMapLabelAnchorPoint } from "@glyphcss/maps";

glyphMapLabelAnchorPoint({ geometryType: "line", rings: [[[0, 0], [1, 0]]] });
// → [0.5, 0]
```

## `glyph`

A point feature drawn as **glyphs in the grid** rather than as a DOM node — the
third point layer, and the only one that is part of the picture. It stamps
through the same post-raster path `line` and `contour` use, so its marks survive
"copy the text", are depth-tested per cell against whatever geometry won it, and
disappear round the limb of a globe.

```ts
map.addLayer({
  type: "glyph",
  source: { features: quakes },
  color: "#ff6b4a",
  sizeProperty: "mag", sizeScale: 0.2, size: 0.5,
  altitudeProperty: "altKm", altitudeScale: 1000,
  textProperty: "title", priorityProperty: "score", textAnchor: "top", textOffset: [0, -1],
  onSelect: (feature) => window.open(String(feature.properties?.url), "_blank", "noopener,noreferrer"),
});
```

| Option | Meaning |
|---|---|
| `size`, `sizeProperty`, `sizeScale` | The mark's disc radius **in cell rows** — not a pixel radius. `size: 0` (the default) is exactly one cell. |
| `ramp` | Ordered mark glyphs, least ink first. Default `["·", "•", "●"]`. |
| `altitude`, `altitudeProperty`, `altitudeScale` | Height above the ground in **true metres**, exempt from the terrain's `exaggeration`. |
| `textProperty` / `text`, `textAnchor`, `textOffset`, `priorityProperty` | An optional stamped label and its placement. |
| `onSelect` | Called with the feature a click landed on. |

**Size is a glyph.** A character grid cannot draw a 3-pixel dot, so `size` is a
disc radius in cell rows (columns stretched by the cell aspect). The disc is
rasterized and each cell's glyph is picked by **its own coverage** out of
`ramp` — so under one cell the mark grows by climbing the ramp, and past one
cell it grows by covering more cells, with the partial rim landing on the
smaller entries. `ramp` defaults to three centred discs of increasing ink, which
reads as one shape getting bigger rather than as three different symbols. A
one-entry `ramp` with `size: 0` is a fixed identity mark — a `▲` for a launch
pad, a `★` for a satellite.

**Altitude is true metres.** Like a `fill-extrusion`'s height, and for the same
reason: an orbital altitude is a measured length, not relief, so it does not
take the terrain's exaggeration. The ground it is measured from *is* the
exaggerated relief. A satellite at 550 km therefore stands 8.6% of a radius off
the surface on a globe, clears the limb from altitude, and is hidden when it is
behind the Earth — all from the projection's existing horizon test.

**Labels are rationed, marks are not.** `priorityProperty` feeds the same greedy
declutter arbiter a `symbol` uses, so labels thin out smoothly as the view zooms
out while every feature keeps its mark. A stamped label is folded to printable
ASCII (`glyphMapAsciiLabel`), because a glyph outside the colour-font atlas
would latch the whole scene to the span encoder.

**Selection is a hit test.** A glyph is not an element, so `onSelect` is resolved
by matching a click against the marks the last render actually stamped — one
handler for the whole layer, drag-guarded by the same rule the `click` event
uses, within two cell rows of a mark plus its own size. A layer without
`onSelect` arms neither the test nor the pointer cursor. What a click *means* is
yours: the widget resolves which feature and nothing else.

Use `symbol`/`circle` instead when the mark must be a real element — a name a
reader selects and copies, or a marker with its own hover chrome.

## `model`

Your own polygons, in map world space:

```ts
map.addLayer({
  type: "model",
  polygons: myPolygons,
  attribution: [{ name: "Me", license: "CC-BY 4.0" }],
});
```

The only LAYER that carries its own `attribution`, because it is the only one
with no source to declare it. Every other layer takes the credit off its
source — a provider's `attribution`, a geo tile's, or a static
[feature collection's](#sources).

## Markers

A marker is not a layer — it is a single anchored DOM element:

```ts
const marker = map.addMarker({ at: [-58.38, -34.6], label: "Buenos Aires", elevation: 0 });
marker.el.classList.add("my-pin");
marker.remove();
```

`elevation` is metres, passed straight through as `project`'s third argument, so
it lifts along the locally-correct up direction with no separate anchoring
concept. Default `0` — exactly at the geographic point, Leaflet's convention.

`symbol`, `circle` and `glyph` marks, by contrast, are anchored at the **ground
elevation** under their own lon/lat — never a feature's own elevation property.
See [Terrain](/maps/terrain#where-the-ground-comes-from).


---

# OpenStreetMap

`@glyphcss/maps` reads OpenStreetMap two ways. **OpenFreeMap is the shipped
path**: a public, no-key vector tile service the widget sweeps live, so you get
the planet on demand. PMTiles is the other, for a self-hosted archive or a small
vendored extract.

Both carry ODbL data. [Attribution is not optional](#attribution).

## OpenFreeMap

```ts
import {
  createGlyphMap,
  glyphMapEquirectangular,
  glyphMapOpenFreeMapProvider,
  glyphMapOpenMapTilesLayers,
} from "@glyphcss/maps";

const map = createGlyphMap(host, {
  view: { center: [8.54, 47.375], span: 0.06, cols: 140, rows: 63 },  // Zurich
  projection: glyphMapEquirectangular({ exaggeration: 24 }),
  layers: [{ type: "background", color: "#05070c" }],
  tilt: 55,
});

const osm = glyphMapOpenFreeMapProvider();

for (const layer of glyphMapOpenMapTilesLayers(osm, {
  include: ["omt-water", "omt-roads", "omt-buildings", "omt-places"],
})) {
  map.addLayer(layer);
}
```

That is the whole setup. No API key, no registration, no account.

### The provider

`glyphMapOpenFreeMapProvider(opts?)` mounts
`https://tiles.openfreemap.org/planet/latest/{z}/{x}/{y}.pbf` as a real
`GlyphMapVectorProvider`, so the widget's own tile sweep streams it.

| Option | Default |
|---|---|
| `id` | `"openfreemap"` |
| `tileUrl` | `GLYPH_MAP_OPENFREEMAP_TILE_URL` |
| `layers` | every source layer the tile carries |
| `minZoom` | `0` (`GLYPH_MAP_OPENFREEMAP_MIN_ZOOM`) |
| `maxZoom` | `14` (`GLYPH_MAP_OPENFREEMAP_MAX_ZOOM`) |
| `tileResolution` | `256` |
| `attribution` | `GLYPH_MAP_OPENFREEMAP_ATTRIBUTION` |
| `fetchTile` | real `fetch` |
| `onError` | none |

**A tile that 404s, times out, or does not decode resolves empty — it never
rejects.** One rejection would take down the whole frame's `Promise.all`, so a
missing tile is a blank tile and nothing more. Pass `onError` to hear about it:

```ts
const osm = glyphMapOpenFreeMapProvider({
  onError: (error, { z, x, y }) => console.warn("tile", z, x, y, error),
});
```

### Why Web Mercator works here

Every pyramid this package bakes is addressed on an **equal-angle** quadtree.
OpenFreeMap, like every slippy-map service, is **Web Mercator**. At z12 the
Zurich tile sits at Mercator `y = 1434` and equal-angle `y = 1025` — a sweep on
the wrong grid requests tiles that do not exist and misses the ones that do.

The fix is a provider **capability**, exactly as projections do it: a provider
may declare `tileRange`, and `createGlyphMap` keys on that field's presence,
never on a provider id.

```ts
import { glyphMapMercatorTileRange, glyphMapMercatorZooms } from "@glyphcss/maps";

const provider = {
  id: "my-mvt",
  zooms: glyphMapMercatorZooms(0, 14),     // tileResolution defaults to 256
  tileRange: glyphMapMercatorTileRange,    // ← the opt-in
  bounds: mercatorBounds,
  loadTile: myLoader,
};
```

A provider that declares nothing keeps this package's equal-angle indexer and is
byte-identical.

`tileResolution` is `256` because that is the pixel size the tiles were
generalized *for*. The MVT extent of 4096 is coordinate precision, not detail —
feeding it to the LOD picker makes z0 look like it already resolves street
detail, and the ladder never deepens. `GLYPH_MAP_MERCATOR_MAX_LAT`
(85.0511287798066) is Web Mercator's own latitude limit; above it the strategy
returns an empty range rather than clamping.

Volume falls out of that: one tile at a world view, at most a couple of dozen at
a country or city view, and it caps at the pyramid's own max zoom rather than
requesting z18.

## The OpenMapTiles schema

The service's source layers are **not** map layers. `transportation` holds
motorways, footpaths and railways together, `water` holds lakes as polygons while
`waterway` holds rivers as lines, `boundary` carries a numeric `admin_level`
rather than a kind. So the mapping is
`(source layer, class, geometry) → glyph layer type`, and this package ships it
as a table.

```ts
import { GLYPH_MAP_OPENMAPTILES_LAYERS, glyphMapOpenMapTilesLayers } from "@glyphcss/maps";
```

| Spec id | Layer type | Source layer | Notes |
|---|---|---|---|
| `omt-landcover` | `fill` | `landcover` | coloured by `class` |
| `omt-landuse` | `fill` | `landuse` | coloured by `class` |
| `omt-water` | `fill` | `water` | ocean flat, lakes draped |
| `omt-waterways` | `line` | `waterway` | excludes tunnels |
| `omt-roads` | `line` | `transportation` | excludes tunnels, driveways, parking aisles, indoor |
| `omt-buildings` | `fill-extrusion` | `building` | `render_height` / `render_min_height`, facades on |
| `omt-boundaries` | `line` | `boundary` | international only (`admin_level ≤ 2`), no maritime or disputed |
| `omt-places` | `symbol` | `place` | labelled by `name`, priority `rank` |
| `omt-peaks` | `symbol` | `mountain_peak` | label is `name` + elevation |
| `omt-pois` | `circle` | `poi` | `rank ≤ 20` |
| `omt-parks` | `symbol` | `park` | requires a `name` |
| `omt-aeroways` | `line` | `aeroway` | |
| `omt-water-labels` | `symbol` | `water_name` | points **and** lines |

**Every name in that table was read out of the live service's own TileJSON and
tiles**, vendored under `packages/maps/fixtures/openfreemap/`, not out of the
published schema documentation.

`glyphMapOpenMapTilesLayers(source, opts?)` builds ready-to-mount layers:

| Option | Meaning |
|---|---|
| `include` | Spec ids to build, **in that order**. Default: all of them |
| `classes` | Per source layer, narrow to these `class` values |
| `colors` | Per spec id, replace the colour (this *replaces* a class→colour table, it does not layer over it) |
| `densities` | Per spec id, a `density` |
| `textAnchors` | Per `symbol` spec id, a `textAnchor` |

Every spec is always built, even where the current view has no data for it —
"does this layer have data" is a property of the view, not of a live provider.

Three helpers read the schema's own discriminators, so you can write a `filter`
without hardcoding property names:

```ts
import {
  glyphMapOpenMapTilesClass,
  glyphMapOpenMapTilesAdminLevel,
  glyphMapOpenMapTilesBrunnel,
} from "@glyphcss/maps";

map.addLayer({
  type: "line",
  source: osm,
  sourceLayer: "transportation",
  filter: (f) => glyphMapOpenMapTilesClass(f) === "motorway",
});
```

### Sharing tile requests

Each mounted layer runs its own sweep, so mounting ten rows off one provider
costs ten requests for the same `0/0/0`. The library does not dedupe for you —
wrap `fetchTile` if you mount several rows:

```ts
const inflight = new Map>();

const osm = glyphMapOpenFreeMapProvider({
  fetchTile: (url) => {
    let p = inflight.get(url);
    if (!p) {
      p = fetch(url).then((r) => r.arrayBuffer()).finally(() => inflight.delete(url));
      inflight.set(url, p);
    }
    return p;
  },
});
```

This is what the [maps workbench](/maps) does behind its OSM card.

## PMTiles and the Protomaps basemap

The other path is a **self-hosted or vendored archive**.

```ts
import {
  glyphMapPMTilesProvider,
  glyphMapPMTilesBufferSource,
  glyphMapProtomapsExtract,
  glyphMapProtomapsLayers,
} from "@glyphcss/maps";

const bytes = await (await fetch("/data/osm/zurich-z12.pmtiles")).arrayBuffer();
const extract = await glyphMapProtomapsExtract(glyphMapPMTilesBufferSource(bytes));

extract.bounds;       // the archive header's own bbox — outside it there is no data
extract.kinds.roads;  // ["ferry", "highway", "major_road", "minor_road", "path", "rail"]

for (const layer of glyphMapProtomapsLayers(extract, {
  include: ["osm-roads", "osm-water", "osm-buildings"],
  kinds: { roads: ["highway", "major_road"] },
})) {
  map.addLayer(layer);
}
```

`glyphMapPMTilesProvider(urlOrSource, opts?)` is the raw archive reader —
range-reads with `pmtiles`, decodes MVT with `@mapbox/vector-tile` and `pbf`,
reports the header's own `extent`, and defaults `attribution` to
`GLYPH_MAP_PROTOMAPS_ATTRIBUTION` and `tileResolution` to `4096` (a generic
archive has no basemap-specific LOD assumption to make).

`glyphMapProtomapsExtract` is the schema mapping on top: it reads the archive as
an **extract** — one feature collection per source layer, decoded up front — not
as a mounted provider. `maxTiles` (default `64`) throws rather than pulling an
oversized archive into memory. An extract is a handful of tiles at one zoom, so
there is no LOD ladder for a provider to select across.

The Protomaps table is its own mapping, discriminated by `kind` (or `pmap:kind`
on pre-v4 archives) rather than `class`:

| Spec id | Layer type | Source layer |
|---|---|---|
| `osm-earth` | `fill` | `earth` |
| `osm-landuse` | `fill` | `landuse` |
| `osm-water` | `fill` | `water` (polygons) |
| `osm-waterway` | `line` | `water` (lines) |
| `osm-roads` | `line` | `roads` |
| `osm-buildings` | `fill-extrusion` | `buildings` |
| `osm-boundaries` | `line` | `boundaries` |
| `osm-places` | `symbol` | `places` |
| `osm-pois` | `circle` | `pois` |

It is deliberately **not** a generalization of the OpenMapTiles table. The two
schemas disagree on the discriminator, the layer names, the height property, and
whether a landmass polygon exists at all — one table that covered both would be
wrong about each.

Generate a small extract without downloading the planet:

```sh
pmtiles extract https://build.protomaps.com/DATE.pmtiles region.pmtiles \
  --bbox=WEST,SOUTH,EAST,NORTH --maxzoom=MAX_ZOOM
```

## Attribution

OpenStreetMap data is **ODbL**, and anything derived from it must say so. This is
a licence obligation, not a courtesy.

Both providers carry their credit at the type level, so it rides into
`map.getAttributions()` from the mounted layer itself:

```ts
import { GLYPH_MAP_OPENFREEMAP_ATTRIBUTION } from "@glyphcss/maps";

// [
//   { name: "OpenStreetMap contributors", url: ".../copyright", license: "ODbL" },
//   { name: "OpenMapTiles",               url: "...",           license: "CC-BY 4.0" },
//   { name: "OpenFreeMap",                url: "...",           license: "ODbL" },
// ]
```

`GLYPH_MAP_PROTOMAPS_ATTRIBUTION` is the PMTiles equivalent (OpenStreetMap +
Protomaps, both ODbL).

Render it. The list is derived from the layers actually mounted and recomputed on
every call, so it appears when you mount an OSM layer and withdraws when you
remove it:

```ts
const credit = document.querySelector("#credit")!;
function paintCredit() {
  credit.innerHTML = map.getAttributions()
    .map((a) => (a.url ? `${a.name}` : a.name))
    .join(" · ");
}
map.on("load", paintCredit);
```

OpenFreeMap's own line it calls optional but recommended; it ships anyway,
because "optional" is not a reason to drop the credit of the people hosting the
planet for free. The OpenStreetMap line is not optional at all.


---

# Maps

`@glyphcss/maps` turns **geographic data into a glyphcss scene**. It projects
lon/lat to a 3D world, builds a real relief mesh from elevation tiles, mounts a
MapLibre-shaped layer vocabulary on top, and hands the whole thing to one
`createGlyphScene` — so a map is the same single `
` write per frame as any
other glyphcss render.

```bash
npm install @glyphcss/maps
```

There is no React or Vue binding. The package is imperative: you own the host
element, and `createGlyphMap` returns a handle.

## Two entry points

| Import | Contains | Safe in |
|---|---|---|
| `@glyphcss/maps` | Everything — projections, tiles, the relief mesh, the widget, layers, vector providers | Browser, Node, a worker |
| `@glyphcss/maps/node` | The root entry re-exported, **plus** `loadGlyphMapSource` | Node only |

The root is **pure**: no `fs`, no native modules, nothing that assumes a
filesystem. The `/node` subpath is the only place a filesystem reader lives, and
the root never imports it, so bundling the root can never pull `node:fs` into a
browser build.

`gdal-async` is deliberately not a dependency of either. The one reader that
ships reads Esri/Arc-Info ASCII Grid.

## A first map

Everything a map needs is a **view** (where you are looking) and a
**projection** (how the world flattens). Layers are optional and can be added
later.

```ts
import { createGlyphMap, glyphMapGlobe } from "@glyphcss/maps";

const host = document.querySelector("#map")!;

const map = createGlyphMap(host, {
  view: { center: [0, 20], span: 140, cols: 140, rows: 63 },
  projection: glyphMapGlobe({ exaggeration: 24 }),
  layers: [{ type: "background", color: "#05070c" }],
});

map.on("click", ({ lngLat }) => console.log(lngLat));
map.fitBounds({ west: -74, east: -34, south: -56, north: 13 });
```

Drag to pan or orbit, wheel to zoom, Ctrl+drag (or right-drag) to tilt and turn.
On touch: pinch to zoom, twist to turn, two fingers together to tilt.
The widget owns one `requestAnimationFrame` motion loop and issues **at most one
render per displayed frame**, so a gesture never queues renders it will throw
away.

Call `map.destroy()` when you are done — it removes the scene's DOM, every host
listener, and any pending debounced tile fetch.

## Everything public speaks lat/lng

`GlyphMapView` is the whole camera state a consumer ever sets:

```ts
interface GlyphMapView {
  center: readonly [lon: number, lat: number];
  span: number;   // degrees across the viewport
  cols: number;   // output grid width, in character columns
  rows: number;   // output grid height, in character rows
  bounds?: GlyphMapBounds;  // only on a view built by glyphMapBounds()
}
```

The vertical extent is **derived** from `span * (rows / cols)` — the aspect lock
— so terrain can never shear. Grid and cell coordinates never appear in a public
parameter or return value on their own; `project`/`unproject` are the two places
they cross the boundary, and both are explicit about it:

```ts
const { col, row, visible } = map.project([-58.38, -34.6]);
const lngLat = map.unproject([70, 20]);   // null off any projected surface
```

`view.cols`/`view.rows` are the authoritative grid shape. Pass `autoSize: true`
to let the host element's pixel size drive them instead.

## The handle

`createGlyphMap` returns a `GlyphMapHandle`. The whole surface, grouped:

| Group | Methods |
|---|---|
| View | `setView`, `getView`, `getMaxSpan`, `fitBounds`, `flyTo`, `resize` |
| Camera | `setTilt`, `getTilt`, `getMaxTilt`, `setBearing`, `getBearing`, `setProjection` |
| Street level | `setWalk`, `getWalk` |
| Layers | `addLayer`, `removeLayer`, `moveLayer`, `getContourFieldRange` |
| Lighting | `setSun`, `getSun`, `getSunDirection`, `getSubsolarPoint`, `setKeyLight`, `getKeyLight`, `getKeyLightDirection`, `setShadow`, `getShadow` |
| Overlays | `addMarker` |
| Coordinates | `project`, `unproject` |
| Credit | `getAttributions` |
| Events | `on`, `off` |
| Teardown | `destroy` |

Plus two properties: `host`, and `scene` — the underlying `GlyphSceneHandle`.
`scene` is the deliberate escape hatch: anything glyphcss can do that the map
does not model (effect layers, `charMode`, the font atlas, direct camera reads)
you reach through it.

```ts
map.scene.setOptions({ charMode: "braille" });
```

Scene options you want set from the start go through `GlyphMapOptions.scene`
instead — they are merged **under** the widget's own `camera`/`cols`/`rows`, so
shading, colour encoding and shadows compose without fighting the map for the
grid:

```ts
createGlyphMap(host, {
  view, projection,
  scene: { useColors: true, colorEncoding: "atlas", ambientLight: { intensity: 0.25 } },
});
```

## Events

```ts
map.on("load", () => {});                       // construction-time layers finished their first load
map.on("move", ({ view }) => {});               // pan / flyTo / setView
map.on("zoom", ({ view }) => {});
map.on("click", ({ lngLat, originalEvent }) => {});  // lngLat is null off-surface
map.on("sun", ({ at, subsolar, direction }) => {});  // only while sun mode is on
```

`off(type, handler)` takes the same handler identity back.

## Attribution is not optional

Real map data comes with real licence terms. Every source in this package
declares its own provenance at the type level, and the handle aggregates
whatever the **currently mounted** layers carry:

```ts
for (const credit of map.getAttributions()) {
  console.log(credit.name, credit.license, credit.url);
  // → OpenStreetMap contributors  ODbL  https://www.openstreetmap.org/copyright
}
```

```ts
interface GlyphMapAttribution {
  name: string;
  url?: string;
  license: string;
  date?: string;
}
```

The list is recomputed per call over the live layer order, so it grows when you
mount an OSM layer and shrinks when you remove it. **Render it somewhere the
reader can see.** OpenStreetMap data is ODbL and requires the credit;
OpenMapTiles is CC-BY 4.0. Nothing in this package hardcodes a credit string on
a page, precisely so the displayed credit cannot drift from the data actually on
screen.

## Where to go next

- [Projections](/maps/projections) — the lon/lat contract, the four projections, and capabilities.
- [Layers](/maps/layers) — the full layer vocabulary and what each one takes.
- [Camera & Navigation](/maps/camera) — tilt, bearing, their gestures, flights, and street-level walk mode.
- [Terrain](/maps/terrain) — elevation tiles, the relief mesh, draping, sun and shadows.
- [OpenStreetMap](/maps/openstreetmap) — streaming the planet through OpenFreeMap.

The live [maps workbench](/maps) drives every option on this page against real
ETOPO1 terrain and OpenStreetMap tiles, and emits the `createGlyphMap` call for
whatever you tuned.


---

# Projections

A projection in this package is **not** a rendering mode. It is a vertex
transform: lon/lat/elevation in, a world-space `Vec3` out. The camera, the
relief mesh, the layers and the widget all consume that one function, which is
why a globe and a flat sheet are the same code path rather than two renderers.

```ts
import { glyphMapGlobe } from "@glyphcss/maps";

const globe = glyphMapGlobe({ radius: 1, exaggeration: 24 });
const world = globe.project(8.54, 47.37, 408);   // lon, lat, metres
const [lon, lat] = globe.unproject(world);
```

## The contract

```ts
interface GlyphMapProjection {
  id: string;
  project(lon: number, lat: number, elev: number): Vec3;
  unproject(p: Vec3): readonly [lon: number, lat: number];
  domain: GlyphMapBounds;
  exaggeration: number;

  // optional capabilities
  visible?(world: Vec3, depthOf: (world: Vec3) => number): boolean;
  cameraForCenter?(lon: number, lat: number): { rotX: number; rotY: number };
  centerForCamera?(rotX: number, rotY: number): readonly [lon: number, lat: number];
}
```

**Units are degrees**, in and out. That is the repo convention across cameras
and meshes alike, not d3's radians.

**Crop, don't clamp.** `project` returns `[NaN, NaN, NaN]` for a point outside
its valid window — Mercator past `±maxLat`, orthographic on the far hemisphere.
A mesh builder drops a quad with any invalid corner rather than clamping it,
because clamping collapses a row of vertices into a zero-area sliver.

## The four built-ins

```ts
import {
  glyphMapEquirectangular,
  glyphMapMercator,
  glyphMapGlobe,
  glyphMapOrthographic,
} from "@glyphcss/maps";

glyphMapEquirectangular({ exaggeration: 24 });
glyphMapMercator({ maxLat: 85.05112877980659, exaggeration: 24 });
glyphMapGlobe({ radius: 1, exaggeration: 24 });
glyphMapOrthographic({ lon0: 0, lat0: 0, exaggeration: 24 });
```

Every option shown is optional; `exaggeration` defaults to `1`, `radius` to `1`,
`maxLat` to Web Mercator's own limit, and `lon0`/`lat0` to `0`.

The three flat ones share one world frame: **`X` is north/south (increasing
north), `Y` is east/west (increasing east)**. `glyphMapGlobe` is the textbook
right-handed sphere — `X = r·cosLat·cos(lon)`, `Y = r·cosLat·sin(lon)`,
`Z = r·sinLat` — and that chirality is pinned by a test anchored *outside* this
package's own math, using glyphcss's real camera to assert 30°E lands at a
greater screen column than 0°E under a north-up camera facing Greenwich.

### Any d3 raw projection

```ts
import { geoMollweideRaw } from "d3-geo-projection";
import { glyphMapFromD3Raw } from "@glyphcss/maps";

const mollweide = glyphMapFromD3Raw(geoMollweideRaw);
```

`glyphMapFromD3Raw` adapts a `(lambda, phi) → [x, y]` raw projection: it
converts degrees to radians and swaps d3's `(x, y)` into this package's `(Y, X)`
frame. A second options argument sets `id`, `domain` and `exaggeration`
(defaults: `"glyph-map-d3-raw"`, the full world, `1`). `unproject` delegates to
the raw projection's `.invert` and **throws a `TypeError`** if it has none —
loudly, rather than silently answering a wrong coordinate.

`d3-geo-projection` is a **devDependency only** — the adapter never imports it,
so it never becomes a runtime dependency of a consumer.

## Capabilities, never `projection.id`

`project`/`unproject`/`domain`/`exaggeration` are required. The other three are
capabilities, and **every projection-specific behaviour in the widget keys on
their presence** — there is no `if (projection is globe)` anywhere.

### `visible` — near/far hemisphere

Present only on `glyphMapGlobe`, where every `(lon, lat)` is a geometrically
valid point on the sphere, front *or* back. Absent on every flat projection,
which already excludes an invisible point by returning `NaN` from `project`.
One test drives tile culling, `map.project(...).visible`, and marker hiding.

### `cameraForCenter` / `centerForCamera` — orbit vs. sheet

Present only on a projection navigated by **orbiting the camera around fixed
world geometry** (the globe). Their presence is what makes a drag orbit instead
of pan-with-clamp, and their absence is what this package means by a **sheet**.

The distinction shows up in five places you can feel:

| | Sheet (equirectangular, Mercator, orthographic) | Orbit (globe) |
|---|---|---|
| Drag | Pans, clamped in world space | Orbits the camera |
| `tilt` default | `40`, and *is* `camera.rotX` | `0`, and *adds* to the framing pitch |
| `getMaxSpan()` | The cover limit — the map always fills the viewport | The projection's domain width |
| `getMaxTilt()` | The `85` cap at every span | The horizon angle at the view's scale |
| `setWalk()` | Throws — a metre of height and a metre of ground are different world units | Supported |

## Exaggeration, and the one true-metre escape

`exaggeration` is a **terrain** concept, and `project` is the package's one
elevation conversion: `z = (elev / GLYPH_MAP_EARTH_RADIUS_M) * exaggeration`. So
`exaggeration: 1` is true-scale relief on every projection, globe included, and
`24` is the readable default the workbench opens on.

It is readable on the projection (`projection.exaggeration`) because nothing
else can recover the factor. A sheet's `X`/`Y` are degrees and the globe's are
Earth radii, so no probe of `project` can tell you how much of its `Z` was
exaggeration.

That matters as soon as something in the scene is measured in **true metres** —
a building's height, an eye height, a sky dome's radius. Those quantities are
exempt from terrain exaggeration, and the conversion is public:

```ts
import { glyphMapTrueScaleElevation } from "@glyphcss/maps";

// 60 m of building, on a projection whose terrain is 24x exaggerated
const axisElev = glyphMapTrueScaleElevation(60, projection);  // 60 / 24
projection.project(lon, lat, axisElev);
```

Reverse that and every extrusion is buried; skip it and a 20 m building draws
480 m tall. `fill-extrusion` does this for you — see
[Layers](/maps/layers#fill-extrusion).

## Animated transitions

`setProjection` blends between two projections rather than cutting:

```ts
await map.setProjection(glyphMapGlobe({ exaggeration: 24 }), { durationMs: 900 });
```

`durationMs: 0` applies the target instantly with no animation frame at all. The
blend strategy is chosen by capability — a plain lerp, a scale-normalized lerp,
or a spherical-cap unwrap — anchored on the view centre so the point you were
looking at stays put. Zoom is scheduled on **log-linear apparent size**, never
lerped, so the world does not appear to lurch. Endpoint framings are assigned
verbatim at the ends of the flight, and each endpoint's pitch is clamped to its
*own* ceiling.

The pure blend is exported on its own if you want to drive the interpolation
yourself:

```ts
import { glyphMapProjectionTransition } from "@glyphcss/maps";
```

Entering [walk mode](/maps/camera#street-level-walk-mode) is incompatible with a
flight: a `setProjection` while walking **leaves** walk mode rather than
blending through it.


---

# Terrain

Terrain is what makes the rest of the map three-dimensional. A `raster` layer
turns an elevation pyramid into a real mesh, and everything else in the scene —
roads, buildings, labels, washes — stands on it.

## Tiles

```ts
interface GlyphMapGeoTile {
  bounds: GlyphMapBounds;
  cols: number;
  rows: number;
  elevation: Float32Array;   // (cols + 1) x (rows + 1), row-major, row 0 = north
  source: string;
  sampler: string;
  attribution?: readonly GlyphMapAttribution[];
}
```

The elevation grid is **vertex-centered** — `(cols + 1) × (rows + 1)`, not the
cell-centered `GlyphMapField` the static pipeline uses — so adjacent quads share
an edge with no seam.

Crucially, a tile carries **lon/lat/elevation only**. Projection is applied
client-side, so **one tile pyramid serves every projection**: switching from
Mercator to a globe re-projects the tiles you already have rather than fetching
a different pyramid.

A tile whose bounds straddle the antimeridian (`bounds.east > 180`, the
unwrapped authoring convention) must be split **before** projecting, or its quads
bridge the whole map as garbage strips:

```ts
import { splitGlyphMapGeoTileAtAntimeridian } from "@glyphcss/maps";

// One tile back when it does not straddle; two when it does.
const pieces = splitGlyphMapGeoTileAtAntimeridian(tile);
```

It splits on a whole-column boundary near the seam — a literal grid slice, never
a resample.

Reading and probing a tile:

```ts
import {
  glyphMapDecodeGeoTileInt16,
  glyphMapGeoTileElevationAt,
  glyphMapGeoTileElevationRange,
  glyphMapGeoTileVertexLonLat,
} from "@glyphcss/maps";
```

## Providers

A provider is a tile pyramid the widget sweeps for you:

```ts
interface GlyphMapProvider {
  id: string;
  zooms: readonly GlyphMapProviderZoomLevel[];
  attribution?: readonly GlyphMapAttribution[];
  bounds(z: number, x: number, y: number): GlyphMapBounds;
  loadTile(z: number, x: number, y: number): Promise;
  tileRange?: GlyphMapTileRangeStrategy;
}
```

Mount it as a `raster` layer's `source` and the widget handles the tile cache,
the visibility diff, the in-flight guard, and a gesture-gated debounce.

**LOD is keyed on degrees per glyph cell, never on `camera.zoom`.**

```ts
import { glyphMapDegreesPerCell, glyphMapTargetLOD, glyphMapFinestLOD } from "@glyphcss/maps";
```

Ground units per cell is geographic by construction, so a `radius: 1` globe and
a `radius: 100` one select the same level for the same view. When no level is
fine enough the **deepest** is kept, so a shallow pyramid stops sharpening
rather than stops drawing.

A tile sweep's visibility test samples the tile's own bounds **and** the
viewport's own unprojected points — never a single point. A 22.5° tile straddles
a 3° viewport whenever that point lands within a viewport-width of a tile edge,
and a nonzero `tilt` makes the visible window asymmetric (it reaches much
further toward the horizon), so no box centred on `view.center` describes it.

Past a global pyramid's affordable depth, wrap it:

```ts
import { glyphMapCuratedProvider } from "@glyphcss/maps";

const provider = glyphMapCuratedProvider(base, [
  { zoom: z5Level, tiles: new Set(["17_11"]), loadTile: (x, y) => fetchCurated(5, x, y) },
]);
```

Curated levels hold real tiles only inside a place's bounds; every other tile at
those depths **degrades to the deepest ancestor that exists** — never blank,
never a throw.

## The relief mesh

```ts
import { glyphMapPolygons, glyphMapGlobe } from "@glyphcss/maps";

const polygons = glyphMapPolygons(tile, glyphMapGlobe({ exaggeration: 24 }), {
  color: (elev) => (elev < 0 ? "#2a55a8" : "#5a7a30"),
  resolution: { cols: 32, rows: 32 },
});
```

One quad per tile cell, skipping any quad with a corner outside the projection's
valid window. The result is a plain `Polygon[]` — render it with
`createGlyphScene` or `compileScene` like any other mesh, no widget required.

| Option | Meaning |
|---|---|
| `color` | `(elev) => string \| undefined` — per-quad colour from its representative elevation |
| `colorSample` | `"surface-median"` (default) or `"corner-mean"` |
| `resolution` | Build a coarser mesh than the tile's baked grid |
| `elevationBias` | Metres, offsets vertex **position** only |
| `minElevation` / `maxElevation` | Clamp positions into an elevation window |

**Resolve one resolution per pyramid *level*, never per tile.** Two tiles built
at the same resolution sample identical source vertices along their shared edge,
so the edge is exactly shared; tiles at different resolutions do not, and the
T-junction gap shows as a tear.

### Why the colour is a median

`colorSample: "surface-median"` takes a quad's colour elevation from the
**median of the tile's own full-resolution vertices** over the block the quad
covers. So colour fidelity does not degrade with mesh resolution, and a quad
straddling a coastline can never be classified into a band its terrain's
majority does not hold.

A corner mean can: it averages deep ocean with high land and falls below sea
level, painting bathymetric blue over land. Measured on the real ETOPO1 pyramid,
the floor tier's quad over Bogotá averaged −155 m across corners on terrain that
is 379 of 441 samples above sea level. The median's guarantee is exact rather
than statistical: if more than half the covered samples are at or above sea
level, the middle sample is too. (Point-sampling the quad centre was measured
and is *worse* than the mean — it lands in rivers and inlets.)

`"corner-mean"` exists because the `heatmap` layer keys a `Map` on that exact
float to recover per-quad density.

### Three tiers, and why the backstops are sunk

A `raster` layer mounts **three** tiers at once: the target LOD, a coarser
fallback mounted first, and a permanent floor capped to 32 quads per axis. That
is what makes panning never show a blank hole.

All three are opaque meshes in **one** scene, and a coarse quad is not merely a
blurrier fine one: its chord interpolates linearly across up to ~11°, so where
it straddles a coast it sits kilometres *above* the fine tier's sea floor, wins
the shared depth test over open ocean, and paints it the colour of a block that
is mostly land — "the sea is basically green".

So every tier that is not the target is **sunk** by 20,000 m through
`elevationBias`. That is a bound rather than a tuning: it exceeds Earth's entire
solid-surface relief, which bounds how far any coarse chord can rise above a
finer sample of the same field, and it is in metres, so it is
exaggeration-invariant. With the sink, the combined render is cell-for-cell
identical to the target tier alone.

## The terrain elevation window

`minElevation`/`maxElevation` on a `raster` layer are metres, both optional, both
byte-identical when omitted. Terrain outside the window is **held at the window
edge, not dropped**:

```ts
// Draw the land; replace the seabed with a smooth plane at sea level.
map.addLayer({ type: "raster", source: provider, classifier, colors, minElevation: 0 });

// The reverse: bathymetry with the land flattened off.
map.addLayer({ type: "raster", source: provider, classifier, colors, maxElevation: 0 });
```

This is the one place the package clamps where it otherwise crops, and the tier
ladder is why: a dropped quad is a **hole** the backstop underneath fills, in the
colour of an 11° quad that is mostly land. A clamp has no hole, and wherever
every sample a tier covers is out of window, every tier's surface is the same
constant plane — so no coarse chord can rise above a finer one.

It moves **position only**, per vertex. A quad's colour still reads the terrain's
own unwindowed elevation, so the sea keeps its bathymetric band and only its
floor goes, and a partially-submerged quad keeps its land corners at their own
heights — a coast still slopes rather than steps.

A `contour` layer is deliberately **not** windowed by this; it carries its own
window. A contour below the terrain's floor is buried under the flattened
surface, so set the contour's own floor to match.

## Where the ground comes from

Everything that stands on the ground — a `line`'s draped vertices, a
`symbol`/`circle` anchor, a `fill-extrusion`'s footing, a draped `fill`'s cap —
reads **one** elevation source. They must, or a road, the building beside it, the
label on it and the lake behind it part company under a tilt.

By default that source is the mounted `raster` layers' own tiles, finest tier
first. Supply your own and it wins everywhere, with no `raster` layer needed at
all — so a map can drape on terrain it never draws:

```ts
createGlyphMap(host, {
  view, projection,
  // metres, or null where you have no data for that point
  groundElevation: (lon, lat) => myDem.sample(lon, lat) ?? null,
  layers: [...],
});
```

`null` (or a non-finite number) means the datum for that point, so a source that
answers for only part of the world is fine. It is read **live** on every build
and every stamp, so a source closing over a still-loading DEM needs no setter:
change what it answers, then move the camera or re-add the layer. What it cannot
do is announce itself — a `raster` layer's tile arrivals re-plant mounted
geometry through the widget's own registry; a caller-owned source has no such
event.

**With no terrain and no source, everything sits on the datum.** That is not a
degraded mode — it is exactly what a flat map looks like.

One structural exception: a `contour` does **not** read this. Its lines are
marched from the mounted raster mosaic's own vertex grids — a field, not a point
lookup — so a caller-supplied function cannot serve it.

### Why draping matters

At a city view with `exaggeration: 24`, 10 m of ground moves the building
standing on it 16 rows and moved the road beside it zero. Roads and buildings
stopped coinciding. Draping a `line`'s vertices at the ground under their own
lon/lat is what puts them back together, and the depth test is then the ordinary
coplanar-surface allowance — real work, not a formality, because the drape reads
the tile's full-resolution field while the terrain rasterizes from a coarsened
quad mesh.

A finer tier landing **does** move a stroke, onto the ground that tier now
describes, in the same frame the terrain moves there. Within one tier the sample
is camera-independent, so no pan or orbit can make it shimmer.

## Classifiers

Elevations become bands through a classifier — a value with an id, not a flag:

```ts
import {
  GlyphMapClassifiers,
  glyphMapBreaks,
  glyphMapQuantile,
  glyphMapEqualInterval,
  glyphMapLog,
} from "@glyphcss/maps";

GlyphMapClassifiers.etopo1V1;              // frozen ETOPO1 breaks
glyphMapBreaks([0, 250, 800, 1500, 3000]);
glyphMapQuantile(9);                       // fits the field's own distribution
glyphMapEqualInterval(9, [-8000, 6000]);
glyphMapLog(9);
```

A field's `kind` gates which classifiers are legal: order-statistic classifiers
(`glyphMapQuantile`, `glyphMapLog`) **throw** on a categorical field rather than
coercing it.

## Lighting

Three mechanisms, and they have a strict precedence: **sun outranks headlight
outranks whatever you set yourself.** `getKeyLightDirection()` reports whichever
owner is live, and is the one call to read if you compose your own
`directionalLight` write.

### Real sun

```ts
map.setSun({ mode: "realtime" });
map.setSun({ mode: "manual", date: new Date("2026-06-21T12:00:00Z") });
map.setSun({ mode: "off" });
map.getSunDirection();
map.getSubsolarPoint();
```

| Option | Default |
|---|---|
| `mode` | `"off"` |
| `date` | now (`"manual"` only) |
| `tickMs` | `30000` (`GLYPH_MAP_SUN_TICK_MS`) |
| `twilightDeg` | `6` |
| `nightOpacity` | `0.72` |
| `nightColor` | `"#000000"` |
| `nightLevels` | `4` (`GLYPH_MAP_NIGHT_LEVELS`) |

`"off"` writes no light, starts no timer, installs no hook, and is byte-identical
to a map built before the option existed. `"realtime"` re-resolves on the
widget's own interval so the terminator keeps advancing at 0.25°/minute;
`"manual"` pins one instant and runs no timer.

The **mechanism is chosen by projection capability**. An orbit projection gets a
real `directionalLight.direction` — the outward unit vector at the subsolar
point — and Lambert draws the terminator, so no cell hook is installed there at
all. A sheet has one normal everywhere and cannot express a terminator with a
directional light, so it gets a per-cell day/night term that darkens **colour
only** (never the glyph, which carries terrain shape) and is therefore invisible
under `useColors: false`.

Darkness is quantized to 4 levels purely for cost: a continuous ramp gives almost
every cell its own colour and destroys span-run merging — measured 1,679 spans
and 9.40 ms per render on the worst case, against 123 spans and 1.00 ms with no
sun. Four levels is 604 spans and 3.32 ms.

The solar math is pure and clock-free if you want it on its own:

```ts
import { glyphMapSubsolarPoint, glyphMapSunDirection } from "@glyphcss/maps";
```

It is NOAA's formulation **including the equation of time** — worth ±16 minutes,
about ±4° of longitude, so dropping it is a visible error.

### Camera-following key light

```ts
map.setKeyLight("headlight");   // or "fixed", the default
```

`"fixed"` means the widget never writes `direction` at all. `"headlight"` points
the key light along the camera's own view axis and rewrites it whenever the
camera moves. That is what makes "everything lit, like a globe on a stand"
reachable: the whole visible face is lit with no terminator anywhere, while
Lambert still varies per face, so terrain relief survives. Pure ambient also
removes the terminator, but gives every face the same shade and renders the map
as flat paint.

Either way only `direction` is written — `intensity` and `color` stay yours.

The price is real and inherent: a light bolted to the camera invalidates
glyphcss's per-triangle shade cache every frame. A pure zoom is free (it does not
rotate the camera), but an orbit measured 9.11 → 16.52 ms per render. It is
partly self-funding, since an evenly lit globe has longer same-colour runs, so
the commit write drops. `interactiveDownscale` is the existing lever for a heavy
globe under a drag.

### Cast shadows

```ts
map.setShadow({ color: "#000000", opacity: 0.25 });
map.setShadow(null);   // off, the default
```

Off by default and byte-identical there — no `shadow` key reaches the scene at
all. What the widget adds on top of glyphcss's shadow map is the three things a
caller here cannot supply:

**Who.** `fill-extrusion` and `model` cast; those two **plus** `raster`, `fill`
and `heatmap` receive. The sets overlap deliberately — that is what makes a
building shadow the building next to it, the case a reader notices first in a
city. Terrain never casts: the shadow volume is fitted to all casters and a
raster layer keeps a global floor tier mounted, so 256 texels would span the
Earth.

**The bias.** `lift` defaults to `0` here, because glyphcss's derived,
slope-scaled acne guard is already correct and `lift` is only an *extra*
absolute length. glyphcss's own `0.05` default is 5% of the globe's radius — 318
km — and erases every shadow. Any nonzero `b` erases every caster standing less
than `b / sin(altitude)` above its receiver: the short buildings first, and worst
at a low sun.

**The direction is not an option.** Shadows fall along the scene's own
`directionalLight.direction`, whatever `getKeyLightDirection()` reports, so one
vector lights the scene and casts its shadows and the two can never disagree.

That last point has a hard consequence: **`keyLight: "headlight"` is incompatible
with visible shadows.** A headlight is the camera's view axis, and an
orthographic camera's screen position is the component *perpendicular* to that
axis, so displacing a caster along it moves its shadow zero columns and zero
rows — every shadow lands inside its own caster's cells.

Shadows are not base-grid only: one shadow map is built per frame from every
caster in the scene, so a layer separated by its own `density`, `renderMode` or
`glyphPalette` casts and receives like any other.

What still cannot **receive** is a `line` or `contour`. A stroke's colour is
written flat into the cell after shading, so a road is never darkened by the
building beside it. That is a known, documented inconsistency rather than a
tuning.

Cost is a flat +2.5 to +3.0 ms per render at 140×63 with buildings mounted, so it
costs most where the frame was cheapest. Shadows stay solid down to roughly 10°
of sun altitude; below that the edge dithers, and by ~5° it is a stipple that
loses about half its cells — on a contrast that is by then near-invisible anyway.

## Baking a static map

The widget is not the only consumer. The original pipeline is still there and
still pure: `source → sample → classify → compile`, producing a frozen `
`
with no JavaScript at all.

```ts
import { loadGlyphMapSource } from "@glyphcss/maps/node";
import {
  glyphMapBounds,
  sampleGlyphMapField,
  classifyGlyphMapField,
  compileGlyphMap,
  GlyphMapClassifiers,
} from "@glyphcss/maps";

const src = await loadGlyphMapSource({ path: "ETOPO1.asc.gz", id: "etopo1-2009" });
const view = glyphMapBounds({ west: 84.5, east: 86.5, south: 27.6, north: 28.8, cols: 140, rows: 48 });

const field = await sampleGlyphMapField(src, view, { sampler: "max" });
const bands = classifyGlyphMapField(field, GlyphMapClassifiers.etopo1V1);
const { html, css } = compileGlyphMap(bands, { ramp: " .:-=+*#%@", water: "~" });
```

`glyphMapBounds` is the constructor for this case: it carries the exact requested
box on `.bounds` rather than re-deriving one, because a one-shot bake wants
precisely the window it asked for.

The sampler is a real visual choice, not an implementation detail — `"max"` keeps
peaks and is usually best for terrain legibility, `"mean"` is stable under
panning but flattens summits, `"nearest"` aliases. Same source + same bounds +
same classifier + same sampler id gives byte-identical output on a given engine;
a callback sampler opts out, because a function has no id.

See the package README for the full static-bake reference, including
`buildGlyphMapArtifact` and the no-data rules.


---

# Quickstart

import { Tabs, TabItem } from '@astrojs/starlight/components';

## Install


  

```bash
npm install @glyphcss/react
```

  
  

```bash
npm install @glyphcss/vue
```

  
  

```bash
npm install glyphcss
```

  


## Hello cuboctahedron


  

```tsx
import { GlyphCamera, GlyphScene, GlyphOrbitControls, GlyphMesh, GlyphHotspot } from "@glyphcss/react";

export function App() {
  return (
    
      
        
        
           alert("vertex")} />
        
      
    
  );
}
```

  
  

```vue



```

  
  

```html



  
    
    
      
    
  

```

  


## What you get

- A single `
` rendered as ASCII glyphs — no canvas, no WebGL.
- Drag-to-orbit and scroll-to-zoom out of the box via ``.
- One absolutely-positioned `
` for each ``, re-projected through the same camera each render so it stays glued to the mesh. ## Try a Platonic solid ```tsx import { GlyphCamera, GlyphScene, GlyphOrbitControls, GlyphMesh } from "@glyphcss/react"; import { icosahedronPolygons } from "@glyphcss/core"; const icosa = icosahedronPolygons({ center: [0, 0, 0], size: 1, color: "#44ffcc" }); export function IcosahedronDemo() { return ( ); } ``` ```vue ``` ```ts import { createGlyphCamera, createGlyphScene } from "glyphcss"; import { icosahedronPolygons } from "@glyphcss/core"; const host = document.querySelector("#scene")!; const camera = createGlyphCamera({ rotX: 25, zoom: 50 }); const scene = createGlyphScene(host, { camera, mode: "solid", cols: 80, rows: 24 }); scene.add(icosahedronPolygons({ center: [0, 0, 0], size: 1, color: "#44ffcc" })); ``` Next: [Core Concepts](/core-concepts) to understand what's happening under the hood.