# 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 `<pre>.textContent`, **once**.

That last point is the invariant the whole renderer is built around: each render
cycle writes each `<pre>` exactly once. No cell-by-cell DOM patching, no
`matrix3d`, no per-polygon elements. A scene with per-mesh detail layers writes
the base `<pre>` 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" });

<GlyphMesh polygons={shape}>
  <GlyphHotspot at={[0, 1.2, 0]} onClick={...}>
    <span className="badge">Top</span>
  </GlyphHotspot>
</GlyphMesh>
```

Each hotspot becomes a real `<div>` 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)

`<pre>` is `display: block`, so `getBoundingClientRect().width` returns the
**container width**, not the character width. Always measure on a hidden
`<span style="display: inline-block">` instead. The probe should have the same
font properties as the `<pre>` (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.
