diff --git a/AGENTS.md b/AGENTS.md index 8be1d918..a3945d69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ Monorepo layout (pnpm workspaces): | Package | npm name | Role | |---|---|---| | `packages/core` | `@glyphcss/core` | Pure math: Vec3, Polygon, scene, camera, mesh ops, parsers. Zero browser globals. | -| `packages/glyphcss` | `glyphcss` | Vanilla renderer + custom elements (``, etc.). Owns the ASCII rasteriser, custom element definitions, and imperative API. | +| `packages/glyphcss` | `glyphcss` | Vanilla renderer + custom elements (``, etc.). Owns the ASCII rasteriser, custom element definitions, and imperative API. | | `packages/react` | `@glyphcss/react` | React components + hooks. Thin wrapper over core + glyphcss. | | `packages/vue` | `@glyphcss/vue` | Vue 3 mirror of the React package. | | `website` | `@glyphcss/website` | Astro + Starlight docs site. Not published. | @@ -53,15 +53,15 @@ Controls (orbit, map, first-person) mutate a single camera state object; the ras ## Naming -Every public export gets a `Glyphcss` prefix. Exceptions are generic math/geometry types: `Vec2`, `Vec3`, `Polygon`, `TextureTriangle`. +Every public export gets a `Glyph` prefix. Exceptions are generic math/geometry types: `Vec2`, `Vec3`, `Polygon`, `TextureTriangle`. -- **Hooks/composables:** `useGlyphcssCamera`, `useGlyphcssMesh`, `useGlyphcssSceneContext`, `useGlyphcssAnimation`. -- **Components:** `GlyphcssPerspectiveCamera`, `GlyphcssOrthographicCamera`, `GlyphcssOrbitControls`, `GlyphcssMapControls`, `GlyphcssFirstPersonControls`, `GlyphcssAxesHelper`, `GlyphcssDirectionalLightHelper`. -- **Types:** `GlyphcssDirectionalLight`, `GlyphcssAmbientLight`, `GlyphcssAnimationMixer`, `GlyphcssAnimationAction`, `GlyphcssAnimationClip`, `GlyphcssAnimationTarget`. -- **Functions:** `createGlyphcssAnimationMixer`, `injectGlyphcssBaseStyles`. -- **Vanilla factories:** `createGlyphcssScene`, `createGlyphcssCamera`, `createGlyphcssOrbitControls`, `createGlyphcssMapControls`, `createGlyphcssFirstPersonControls`. -- **HTML custom elements:** `glyphcss-` prefix + kebab-case. Existing tags: ``, ``, ``, ``, ``, ``, ``. Any new element follows the same shape. -- `GlyphcssCamera` is a kept alias for `GlyphcssPerspectiveCamera` — the ergonomic default. **Not deprecated.** +- **Hooks/composables:** `useGlyphCamera`, `useGlyphMesh`, `useGlyphSceneContext`, `useGlyphAnimation`. +- **Components:** `GlyphPerspectiveCamera`, `GlyphOrthographicCamera`, `GlyphOrbitControls`, `GlyphMapControls`, `GlyphFirstPersonControls`, `GlyphAxesHelper`, `GlyphDirectionalLightHelper`. +- **Types:** `GlyphDirectionalLight`, `GlyphAmbientLight`, `GlyphAnimationMixer`, `GlyphAnimationAction`, `GlyphAnimationClip`, `GlyphAnimationTarget`. +- **Functions:** `createGlyphAnimationMixer`, `injectGlyphBaseStyles`. +- **Vanilla factories:** `createGlyphScene`, `createGlyphCamera` (ortho alias), `createGlyphPerspectiveCamera`, `createGlyphOrthographicCamera`, `createGlyphOrbitControls`, `createGlyphMapControls`, `createGlyphFirstPersonControls`. +- **HTML custom elements:** `glyph-` prefix + kebab-case. Existing tags: ``, ``, ``, ``, ``, `` (ortho alias), ``, ``. Any new element follows the same shape. +- `GlyphCamera` is the ergonomic default alias — it resolves to `GlyphOrthographicCamera`. The voxel render mode and iso/diagrammatic scenes are glyphcss's differentiator; ortho is the more representative default. ## Cross-package discipline diff --git a/API_DESIGN.md b/API_DESIGN.md new file mode 100644 index 00000000..39c005ad --- /dev/null +++ b/API_DESIGN.md @@ -0,0 +1,780 @@ +# Glyph API design — unified shape across all four paths + +Single source of truth for what the **same scene** looks like across the four supported usage paths: vanilla JS, custom elements (HTML), React, Vue. If any path diverges from this shape, it's a bug. + +**This describes the target state.** A few API gaps remain relative to this design — see "Known drift" at the bottom. AGENTS.md / CLAUDE.md are updated alongside the implementation PR that closes each drift item. + +--- + +## Goal + +One mental model. A user who learns the React API can write the Vue, vanilla JS, and HTML versions without re-learning anything except idiom (camelCase vs kebab-case, function calls vs JSX). The polycss/voxcss family uses `Poly*`; glyph uses `Glyph*`; the rest of the design rhymes. + +## Tree shape + +``` +GlyphCamera +└── GlyphScene + ├── Controls (GlyphOrbitControls / GlyphMapControls / GlyphFirstPersonControls) — optional + ├── Helpers (GlyphAxesHelper / GlyphDirectionalLightHelper / GlyphGround) — optional + └── Content (GlyphMesh / hotspots) — one per node +``` + +**Why camera wraps scene:** matches the polycss/voxcss family. The glyph rasterizer doesn't need this for layout reasons — projection happens in JS, not in CSS — but mirroring the family means knowledge transfers across the three engines without re-learning composition. Same source of truth, same tree, different paint backend. + +## Camera taxonomy + +Two cameras, shared orbital state. + +| Name | Projection | When to pick | +|---|---|---| +| `GlyphOrthographicCamera` (alias `GlyphCamera`) | Parallel | **Default.** Iso/voxel/diagrammatic scenes. The voxel render mode and ASCII rasterizer favor flat depth. | +| `GlyphPerspectiveCamera` | Foreshortened | Character models, game-like scenes, first-person views. Depth is in world units (default `distance: 3`). | + +Shared state on every camera: `rotX`, `rotY` (radians), `target` (Vec3), `zoom` (fraction of `min(cols, rows)`). Perspective additionally has `distance` (world units). + +**Camera defines projection; controls define behavior.** "First-person view" is `GlyphPerspectiveCamera` + `GlyphFirstPersonControls`. No separate first-person camera, no cinematic camera. Controls own the FPV-specific configuration (eye-position offset, near-plane cull) on the same perspective camera. + +**Why ortho is the default:** glyph has a `voxel` render mode whose identity is iso/orthographic. Setting `GlyphCamera` to ortho aligns the most ergonomic name with the most distinctive mode. Diverges from three.js's `PerspectiveCamera`-as-default convention deliberately. + +**Rotations are in radians.** Diverges from voxcss (degrees). Glyph inherits the radians convention from the asciss rasterizer. + +## Controls taxonomy + +| Name | Behavior | +|---|---| +| `GlyphOrbitControls` | Drag/wheel to orbit/zoom around target. Supports `animate` for auto-rotation. | +| `GlyphMapControls` | Like orbit but pan plane is horizontal (Google-Maps-style). | +| `GlyphFirstPersonControls` | WASD + mouse-look. Configures the parent perspective camera for FPV. | + +**No `GlyphTransformControls`** — manipulator gizmos are deferred. Glyph's voxel/diagrammatic identity doesn't lean on per-object transform handles. + +## Render modes + +Glyph-specific. Lives on `` and equivalents. + +| Mode | How cells are filled | +|---|---| +| `wireframe` | Polygon edges rasterized as ASCII rules. `featureEdges` threshold trims flat-coplanar internal edges. | +| `solid` (default) | Filled cells; glyph picked from `glyphPalette` by Lambert-shaded intensity. | +| `voxel` | Cube-aligned geometry; face normals drive glyph selection. | + +--- + +## Minimal mesh — all four paths + +The same scene, every path. **If a path can't express this verbatim, it's a bug.** + +### Vanilla JS + +```js +import { createGlyphCamera, createGlyphScene, loadMesh } from "glyphcss"; + +const camera = createGlyphCamera({ rotX: 0.5, rotY: 0.4 }); +const scene = createGlyphScene(document.getElementById("app"), { camera }); + +const { polygons } = await loadMesh("/cottage.glb"); +scene.add(polygons); +``` + +### Custom elements (HTML) + +```html + + + + + + + +``` + +### React + +```tsx +import { GlyphCamera, GlyphScene, GlyphMesh } from "@glyphcss/react"; + + + + + + +``` + +### Vue 3 + +```vue + + + +``` + +--- + +## Minimal interactive scene (orbit controls) + +### Vanilla JS + +```js +import { + createGlyphCamera, createGlyphScene, createGlyphOrbitControls, loadMesh, +} from "glyphcss"; + +const camera = createGlyphCamera({ rotX: 0.5, rotY: 0.4 }); +const scene = createGlyphScene(host, { camera }); +createGlyphOrbitControls(scene, { drag: true, wheel: true }); + +const { polygons } = await loadMesh("/cottage.glb"); +scene.add(polygons); +``` + +### Custom elements + +```html + + + + + + +``` + +### React + +```tsx + + + + + + +``` + +### Vue + +```vue + + + + + + +``` + +--- + +## Manual polygons (no file) + +A mesh where geometry is defined inline as `Polygon[]` rather than loaded from a URL. The `Polygon` type lives in `@glyphcss/core`: + +```ts +interface Polygon { + vertices: Vec3[]; // N coplanar vertices, CCW from outside + color?: string; + uvs?: Vec2[]; + data?: Record; +} +``` + +The rasterizer fan-triangulates internally — author N-gons or triangles, both work. + +### Vanilla JS + +```js +const scene = createGlyphScene(host, { camera }); +scene.add([ + { vertices: [[0, 0, 0], [1, 0, 0], [0.5, 1, 0]], color: "#ff6644" }, +]); +``` + +### React + +`polygons` prop is mutually exclusive with `src` on ``. + +```tsx +const polygons = [ + { vertices: [[0, 0, 0], [1, 0, 0], [0.5, 1, 0]], color: "#ff6644" }, +]; + + + + + + +``` + +### Vue + +```vue + + + +``` + +### Custom elements + +Inline polygons on the elements path are **deferred** (not "do not implement" — just not in scope today). HTML attributes can't carry `Polygon[]` ergonomically: a JSON blob in one big attribute is ugly and a child `` element pulls the polygon authoring tree into the markup, which fights the "elements are for declarative composition, JS is for data" split. Use `` with a file, or set `mesh.polygons` from JS, until there's clear demand. + +### Built-in shape generators + +`@glyphcss/core` (re-exported from every wrapper) ships polygon factories. Every factory returns `Polygon[]` and slots into `scene.add()` directly. + +```js +import { cubePolygons, spherePolygons } from "@glyphcss/core"; +scene.add(cubePolygons({ size: 1, color: "#ff6644" })); +scene.add(spherePolygons({ subdivisions: 2, radius: 1, color: "#44aaff" })); +``` + +**Inventory (target).** Mirrors the polycss/voxcss factories where they exist, expanded with sphere + Archimedean / Catalan / Kepler-Poinsot families that polycss skips for DOM-cost reasons. + +- **Platonic (5):** `tetrahedronPolygons`, `cubePolygons`, `octahedronPolygons`, `dodecahedronPolygons`, `icosahedronPolygons`. +- **Kepler-Poinsot star polyhedra (4):** `smallStellatedDodecahedronPolygons`, `greatDodecahedronPolygons`, `greatStellatedDodecahedronPolygons`, `greatIcosahedronPolygons`. +- **Archimedean (13):** `truncatedTetrahedronPolygons`, `truncatedCubePolygons`, `truncatedOctahedronPolygons`, `truncatedDodecahedronPolygons`, `truncatedIcosahedronPolygons` (soccer ball), `truncatedCuboctahedronPolygons`, `truncatedIcosidodecahedronPolygons`, `cuboctahedronPolygons`, `icosidodecahedronPolygons`, `rhombicuboctahedronPolygons`, `rhombicosidodecahedronPolygons`, `snubCubePolygons`, `snubDodecahedronPolygons`. +- **Catalan duals (13):** `triakisTetrahedronPolygons`, `triakisOctahedronPolygons`, `triakisIcosahedronPolygons`, `tetrakisHexahedronPolygons`, `pentakisDodecahedronPolygons`, `rhombicDodecahedronPolygons`, `rhombicTriacontahedronPolygons`, `deltoidalIcositetrahedronPolygons`, `deltoidalHexecontahedronPolygons`, `disdyakisDodecahedronPolygons`, `disdyakisTriacontahedronPolygons`, `pentagonalIcositetrahedronPolygons`, `pentagonalHexecontahedronPolygons`. +- **Parametric polyhedral families (4):** `prismPolygons({ sides, radius, height })`, `antiprismPolygons(…)`, `bipyramidPolygons(…)`, `trapezohedronPolygons(…)`. +- **Round / parametric primitives (5):** `spherePolygons({ subdivisions, radius })` (icosphere), `cylinderPolygons(…)`, `conePolygons(…)`, `torusPolygons(…)`, `pyramidPolygons(…)`. +- **Helpers / utility (4):** `planePolygons`, `ringPolygons`, `ringQuadPolygons`, `arrowPolygons`, `axesHelperPolygons`. + +**Why sphere ships here but not in polycss/voxcss:** in polycss, each polygon is a DOM leaf — a 32×16 UV sphere costs 1024 DOM nodes per instance. Glyph rasterizes polygons into character cells in one pass, so an 80-triangle icosphere is no more expensive at render time than an 8-triangle octahedron. `spherePolygons` defaults to `subdivisions: 1` (an icosphere = 80 triangles) which is plenty for ASCII. + +**Johnson solids are not shipped.** 92 convex regular-faced polyhedra with no clean parametric form, mostly niche. Add on demand if a specific one comes up; don't preempt. + +**No primitive shape components** (``, ``, etc.) are shipped. The polygon factories cover the same need with one fewer abstraction layer. Reconsider if user demand appears. + +--- + +## First-person scene + +FPV needs perspective foreshortening, so this example uses the explicit `` rather than the default `` (which is orthographic). `GlyphFirstPersonControls` owns the WASD + mouse-look behavior and configures the camera's eye-position / near-plane handling internally. + +### Vanilla JS + +```js +import { + createGlyphPerspectiveCamera, createGlyphScene, + createGlyphFirstPersonControls, loadMesh, +} from "glyphcss"; + +const camera = createGlyphPerspectiveCamera(); +const scene = createGlyphScene(host, { camera }); +createGlyphFirstPersonControls(scene); +const { polygons } = await loadMesh("/world.glb"); +scene.add(polygons); +``` + +### Custom elements + +```html + + + + + + +``` + +### React / Vue + +```tsx + + + + + + +``` + +--- + +## Helpers + +Helpers are diagnostic / decorative scene children — they render polygons through the same `mesh` machinery but represent the scene's *own* state (axes, light direction, ground plane). + +| Component | What it shows | +|---|---| +| `GlyphAxesHelper` | World-space XYZ axes as colored arrows. Sized via `size` prop. | +| `GlyphDirectionalLightHelper` | An arrow at the world origin pointing along the scene's `directionalLight.direction`. Updates when the light moves. | +| `GlyphGround` | A planar `` parameterized by `size` / `color` — convenience over `planePolygons`. | + +```tsx + + + + + + + + + +``` + +Helpers are scene children, not props — they own a polygon list and register with the scene like any other mesh. Same shape across React / Vue / custom elements (``, etc.). Vanilla JS has no equivalent component wrapper; emit the same polygons directly via `scene.add(axesHelperPolygons({ size: 1 }))`. + +## Hotspots + +Hotspots are 3D anchors that produce 2D screen-space hitboxes in the consumer's DOM. They're glyph-specific — no equivalent in polycss/voxcss — because the rasterizer can project arbitrary world points to character-cell coordinates as a side effect of rendering. + +A hotspot consists of: +- A 3D `at: Vec3` position +- A unique `id` +- An optional `size: [cols, rows]` (in character cells) + +The rasterizer projects each hotspot through the camera every render and emits a `HotspotCell` (`col`, `row`, `depth`, `visible`). Consumers absolute-position a `
` over the cell — the rasterizer computes the position; it does **not** emit the DOM node. + +```tsx + + + + + Enter + + + +``` + +```html + +
Enter
+
+``` + +```js +const handle = scene.addHotspot({ id: "door", at: [0, 1, 0.5] }, () => openDoor()); +// handle.remove() to dispose +``` + +Hotspots auto-hide when their projected depth is occluded by mesh polygons (same depth buffer the rasterizer fills). + +## Animation + +glTF animation clips are decoded by `loadMesh` and exposed as an `AnimationController` on the parse result. The mixer is mounted independently of the scene so animation is opt-in. + +```tsx +import { useGlyphAnimation } from "@glyphcss/react"; + +const { polygons, animation } = await loadMesh("/character.glb"); +// inside a component: +useGlyphAnimation(animation, { clip: "Walk", speed: 1, loop: true }); +``` + +```vue + +``` + +```js +import { createGlyphAnimationMixer } from "glyphcss"; + +const { polygons, animation } = await loadMesh("/character.glb"); +const handle = scene.add(polygons); +const mixer = createGlyphAnimationMixer(animation, handle, { clip: "Walk", speed: 1, loop: true }); +// mixer.stop() to dispose +``` + +**No `` component.** Animation is an effect on an existing mesh, not a tree node. Hook/composable on React/Vue, factory on vanilla. Custom elements have no animation surface yet (deferred — would need attribute observers for `clip`, `speed`, `loop`). + +--- + +## Scene & mesh features + +### Feature placement (where does it live?) + +| Feature | Camera | Scene | Mesh | Controls | +|---|---|---|---|---| +| `rotX` / `rotY` (radians) | ✅ | — | — | — | +| `target` (Vec3) | ✅ | — | — | — | +| `zoom` | ✅ | — | — | — | +| `distance` | ✅ (perspective only) | — | — | — | +| `stretch` (horizontal cell-aspect override) | ✅ | — | — | — | +| Render `mode` (`wireframe` / `solid` / `voxel`) | — | ✅ | — | — | +| Grid `cols` / `rows` / `cellAspect` | — | ✅ | — | — | +| `glyphPalette` (named ramp) | — | ✅ | — | — | +| `useColors` (emit color spans) | — | ✅ | — | — | +| `lineHeight` | — | ✅ | — | — | +| `featureEdges` (wireframe threshold) | — | ✅ | — | — | +| `directionalLight` / `ambientLight` | — | ✅ | — | — | +| `autoCenter` (boolean) | — | ✅ (re-centers world bbox) | ✅ (re-centers polygons into mesh-local space) | — | +| `meshResolution` (`"lossless"` \| `"lossy"`) | — | — | ✅ (top-level prop) | — | +| Mesh transform (`position` / `scale` / `rotation`) | — | — | ✅ | — | +| `animate` (`{ speed, axis }` \| `false`) — auto-rotation | — | — | — | ✅ (orbit / map) | + +### Lights + +Object-shaped props on `` — not components. + +```ts +type GlyphDirectionalLight = { direction: Vec3; color?: string; intensity?: number }; +type GlyphAmbientLight = { color?: string; intensity?: number }; +``` + +```js +const scene = createGlyphScene(host, { + camera, + directionalLight: { direction: [0.45, 0.71, 0.54], color: "#ffffff", intensity: 1 }, + ambientLight: { color: "#ffffff", intensity: 0.4 }, +}); +``` + +```html + + … + +``` + +```tsx + +``` + +> **Note — no `` / `` components.** Lights are inputs to the rasterizer, not transformable scene-graph nodes. Object-shaped props are the right primitive here. Reconsider if multi-light support is added. + +### Mesh options — flat second arg on `scene.add()` + +`scene.add(polygons, options?)` takes a single options object combining **transform** and **per-mesh flags**. Flat shape (not nested) so each path can spread it cleanly. + +```ts +interface GlyphMeshOptions { + // Transform + position?: Vec3; + scale?: Vec3 | number; + rotation?: Vec3; // Euler radians, XYZ order + // Per-mesh flags + id?: string; + autoCenter?: boolean; + meshResolution?: "lossless" | "lossy"; +} +``` + +```js +scene.add(polygons, { + id: "cottage", + position: [0, 0, 0], + scale: 1.5, + rotation: [0, Math.PI / 4, 0], + autoCenter: true, +}); + +// Built-in geometry via resolveGeometry: +import { resolveGeometry } from "@glyphcss/core"; +scene.add(resolveGeometry("dodecahedron", { size: 1 })); +scene.add(resolveGeometry("torus", { size: 0.8, color: "#f97316" })); +``` + +On React / Vue, every field lifts to a `` prop: + +```tsx + +``` + +On custom elements, each lifts to an attribute (`position="0,0,0"`, `scale="1.5"`, `auto-center`, `mesh-resolution="lossless"`, etc.). + +### Geometry shortcut + +`` / `` accepts a `geometry` prop/attribute that resolves to a built-in polygon factory by name, eliminating the need to import the factory explicitly for common shapes. + +```ts +geometry?: GlyphGeometryName // / +size?: number // default 1 +``` + +Precedence: explicit `polygons` > `src` > `geometry`. When `src` and `geometry` are both supplied, `src` wins silently. + +**React / Vue:** + +```tsx + + +``` + +**Custom elements:** + +```html + + +``` + +**Vanilla JS** — call `resolveGeometry` directly from `@glyphcss/core`: + +```js +import { resolveGeometry } from "@glyphcss/core"; +scene.add(resolveGeometry("dodecahedron", { size: 1 })); +``` + +**`GlyphGeometryName` union** covers all 44 built-in factories: + +- Platonic (5): `tetrahedron`, `cube`, `octahedron`, `dodecahedron`, `icosahedron` +- Kepler-Poinsot (4): `smallStellatedDodecahedron`, `greatDodecahedron`, `greatStellatedDodecahedron`, `greatIcosahedron` +- Archimedean (13): `cuboctahedron`, `icosidodecahedron`, `truncatedTetrahedron`, `truncatedCube`, `truncatedOctahedron`, `truncatedDodecahedron`, `truncatedIcosahedron`, `truncatedCuboctahedron`, `truncatedIcosidodecahedron`, `rhombicuboctahedron`, `rhombicosidodecahedron`, `snubCube`, `snubDodecahedron` +- Catalan duals (13): `rhombicDodecahedron`, `rhombicTriacontahedron`, `triakisTetrahedron`, `triakisOctahedron`, `triakisIcosahedron`, `tetrakisHexahedron`, `pentakisDodecahedron`, `disdyakisDodecahedron`, `disdyakisTriacontahedron`, `deltoidalIcositetrahedron`, `deltoidalHexecontahedron`, `pentagonalIcositetrahedron`, `pentagonalHexecontahedron` +- Parametric families (4): `prism`, `antiprism`, `bipyramid`, `trapezohedron` +- Round / parametric primitives (5): `sphere`, `cylinder`, `cone`, `torus`, `pyramid` + +For parametric shapes (`cylinder`, `cone`, `torus`, `pyramid`, `prism`, `antiprism`, `bipyramid`, `trapezohedron`), the `size` field drives radius/height with reasonable defaults derived from a single scalar. When richer control is needed (e.g. different `majorRadius` and `minorRadius` on a torus), call the factory directly instead. + +### Auto center + +`autoCenter?: boolean` exists at **two** levels: + +- **Scene level** (option on `GlyphSceneOptions` / ``): translates the *world* so the bbox of all live meshes sits at origin. Camera orbits the model's visible center. +- **Mesh level** (option on `scene.add()` / ``): re-centers a mesh's polygons into mesh-local space. Useful for OBJ/GLB assets whose origin is at a corner / feet / arbitrary point. + +These are independent — both can be `true`. Default: both `false`. Catches the common "asset is way off-axis" failure mode without forcing users to compute a bbox by hand. + +### Mesh resolution + +Top-level `meshResolution?: "lossless" | "lossy"` prop on ``. + +- `"lossy"` (default) — bounded geometric approximation when it reduces polygon count. +- `"lossless"` — preserve the authored surface; only apply exact merges. + +```js +const { polygons } = await loadMesh("/cottage.glb", { meshResolution: "lossless" }); +scene.add(polygons); +``` + +```html + +``` + +```tsx + +``` + +### Auto rotation + +**There is no `autoRotate` prop on the scene or camera.** Auto-rotation is the `animate` option on `GlyphOrbitControls` / `GlyphMapControls`: + +```ts +animate?: { speed: number; axis?: "x" | "y"; pauseOnInteraction?: boolean } | false +``` + +```tsx + + + +``` + +```html + + +``` + +```js +createGlyphOrbitControls(scene, { animate: { speed: 0.3 } }); +``` + +### Camera target + +`target?: Vec3` on the camera. The orbital state rotates around this point. Default `[0, 0, 0]`. + +```tsx + +``` + +```html + +``` + +```js +const camera = createGlyphCamera({ rotX: 0.5, rotY: 0.4, target: [1, 0, 0] }); +``` + +Combined with scene-level `autoCenter`, the effective orbit pivot is `target + autoCenterOffset`. Users typically set either, not both. + +### Cross-path consistency matrix + +Per-path syntactic differences (camelCase vs kebab-case, JSX vs HTML) are expected. What must match: **field name root, value shape, default**. + +| Feature | Lives on | Field root | Value shape | Default | Vanilla JS | Custom elements | React / Vue | +|---|---|---|---|---|---|---|---| +| Camera rotation | Camera | `rotX`, `rotY` | `number` (radians) | `0`, `0` | `{ rotX: 0.5, rotY: 0.4 }` | `rot-x="0.5" rot-y="0.4"` | `rotX={0.5}` / `:rot-x="0.5"` | +| Camera target | Camera | `target` | `Vec3` | `[0,0,0]` | `{ target: [1,0,0] }` | `target="1,0,0"` | `target={[1,0,0]}` | +| Camera zoom | Camera | `zoom` | `number` | `0.4` | `{ zoom: 0.6 }` | `zoom="0.6"` | `zoom={0.6}` | +| Camera distance | Perspective camera | `distance` | `number` (world units) | `3` | `{ distance: 5 }` | `distance="5"` | `distance={5}` | +| Render mode | Scene | `mode` | `"wireframe" \| "solid" \| "voxel"` | `"solid"` | `{ mode: "voxel" }` | `mode="voxel"` | `mode="voxel"` | +| Grid cols / rows | Scene | `cols`, `rows` | `number` | `80`, `24` | `{ cols: 100, rows: 30 }` | `cols="100" rows="30"` | `cols={100}` | +| Cell aspect | Scene | `cellAspect` | `number` | `2.0` | `{ cellAspect: 2 }` | `cell-aspect="2"` | `cellAspect={2}` | +| Glyph palette | Scene | `glyphPalette` | `string` | `"default"` | `{ glyphPalette: "blocks" }` | `glyph-palette="blocks"` | `glyphPalette="blocks"` | +| Use colors | Scene | `useColors` | `boolean` | `true` | `{ useColors: false }` | `use-colors="false"` | `useColors={false}` | +| Line height | Scene | `lineHeight` | `number` | `1` | `{ lineHeight: 1.1 }` | `line-height="1.1"` | `lineHeight={1.1}` | +| Feature edges (wireframe) | Scene | `featureEdges` | `number` (radians) | `0` | `{ featureEdges: 0.3 }` | `feature-edges="0.3"` | `featureEdges={0.3}` | +| Directional light | Scene | `directionalLight` | `{ direction: Vec3; color?: string; intensity?: number }` | (none) | object option | JSON attr `directional-light='{…}'` | object prop | +| Ambient light | Scene | `ambientLight` | `{ color?: string; intensity?: number }` | (none) | object option | JSON attr `ambient-light='{…}'` | object prop | +| Auto center (scene) | Scene | `autoCenter` | `boolean` | `false` | `{ autoCenter: true }` | bare attr `auto-center` | bare prop | +| Auto center (mesh) | Mesh | `autoCenter` | `boolean` | `false` | `scene.add(p, { autoCenter: true })` | bare attr `auto-center` | bare prop | +| Mesh resolution | Mesh | `meshResolution` | `"lossless" \| "lossy"` | `"lossy"` | `loadMesh(url, { meshResolution })` | `mesh-resolution="lossless"` | `meshResolution="lossless"` | +| Mesh position | Mesh | `position` | `Vec3` | `[0,0,0]` | `scene.add(p, { position: [1,0,0] })` | `position="1,0,0"` | `position={[1,0,0]}` | +| Mesh scale | Mesh | `scale` | `Vec3 \| number` | `1` | `scene.add(p, { scale: 1.5 })` | `scale="1.5"` | `scale={1.5}` | +| Mesh rotation | Mesh | `rotation` | `Vec3` (Euler radians, XYZ) | `[0,0,0]` | `scene.add(p, { rotation: [0,1,0] })` | `rotation="0,1,0"` | `rotation={[0,1,0]}` | +| Mesh id | Mesh | `id` | `string` | (none) | `scene.add(p, { id: "cottage" })` | `id="cottage"` | `id="cottage"` | +| Auto-rotate (orbit / map) | Controls | `animate` | `{ speed: number; axis?: "x"\|"y"; pauseOnInteraction?: boolean } \| false` | (off) | `{ animate: { speed: 0.3 } }` | flat attrs `animate-speed="0.3"` `animate-axis="x"` | object prop | +| Geometry shortcut | Mesh | `geometry` | `GlyphGeometryName` | (none) | `scene.add(resolveGeometry("dodecahedron"))` | `geometry="dodecahedron"` | `geometry="dodecahedron"` | +| Geometry size | Mesh | `size` | `number` | `1` | `resolveGeometry("dodecahedron", { size: 1.5 })` | `size="1.5"` | `size={1.5}` | +| Hotspot anchor | Hotspot | `at` | `Vec3` | (required) | `scene.addHotspot({ at: [0,1,0] })` | `at="0,1,0"` | `at={[0,1,0]}` | + +**Nested-object props on custom elements** follow the same split as voxcss: + +- **Settings-shaped** (lights) → **JSON-stringified attribute**. +- **Behavior-shaped with "boolean + tuning"** (`animate`) → **flat attributes** (`animate-speed`, `animate-axis`). + +No further nesting conventions — if a new feature needs a nested object, pick one of these two patterns to match its character. + +--- + +## Per-path naming conventions + +| Concept | Vanilla JS | Custom elements | React | Vue (template) | +|---|---|---|---|---| +| Camera (default = ortho) | `createGlyphCamera(opts)` | `` | `` | `` | +| Scene | `createGlyphScene(host, opts)` | `` | `` | `` | +| Mesh (file) | `scene.add((await loadMesh(url)).polygons)` | `` | `` | `` | +| Mesh (inline) | `scene.add(polygons)` | (deferred) | `` | `` | +| Mesh options | `scene.add(p, opts)` second arg | attributes on `` | props on `` | props on `` | +| Hotspot | `scene.addHotspot(opts, onClick)` | `` | `` | `` | +| Animation | `createGlyphAnimationMixer(…)` | (deferred) | `useGlyphAnimation(…)` | `useGlyphAnimation(…)` | +| Prop casing | camelCase (`rotX`) | kebab-case (`rot-x`) | camelCase (`rotX`) | kebab-case in template (`:rot-x`), camelCase in ` + + + + + + + + + + + diff --git a/examples/html/hotspot/index.html b/examples/html/hotspot/index.html new file mode 100644 index 00000000..b9a49c0f --- /dev/null +++ b/examples/html/hotspot/index.html @@ -0,0 +1,37 @@ + + + + + + hotspot — glyphcss HTML + + + + + + + + + +
corner
+
+
+
+ + + diff --git a/examples/html/index.html b/examples/html/index.html new file mode 100644 index 00000000..b7b20727 --- /dev/null +++ b/examples/html/index.html @@ -0,0 +1,24 @@ + + + + + + glyphcss — HTML custom-element examples + + + +

glyphcss — HTML (custom elements)

+ + + diff --git a/examples/html/package.json b/examples/html/package.json new file mode 100644 index 00000000..6fd6c039 --- /dev/null +++ b/examples/html/package.json @@ -0,0 +1,18 @@ +{ + "name": "@glyphcss/examples-html", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port 6174", + "build": "vite build", + "preview": "vite preview --port 6174" + }, + "dependencies": { + "glyphcss": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.3.3", + "vite": "^6.0.0" + } +} diff --git a/examples/html/public/apple.glb b/examples/html/public/apple.glb new file mode 100644 index 00000000..51cc4dc2 Binary files /dev/null and b/examples/html/public/apple.glb differ diff --git a/examples/html/solid-mesh/index.html b/examples/html/solid-mesh/index.html new file mode 100644 index 00000000..8c97d6db --- /dev/null +++ b/examples/html/solid-mesh/index.html @@ -0,0 +1,22 @@ + + + + + + solid-mesh — glyphcss HTML + + + + + + + + + + + + diff --git a/examples/html/tsconfig.json b/examples/html/tsconfig.json new file mode 100644 index 00000000..18f54623 --- /dev/null +++ b/examples/html/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "lib": ["ES2020", "DOM"] + }, + "include": ["**/*.ts"] +} diff --git a/examples/html/vite.config.ts b/examples/html/vite.config.ts new file mode 100644 index 00000000..c572b83c --- /dev/null +++ b/examples/html/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import { resolve } from "path"; + +export default defineConfig({ + build: { + rollupOptions: { + input: { + main: resolve(__dirname, "index.html"), + "baked-shapes": resolve(__dirname, "baked-shapes/index.html"), + "solid-mesh": resolve(__dirname, "solid-mesh/index.html"), + hotspot: resolve(__dirname, "hotspot/index.html"), + }, + }, + }, +}); diff --git a/examples/react/baked-shapes/index.html b/examples/react/baked-shapes/index.html new file mode 100644 index 00000000..91241650 --- /dev/null +++ b/examples/react/baked-shapes/index.html @@ -0,0 +1,13 @@ + + + + + + baked-shapes — glyphcss React + + + +
+ + + diff --git a/examples/react/baked-shapes/main.tsx b/examples/react/baked-shapes/main.tsx new file mode 100644 index 00000000..c11ea5d2 --- /dev/null +++ b/examples/react/baked-shapes/main.tsx @@ -0,0 +1,20 @@ +import { createRoot } from "react-dom/client"; +import { + GlyphPerspectiveCamera, + GlyphScene, + GlyphOrbitControls, + GlyphMesh, +} from "@glyphcss/react"; + +function App() { + return ( + + + + + + + ); +} + +createRoot(document.getElementById("root")!).render(); diff --git a/examples/react/hotspot/index.html b/examples/react/hotspot/index.html new file mode 100644 index 00000000..e88760c5 --- /dev/null +++ b/examples/react/hotspot/index.html @@ -0,0 +1,13 @@ + + + + + + hotspot — glyphcss React + + + +
+ + + diff --git a/examples/react/hotspot/main.tsx b/examples/react/hotspot/main.tsx new file mode 100644 index 00000000..ac0ac03b --- /dev/null +++ b/examples/react/hotspot/main.tsx @@ -0,0 +1,26 @@ +import { createRoot } from "react-dom/client"; +import { + GlyphPerspectiveCamera, + GlyphScene, + GlyphOrbitControls, + GlyphMesh, + GlyphHotspot, +} from "@glyphcss/react"; + +function App() { + return ( + + + + + alert("corner")}> + + corner + + + + + ); +} + +createRoot(document.getElementById("root")!).render(); diff --git a/examples/react/index.html b/examples/react/index.html new file mode 100644 index 00000000..2d2c009f --- /dev/null +++ b/examples/react/index.html @@ -0,0 +1,24 @@ + + + + + + glyphcss — React examples + + + +

glyphcss — React examples

+ + + diff --git a/examples/react/package.json b/examples/react/package.json new file mode 100644 index 00000000..333b9077 --- /dev/null +++ b/examples/react/package.json @@ -0,0 +1,24 @@ +{ + "name": "@glyphcss/examples-react", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port 6175", + "build": "vite build", + "preview": "vite preview --port 6175" + }, + "dependencies": { + "@glyphcss/core": "workspace:*", + "@glyphcss/react": "workspace:*", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.0.0", + "typescript": "^5.3.3", + "vite": "^6.0.0" + } +} diff --git a/examples/react/public/apple.glb b/examples/react/public/apple.glb new file mode 100644 index 00000000..51cc4dc2 Binary files /dev/null and b/examples/react/public/apple.glb differ diff --git a/examples/react/solid-mesh/index.html b/examples/react/solid-mesh/index.html new file mode 100644 index 00000000..a0063d39 --- /dev/null +++ b/examples/react/solid-mesh/index.html @@ -0,0 +1,13 @@ + + + + + + solid-mesh — glyphcss React + + + +
+ + + diff --git a/examples/react/solid-mesh/main.tsx b/examples/react/solid-mesh/main.tsx new file mode 100644 index 00000000..60e70eca --- /dev/null +++ b/examples/react/solid-mesh/main.tsx @@ -0,0 +1,54 @@ +import { createRoot } from "react-dom/client"; +import { useState, useEffect } from "react"; +import { + GlyphPerspectiveCamera, + GlyphScene, + GlyphOrbitControls, + GlyphMesh, + loadMesh, +} from "@glyphcss/react"; +import { computeSceneBbox } from "@glyphcss/react"; +import type { Polygon, Vec3 } from "@glyphcss/react"; + +const GLB_URL = "/apple.glb"; + +/** Center and scale polygons to fit a 2-unit bounding box at origin. */ +function fitToUnitBbox(polygons: Polygon[]): Polygon[] { + const bbox = computeSceneBbox(polygons); + const cx = (bbox.min[0] + bbox.max[0]) / 2; + const cy = (bbox.min[1] + bbox.max[1]) / 2; + const cz = (bbox.min[2] + bbox.max[2]) / 2; + const size = Math.max( + bbox.max[0] - bbox.min[0], + bbox.max[1] - bbox.min[1], + bbox.max[2] - bbox.min[2], + ) || 1; + const k = 2 / size; + return polygons.map((p) => ({ + ...p, + vertices: p.vertices.map((v): Vec3 => [ + (v[0] - cx) * k, + (v[1] - cy) * k, + (v[2] - cz) * k, + ]), + })); +} + +function App() { + const [polygons, setPolygons] = useState(); + + useEffect(() => { + loadMesh(GLB_URL).then((result) => setPolygons(fitToUnitBbox(result.polygons))); + }, []); + + return ( + + + + {polygons && } + + + ); +} + +createRoot(document.getElementById("root")!).render(); diff --git a/examples/react/tsconfig.json b/examples/react/tsconfig.json new file mode 100644 index 00000000..17df2e2f --- /dev/null +++ b/examples/react/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "react-jsx", + "lib": ["ES2020", "DOM"] + }, + "include": ["**/*.ts", "**/*.tsx"] +} diff --git a/examples/react/vite.config.ts b/examples/react/vite.config.ts new file mode 100644 index 00000000..e47b1a24 --- /dev/null +++ b/examples/react/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { resolve } from "path"; + +export default defineConfig({ + plugins: [react()], + build: { + rollupOptions: { + input: { + main: resolve(__dirname, "index.html"), + "baked-shapes": resolve(__dirname, "baked-shapes/index.html"), + "solid-mesh": resolve(__dirname, "solid-mesh/index.html"), + hotspot: resolve(__dirname, "hotspot/index.html"), + }, + }, + }, +}); diff --git a/examples/vanilla/baked-shapes/index.html b/examples/vanilla/baked-shapes/index.html new file mode 100644 index 00000000..2ab09b50 --- /dev/null +++ b/examples/vanilla/baked-shapes/index.html @@ -0,0 +1,16 @@ + + + + + + baked-shapes — glyphcss vanilla + + + +
+ + + diff --git a/examples/vanilla/baked-shapes/main.ts b/examples/vanilla/baked-shapes/main.ts new file mode 100644 index 00000000..4618a6ae --- /dev/null +++ b/examples/vanilla/baked-shapes/main.ts @@ -0,0 +1,13 @@ +import { + createGlyphPerspectiveCamera, + createGlyphScene, + createGlyphOrbitControls, + dodecahedronPolygons, +} from "glyphcss"; + +const host = document.getElementById("host")!; +const camera = createGlyphPerspectiveCamera({ rotX: 0.5, rotY: 0.4, zoom: 0.4, distance: 100 }); +const scene = createGlyphScene(host, { camera, autoSize: true }); + +createGlyphOrbitControls(scene, { drag: true, wheel: true }); +scene.add(dodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#ff6644" })); diff --git a/examples/vanilla/hotspot/index.html b/examples/vanilla/hotspot/index.html new file mode 100644 index 00000000..8e96629f --- /dev/null +++ b/examples/vanilla/hotspot/index.html @@ -0,0 +1,16 @@ + + + + + + hotspot — glyphcss vanilla + + + +
+ + + diff --git a/examples/vanilla/hotspot/main.ts b/examples/vanilla/hotspot/main.ts new file mode 100644 index 00000000..104bda49 --- /dev/null +++ b/examples/vanilla/hotspot/main.ts @@ -0,0 +1,36 @@ +import { + createGlyphPerspectiveCamera, + createGlyphScene, + createGlyphOrbitControls, + cubePolygons, +} from "glyphcss"; + +const host = document.getElementById("host")!; +const camera = createGlyphPerspectiveCamera({ rotX: 1.13, rotY: 0.785, zoom: 0.25, distance: 100 }); +const scene = createGlyphScene(host, { camera, autoSize: true }); + +createGlyphOrbitControls(scene, { drag: true, wheel: true }); +scene.add(cubePolygons({ center: [0, 0, 0], size: 1, color: "#4488ff" })); + +// Cube edge length = 1 → corners at ±0.5. Anchor the hotspot at the +Z top corner. +const handle = scene.addHotspot({ id: "corner", at: [-0.5, -0.5, 0.5] }, () => alert("corner")); + +// Style the hotspot overlay div once the scene has mounted. +// Use individual style assignments (not cssText) so the positioned left/top +// and transform set by updateHotspots() are never clobbered. +const observer = new MutationObserver(() => { + const el = host.querySelector(`[data-hotspot-id="corner"]`) as HTMLElement | null; + if (!el) return; + observer.disconnect(); + el.style.background = "#ff6644"; + el.style.color = "#fff"; + el.style.padding = "2px 6px"; + el.style.borderRadius = "4px"; + el.style.fontSize = "12px"; + el.style.whiteSpace = "nowrap"; + el.textContent = "corner"; +}); +observer.observe(host, { childList: true, subtree: true }); + +// Expose for devtools inspection. +(window as unknown as Record).__hotspotHandle = handle; diff --git a/examples/vanilla/index.html b/examples/vanilla/index.html new file mode 100644 index 00000000..2db80893 --- /dev/null +++ b/examples/vanilla/index.html @@ -0,0 +1,24 @@ + + + + + + glyphcss — vanilla examples + + + +

glyphcss — vanilla (imperative API)

+ + + diff --git a/examples/vanilla/package.json b/examples/vanilla/package.json new file mode 100644 index 00000000..33d68ef9 --- /dev/null +++ b/examples/vanilla/package.json @@ -0,0 +1,19 @@ +{ + "name": "@glyphcss/examples-vanilla", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port 6173", + "build": "vite build", + "preview": "vite preview --port 6173" + }, + "dependencies": { + "@glyphcss/core": "workspace:*", + "glyphcss": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.3.3", + "vite": "^6.0.0" + } +} diff --git a/examples/vanilla/public/apple.glb b/examples/vanilla/public/apple.glb new file mode 100644 index 00000000..51cc4dc2 Binary files /dev/null and b/examples/vanilla/public/apple.glb differ diff --git a/examples/vanilla/solid-mesh/index.html b/examples/vanilla/solid-mesh/index.html new file mode 100644 index 00000000..6589232a --- /dev/null +++ b/examples/vanilla/solid-mesh/index.html @@ -0,0 +1,16 @@ + + + + + + solid-mesh — glyphcss vanilla + + + +
+ + + diff --git a/examples/vanilla/solid-mesh/main.ts b/examples/vanilla/solid-mesh/main.ts new file mode 100644 index 00000000..a8473d6d --- /dev/null +++ b/examples/vanilla/solid-mesh/main.ts @@ -0,0 +1,40 @@ +import { + createGlyphPerspectiveCamera, + createGlyphScene, + createGlyphOrbitControls, + loadMesh, + computeSceneBbox, +} from "glyphcss"; +import type { Polygon, Vec3 } from "glyphcss"; + +/** Center and scale polygons to fit a 2-unit bounding box at origin. */ +function fitToUnitBbox(polygons: Polygon[]): Polygon[] { + const bbox = computeSceneBbox(polygons); + const cx = (bbox.min[0] + bbox.max[0]) / 2; + const cy = (bbox.min[1] + bbox.max[1]) / 2; + const cz = (bbox.min[2] + bbox.max[2]) / 2; + const size = Math.max( + bbox.max[0] - bbox.min[0], + bbox.max[1] - bbox.min[1], + bbox.max[2] - bbox.min[2], + ) || 1; + const k = 2 / size; + return polygons.map((p) => ({ + ...p, + vertices: p.vertices.map((v): Vec3 => [ + (v[0] - cx) * k, + (v[1] - cy) * k, + (v[2] - cz) * k, + ]), + })); +} + +const host = document.getElementById("host")!; +const camera = createGlyphPerspectiveCamera({ rotX: 0.5, rotY: 0.4, zoom: 0.35, distance: 100 }); +const scene = createGlyphScene(host, { camera, autoSize: true }); + +createGlyphOrbitControls(scene, { drag: true, wheel: true }); + +loadMesh("/apple.glb").then((result) => { + scene.add(fitToUnitBbox(result.polygons)); +}); diff --git a/examples/vanilla/tsconfig.json b/examples/vanilla/tsconfig.json new file mode 100644 index 00000000..18f54623 --- /dev/null +++ b/examples/vanilla/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "lib": ["ES2020", "DOM"] + }, + "include": ["**/*.ts"] +} diff --git a/examples/vanilla/vite.config.ts b/examples/vanilla/vite.config.ts new file mode 100644 index 00000000..c572b83c --- /dev/null +++ b/examples/vanilla/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import { resolve } from "path"; + +export default defineConfig({ + build: { + rollupOptions: { + input: { + main: resolve(__dirname, "index.html"), + "baked-shapes": resolve(__dirname, "baked-shapes/index.html"), + "solid-mesh": resolve(__dirname, "solid-mesh/index.html"), + hotspot: resolve(__dirname, "hotspot/index.html"), + }, + }, + }, +}); diff --git a/examples/vue/baked-shapes/App.vue b/examples/vue/baked-shapes/App.vue new file mode 100644 index 00000000..59527a77 --- /dev/null +++ b/examples/vue/baked-shapes/App.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/examples/vue/baked-shapes/index.html b/examples/vue/baked-shapes/index.html new file mode 100644 index 00000000..a92647ec --- /dev/null +++ b/examples/vue/baked-shapes/index.html @@ -0,0 +1,13 @@ + + + + + + baked-shapes — glyphcss Vue + + + +
+ + + diff --git a/examples/vue/baked-shapes/main.ts b/examples/vue/baked-shapes/main.ts new file mode 100644 index 00000000..b670de8b --- /dev/null +++ b/examples/vue/baked-shapes/main.ts @@ -0,0 +1,4 @@ +import { createApp } from "vue"; +import App from "./App.vue"; + +createApp(App).mount("#app"); diff --git a/examples/vue/hotspot/App.vue b/examples/vue/hotspot/App.vue new file mode 100644 index 00000000..68874751 --- /dev/null +++ b/examples/vue/hotspot/App.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/examples/vue/hotspot/index.html b/examples/vue/hotspot/index.html new file mode 100644 index 00000000..92f6e976 --- /dev/null +++ b/examples/vue/hotspot/index.html @@ -0,0 +1,13 @@ + + + + + + hotspot — glyphcss Vue + + + +
+ + + diff --git a/examples/vue/hotspot/main.ts b/examples/vue/hotspot/main.ts new file mode 100644 index 00000000..b670de8b --- /dev/null +++ b/examples/vue/hotspot/main.ts @@ -0,0 +1,4 @@ +import { createApp } from "vue"; +import App from "./App.vue"; + +createApp(App).mount("#app"); diff --git a/examples/vue/index.html b/examples/vue/index.html new file mode 100644 index 00000000..b7a907fe --- /dev/null +++ b/examples/vue/index.html @@ -0,0 +1,24 @@ + + + + + + glyphcss — Vue examples + + + +

glyphcss — Vue examples

+ + + diff --git a/examples/vue/package.json b/examples/vue/package.json new file mode 100644 index 00000000..995e0bc3 --- /dev/null +++ b/examples/vue/package.json @@ -0,0 +1,21 @@ +{ + "name": "@glyphcss/examples-vue", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port 6176", + "build": "vite build", + "preview": "vite preview --port 6176" + }, + "dependencies": { + "@glyphcss/core": "workspace:*", + "@glyphcss/vue": "workspace:*", + "vue": "^3.5.12" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.3.3", + "vite": "^6.0.0" + } +} diff --git a/examples/vue/public/apple.glb b/examples/vue/public/apple.glb new file mode 100644 index 00000000..51cc4dc2 Binary files /dev/null and b/examples/vue/public/apple.glb differ diff --git a/examples/vue/solid-mesh/App.vue b/examples/vue/solid-mesh/App.vue new file mode 100644 index 00000000..d569ae44 --- /dev/null +++ b/examples/vue/solid-mesh/App.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/examples/vue/solid-mesh/index.html b/examples/vue/solid-mesh/index.html new file mode 100644 index 00000000..b4acba08 --- /dev/null +++ b/examples/vue/solid-mesh/index.html @@ -0,0 +1,13 @@ + + + + + + solid-mesh — glyphcss Vue + + + +
+ + + diff --git a/examples/vue/solid-mesh/main.ts b/examples/vue/solid-mesh/main.ts new file mode 100644 index 00000000..b670de8b --- /dev/null +++ b/examples/vue/solid-mesh/main.ts @@ -0,0 +1,4 @@ +import { createApp } from "vue"; +import App from "./App.vue"; + +createApp(App).mount("#app"); diff --git a/examples/vue/tsconfig.json b/examples/vue/tsconfig.json new file mode 100644 index 00000000..506c5712 --- /dev/null +++ b/examples/vue/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "lib": ["ES2020", "DOM"] + }, + "include": ["**/*.ts", "**/*.vue"] +} diff --git a/examples/vue/vite.config.ts b/examples/vue/vite.config.ts new file mode 100644 index 00000000..14a7aaaa --- /dev/null +++ b/examples/vue/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import { resolve } from "path"; + +export default defineConfig({ + plugins: [vue()], + build: { + rollupOptions: { + input: { + main: resolve(__dirname, "index.html"), + "baked-shapes": resolve(__dirname, "baked-shapes/index.html"), + "solid-mesh": resolve(__dirname, "solid-mesh/index.html"), + hotspot: resolve(__dirname, "hotspot/index.html"), + }, + }, + }, +}); diff --git a/packages/core/README.md b/packages/core/README.md index 3f5ec253..343fe8d7 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -29,8 +29,8 @@ npm install @glyphcss/core | `Vec2` | `[number, number]`: 2D point or UV coordinate | | `Vec3` | `[number, number, number]`: 3D point or direction | | `Polygon` | Single renderable polygon: `vertices`, optional `color`, `texture`, `uvs`, `data` | -| `GlyphcssDirectionalLight` | Directional light: `direction`, optional `color`, optional `intensity` | -| `GlyphcssAmbientLight` | Ambient fill light: optional `color`, optional `intensity` | +| `GlyphDirectionalLight` | Directional light: `direction`, optional `color`, optional `intensity` | +| `GlyphAmbientLight` | Ambient fill light: optional `color`, optional `intensity` | | `ParseResult` | Unified parser return: `polygons`, `objectUrls`, `dispose()`, `warnings` | | `ObjParseOptions` | Options for `parseObj` | | `GltfParseOptions` | Options for `parseGltf` | diff --git a/packages/core/src/animation/animation.test.ts b/packages/core/src/animation/animation.test.ts index 13367d1f..a15b47ed 100644 --- a/packages/core/src/animation/animation.test.ts +++ b/packages/core/src/animation/animation.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { - createGlyphcssAnimationMixer, + createGlyphAnimationMixer, LoopOnce, LoopRepeat, LoopPingPong, } from "./index"; import type { - GlyphcssAnimationTarget, - GlyphcssAnimationAction, + GlyphAnimationTarget, + GlyphAnimationAction, } from "./index"; import type { ParseAnimationController, ParseAnimationClip } from "../parser/types"; import type { Polygon } from "../types"; @@ -31,7 +31,7 @@ function makeController( }; } -function makeTarget(): GlyphcssAnimationTarget & { calls: Polygon[][] } { +function makeTarget(): GlyphAnimationTarget & { calls: Polygon[][] } { const calls: Polygon[][] = []; return { calls, @@ -47,13 +47,13 @@ describe("loop mode constants", () => { it("LoopPingPong is 2202", () => expect(LoopPingPong).toBe(2202)); }); -// ── createGlyphcssAnimationMixer basic lifecycle ────────────────────────────────── +// ── createGlyphAnimationMixer basic lifecycle ────────────────────────────────── -describe("createGlyphcssAnimationMixer", () => { +describe("createGlyphAnimationMixer", () => { it("returns mixer with expected methods", () => { const ctrl = makeController([makeClip(0, "run")]); const target = makeTarget(); - const mixer = createGlyphcssAnimationMixer(target, ctrl); + const mixer = createGlyphAnimationMixer(target, ctrl); expect(typeof mixer.clipAction).toBe("function"); expect(typeof mixer.existingAction).toBe("function"); expect(typeof mixer.update).toBe("function"); @@ -64,14 +64,14 @@ describe("createGlyphcssAnimationMixer", () => { it("clipAction by name returns an action", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); expect(action).toBeDefined(); }); it("clipAction by index returns the same instance as by name", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const byName = mixer.clipAction("run"); const byIndex = mixer.clipAction(0); expect(byName).toBe(byIndex); @@ -79,37 +79,37 @@ describe("createGlyphcssAnimationMixer", () => { it("existingAction returns null before clip is instantiated", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); expect(mixer.existingAction("run")).toBeNull(); }); it("existingAction returns the action after clipAction is called", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); expect(mixer.existingAction("run")).toBe(action); }); it("clipAction throws for unknown clip name", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); expect(() => mixer.clipAction("nonexistent")).toThrow(); }); }); // ── Action lifecycle ────────────────────────────────────────────────────────── -describe("GlyphcssAnimationAction lifecycle", () => { +describe("GlyphAnimationAction lifecycle", () => { it("is not running after creation", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); expect(action.isRunning).toBe(false); }); it("play() sets isRunning = true", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.play(); expect(action.isRunning).toBe(true); @@ -117,7 +117,7 @@ describe("GlyphcssAnimationAction lifecycle", () => { it("stop() resets time to 0 and sets isRunning = false", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.play(); action.time = 0.5; @@ -128,7 +128,7 @@ describe("GlyphcssAnimationAction lifecycle", () => { it("reset() sets time to 0 without stopping", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.play(); action.time = 0.5; @@ -139,14 +139,14 @@ describe("GlyphcssAnimationAction lifecycle", () => { it("play() returns `this` for chaining", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); expect(action.play()).toBe(action); }); it("stop() returns `this` for chaining", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); expect(action.stop()).toBe(action); }); @@ -158,7 +158,7 @@ describe("mixer.update() applies polygons", () => { it("does not call setPolygons when no actions are running", () => { const ctrl = makeController([makeClip(0, "run")]); const target = makeTarget(); - const mixer = createGlyphcssAnimationMixer(target, ctrl); + const mixer = createGlyphAnimationMixer(target, ctrl); mixer.clipAction("run"); // instantiate but don't play mixer.update(0.1); expect(target.calls.length).toBe(0); @@ -167,7 +167,7 @@ describe("mixer.update() applies polygons", () => { it("calls setPolygons when an action is playing", () => { const ctrl = makeController([makeClip(0, "run")]); const target = makeTarget(); - const mixer = createGlyphcssAnimationMixer(target, ctrl); + const mixer = createGlyphAnimationMixer(target, ctrl); mixer.clipAction("run").play(); mixer.update(0.1); expect(target.calls.length).toBe(1); @@ -177,7 +177,7 @@ describe("mixer.update() applies polygons", () => { const sampled: Polygon[] = [TRI_A]; const ctrl = makeController([makeClip(0, "run")], () => sampled); const target = makeTarget(); - const mixer = createGlyphcssAnimationMixer(target, ctrl); + const mixer = createGlyphAnimationMixer(target, ctrl); mixer.clipAction("run").play(); mixer.update(0.1); expect(target.calls[0]).toBe(sampled); @@ -186,7 +186,7 @@ describe("mixer.update() applies polygons", () => { it("advances time with update()", () => { const ctrl = makeController([makeClip(0, "run", 2)]); const target = makeTarget(); - const mixer = createGlyphcssAnimationMixer(target, ctrl); + const mixer = createGlyphAnimationMixer(target, ctrl); const action = mixer.clipAction("run"); action.play(); mixer.update(0.5); @@ -205,7 +205,7 @@ describe("mixer.update() applies polygons", () => { return [TRI_A]; }, }; - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); mixer.clipAction("run").play(); mixer.update(0.4); expect(sampleTimes[0]).toBeCloseTo(0.4); @@ -218,7 +218,7 @@ describe("LoopOnce", () => { it("stops after one full duration", () => { const ctrl = makeController([makeClip(0, "run", 1)]); const target = makeTarget(); - const mixer = createGlyphcssAnimationMixer(target, ctrl); + const mixer = createGlyphAnimationMixer(target, ctrl); const action = mixer.clipAction("run"); action.setLoop(LoopOnce, 1).play(); mixer.update(1.1); @@ -227,7 +227,7 @@ describe("LoopOnce", () => { it("clamps time to duration when clampWhenFinished is true", () => { const ctrl = makeController([makeClip(0, "run", 1)]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.setLoop(LoopOnce, 1); action.clampWhenFinished = true; @@ -239,7 +239,7 @@ describe("LoopOnce", () => { it("resets time to 0 when clampWhenFinished is false", () => { const ctrl = makeController([makeClip(0, "run", 1)]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.setLoop(LoopOnce, 1); action.clampWhenFinished = false; @@ -252,7 +252,7 @@ describe("LoopOnce", () => { it("does not loop past the end", () => { const ctrl = makeController([makeClip(0, "run", 1)]); const target = makeTarget(); - const mixer = createGlyphcssAnimationMixer(target, ctrl); + const mixer = createGlyphAnimationMixer(target, ctrl); const action = mixer.clipAction("run"); action.setLoop(LoopOnce, 1).play(); mixer.update(0.9); @@ -267,7 +267,7 @@ describe("LoopOnce", () => { describe("LoopRepeat", () => { it("wraps time modulo duration", () => { const ctrl = makeController([makeClip(0, "run", 1)]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.setLoop(LoopRepeat, Infinity).play(); mixer.update(1.7); @@ -276,7 +276,7 @@ describe("LoopRepeat", () => { it("keeps running after one full cycle", () => { const ctrl = makeController([makeClip(0, "run", 1)]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.setLoop(LoopRepeat, Infinity).play(); mixer.update(2.5); @@ -285,7 +285,7 @@ describe("LoopRepeat", () => { it("stops after exhausting repetitions", () => { const ctrl = makeController([makeClip(0, "run", 1)]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.setLoop(LoopRepeat, 2).play(); mixer.update(2.5); // 2 full reps + 0.5 @@ -298,7 +298,7 @@ describe("LoopRepeat", () => { describe("LoopPingPong", () => { it("time goes forward then backward", () => { const ctrl = makeController([makeClip(0, "run", 1)]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.setLoop(LoopPingPong, Infinity).play(); @@ -309,7 +309,7 @@ describe("LoopPingPong", () => { // Advance past the turn-around (total 1.5 → 0.5 into reverse) // After full update: time is in range [0,1], internal phase maps to 0.5 const target = makeTarget(); - const mixer2 = createGlyphcssAnimationMixer(target, ctrl); + const mixer2 = createGlyphAnimationMixer(target, ctrl); const action2 = mixer2.clipAction("run"); action2.setLoop(LoopPingPong, Infinity).play(); mixer2.update(1.3); @@ -328,7 +328,7 @@ describe("LoopPingPong", () => { return [TRI_A]; }, }; - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.setLoop(LoopPingPong, Infinity).play(); mixer.update(1.5); @@ -346,7 +346,7 @@ describe("LoopPingPong", () => { return [TRI_A]; }, }; - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.setLoop(LoopPingPong, Infinity).play(); @@ -367,14 +367,14 @@ describe("LoopPingPong", () => { describe("setEffectiveTimeScale", () => { it("returns this for chaining", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); expect(action.setEffectiveTimeScale(2)).toBe(action); }); it("half speed: time advances at half rate", () => { const ctrl = makeController([makeClip(0, "run", 10)]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.setEffectiveTimeScale(0.5).play(); mixer.update(1); @@ -383,7 +383,7 @@ describe("setEffectiveTimeScale", () => { it("double speed: time advances at double rate", () => { const ctrl = makeController([makeClip(0, "run", 10)]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.setEffectiveTimeScale(2).play(); mixer.update(1); @@ -396,7 +396,7 @@ describe("setEffectiveTimeScale", () => { describe("fadeIn / fadeOut", () => { it("fadeIn transitions weight from 0 to 1", () => { const ctrl = makeController([makeClip(0, "run", 10)]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.fadeIn(1).play(); expect(action.weight).toBeCloseTo(0); @@ -408,7 +408,7 @@ describe("fadeIn / fadeOut", () => { it("fadeOut transitions weight toward 0 and stops action", () => { const ctrl = makeController([makeClip(0, "run", 10)]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.play(); action.weight = 1; @@ -419,14 +419,14 @@ describe("fadeIn / fadeOut", () => { it("fadeIn returns this", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); expect(action.fadeIn(1)).toBe(action); }); it("fadeOut returns this", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); expect(action.fadeOut(1)).toBe(action); }); @@ -438,7 +438,7 @@ describe("crossFadeTo", () => { it("fades out the source and fades in the target", () => { const clips = [makeClip(0, "walk", 10), makeClip(1, "run", 10)]; const ctrl = makeController(clips, () => [TRI_A]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const walk = mixer.clipAction("walk"); const run = mixer.clipAction("run"); walk.play(); @@ -454,7 +454,7 @@ describe("crossFadeTo", () => { it("returns this for chaining", () => { const clips = [makeClip(0, "walk", 10), makeClip(1, "run", 10)]; const ctrl = makeController(clips); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const walk = mixer.clipAction("walk"); const run = mixer.clipAction("run"); expect(walk.crossFadeTo(run, 1)).toBe(walk); @@ -466,7 +466,7 @@ describe("crossFadeTo", () => { describe("setEffectiveWeight", () => { it("sets weight and returns this", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); const result = action.setEffectiveWeight(0.7); expect(action.weight).toBeCloseTo(0.7); @@ -480,7 +480,7 @@ describe("stopAllAction", () => { it("stops all running actions", () => { const clips = [makeClip(0, "walk", 5), makeClip(1, "run", 5)]; const ctrl = makeController(clips); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const walk = mixer.clipAction("walk").play(); const run = mixer.clipAction("run").play(); mixer.stopAllAction(); @@ -491,7 +491,7 @@ describe("stopAllAction", () => { it("after stopAllAction, update does not call setPolygons", () => { const ctrl = makeController([makeClip(0, "run", 5)]); const target = makeTarget(); - const mixer = createGlyphcssAnimationMixer(target, ctrl); + const mixer = createGlyphAnimationMixer(target, ctrl); mixer.clipAction("run").play(); mixer.stopAllAction(); mixer.update(0.1); @@ -504,7 +504,7 @@ describe("stopAllAction", () => { describe("uncacheClip / uncacheRoot", () => { it("uncacheClip removes the cached action so existingAction returns null", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); mixer.clipAction("run"); mixer.uncacheClip("run"); expect(mixer.existingAction("run")).toBeNull(); @@ -513,7 +513,7 @@ describe("uncacheClip / uncacheRoot", () => { it("uncacheRoot removes all cached actions", () => { const clips = [makeClip(0, "walk"), makeClip(1, "run")]; const ctrl = makeController(clips); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); mixer.clipAction("walk"); mixer.clipAction("run"); mixer.uncacheRoot(); @@ -523,7 +523,7 @@ describe("uncacheClip / uncacheRoot", () => { it("uncacheClip by index works the same as by name", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); mixer.clipAction("run"); mixer.uncacheClip(0); expect(mixer.existingAction("run")).toBeNull(); @@ -535,7 +535,7 @@ describe("uncacheClip / uncacheRoot", () => { describe("setLoop + clampWhenFinished", () => { it("LoopOnce + clampWhenFinished: time stays at duration after finish", () => { const ctrl = makeController([makeClip(0, "run", 1)]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.setLoop(LoopOnce, 1); action.clampWhenFinished = true; @@ -547,7 +547,7 @@ describe("setLoop + clampWhenFinished", () => { it("setLoop returns this", () => { const ctrl = makeController([makeClip(0, "run")]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); expect(action.setLoop(LoopOnce, 1)).toBe(action); }); @@ -565,7 +565,7 @@ describe("multi-action blending", () => { sample: (name, _t) => name === "a" ? [polyA] : [polyB], }; const target = makeTarget(); - const mixer = createGlyphcssAnimationMixer(target, ctrl); + const mixer = createGlyphAnimationMixer(target, ctrl); const actionA = mixer.clipAction("a"); const actionB = mixer.clipAction("b"); actionA.setEffectiveWeight(0.5).play(); @@ -582,7 +582,7 @@ describe("crossFadeFrom", () => { it("fades in this action and fades out the from action", () => { const clips = [makeClip(0, "walk", 10), makeClip(1, "run", 10)]; const ctrl = makeController(clips, () => [TRI_A]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const walk = mixer.clipAction("walk"); const run = mixer.clipAction("run"); walk.play(); @@ -598,7 +598,7 @@ describe("crossFadeFrom", () => { it("returns this for chaining", () => { const clips = [makeClip(0, "walk", 10), makeClip(1, "run", 10)]; const ctrl = makeController(clips); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const walk = mixer.clipAction("walk"); const run = mixer.clipAction("run"); expect(run.crossFadeFrom(walk, 1)).toBe(run); @@ -610,7 +610,7 @@ describe("crossFadeFrom", () => { describe("enabled", () => { it("defaults to true", () => { const ctrl = makeController([makeClip(0, "run", 10)]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); expect(action.enabled).toBe(true); }); @@ -618,7 +618,7 @@ describe("enabled", () => { it("when false, does not drive setPolygons even while running", () => { const ctrl = makeController([makeClip(0, "run", 10)]); const target = makeTarget(); - const mixer = createGlyphcssAnimationMixer(target, ctrl); + const mixer = createGlyphAnimationMixer(target, ctrl); const action = mixer.clipAction("run"); action.play(); action.enabled = false; @@ -628,7 +628,7 @@ describe("enabled", () => { it("when false, time still advances", () => { const ctrl = makeController([makeClip(0, "run", 10)]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.play(); action.enabled = false; @@ -640,7 +640,7 @@ describe("enabled", () => { it("re-enabling allows the action to contribute again", () => { const ctrl = makeController([makeClip(0, "run", 10)]); const target = makeTarget(); - const mixer = createGlyphcssAnimationMixer(target, ctrl); + const mixer = createGlyphAnimationMixer(target, ctrl); const action = mixer.clipAction("run"); action.play(); action.enabled = false; @@ -657,14 +657,14 @@ describe("enabled", () => { describe("paused", () => { it("defaults to false", () => { const ctrl = makeController([makeClip(0, "run", 10)]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); expect(action.paused).toBe(false); }); it("when true, time does not advance", () => { const ctrl = makeController([makeClip(0, "run", 10)]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.play(); mixer.update(0.5); // advance to 0.5 @@ -676,7 +676,7 @@ describe("paused", () => { it("when true, action still contributes current weight to blend", () => { const ctrl = makeController([makeClip(0, "run", 10)]); const target = makeTarget(); - const mixer = createGlyphcssAnimationMixer(target, ctrl); + const mixer = createGlyphAnimationMixer(target, ctrl); const action = mixer.clipAction("run"); action.play(); action.paused = true; @@ -687,7 +687,7 @@ describe("paused", () => { it("unpausing resumes time advancement", () => { const ctrl = makeController([makeClip(0, "run", 10)]); - const mixer = createGlyphcssAnimationMixer(makeTarget(), ctrl); + const mixer = createGlyphAnimationMixer(makeTarget(), ctrl); const action = mixer.clipAction("run"); action.play(); mixer.update(0.5); diff --git a/packages/core/src/animation/index.ts b/packages/core/src/animation/index.ts index c90a2ac5..2e565507 100644 --- a/packages/core/src/animation/index.ts +++ b/packages/core/src/animation/index.ts @@ -1,5 +1,5 @@ /** - * GlyphcssAnimationMixer — three.js-shaped animation API for glyphcss. + * GlyphAnimationMixer — three.js-shaped animation API for glyphcss. * * Mirrors three.js's AnimationMixer + AnimationAction surface closely enough * that users familiar with drei's `useAnimations` can migrate without friction. @@ -18,15 +18,15 @@ export const LoopPingPong = 2202 as const; export type LoopMode = typeof LoopOnce | typeof LoopRepeat | typeof LoopPingPong; -// Re-export clip type under the Glyphcss-prefixed alias. -export type { ParseAnimationClip as GlyphcssAnimationClip }; +// Re-export clip type under the Glyph-prefixed alias. +export type { ParseAnimationClip as GlyphAnimationClip }; /** - * Minimal target interface the mixer requires. `GlyphcssMeshHandle` from both + * Minimal target interface the mixer requires. `GlyphMeshHandle` from both * the glyphcss vanilla API and the React/Vue frameworks satisfies this * structurally — no import needed. */ -export interface GlyphcssAnimationTarget { +export interface GlyphAnimationTarget { setPolygons(polygons: Polygon[]): void; } @@ -34,33 +34,33 @@ export interface GlyphcssAnimationTarget { * Per-clip playback action. Mirrors three.js `AnimationAction` method surface. * All mutating methods return `this` for chaining. */ -export interface GlyphcssAnimationAction { +export interface GlyphAnimationAction { /** Start playing (sets weight=1, resets time if not already playing). */ - play(): GlyphcssAnimationAction; + play(): GlyphAnimationAction; /** Stop playing and reset time to 0. */ - stop(): GlyphcssAnimationAction; + stop(): GlyphAnimationAction; /** Reset time to 0 without stopping. */ - reset(): GlyphcssAnimationAction; + reset(): GlyphAnimationAction; /** Fade weight from 0 to 1 over `durationSeconds`. */ - fadeIn(durationSeconds: number): GlyphcssAnimationAction; + fadeIn(durationSeconds: number): GlyphAnimationAction; /** Fade weight from current to 0 over `durationSeconds`. */ - fadeOut(durationSeconds: number): GlyphcssAnimationAction; + fadeOut(durationSeconds: number): GlyphAnimationAction; /** * Cross-fade from this action to `target` over `durationSeconds`. * Fades this out and target in simultaneously. */ - crossFadeTo(target: GlyphcssAnimationAction, durationSeconds: number): GlyphcssAnimationAction; + crossFadeTo(target: GlyphAnimationAction, durationSeconds: number): GlyphAnimationAction; /** * Cross-fade from `from` into this action over `durationSeconds`. * Sugar for `from.fadeOut(d); this.fadeIn(d)`. */ - crossFadeFrom(from: GlyphcssAnimationAction, durationSeconds: number): GlyphcssAnimationAction; + crossFadeFrom(from: GlyphAnimationAction, durationSeconds: number): GlyphAnimationAction; /** Set loop mode and repetition count. */ - setLoop(mode: LoopMode, repetitions: number): GlyphcssAnimationAction; + setLoop(mode: LoopMode, repetitions: number): GlyphAnimationAction; /** Override the effective time scale. */ - setEffectiveTimeScale(scale: number): GlyphcssAnimationAction; + setEffectiveTimeScale(scale: number): GlyphAnimationAction; /** Override the effective weight. */ - setEffectiveWeight(weight: number): GlyphcssAnimationAction; + setEffectiveWeight(weight: number): GlyphAnimationAction; /** When true, the action freezes on the last frame after finishing. */ clampWhenFinished: boolean; /** Playback speed multiplier. Default 1. */ @@ -84,20 +84,20 @@ export interface GlyphcssAnimationAction { } /** - * Drives one or more `GlyphcssAnimationAction`s against a single mesh target. + * Drives one or more `GlyphAnimationAction`s against a single mesh target. * Mirrors the three.js `AnimationMixer` API. */ -export interface GlyphcssAnimationMixer { +export interface GlyphAnimationMixer { /** * Return the action for a clip (by index or name). Creates the action if it * doesn't exist yet (lazy instantiation, same as three.js). */ - clipAction(clip: number | string): GlyphcssAnimationAction; + clipAction(clip: number | string): GlyphAnimationAction; /** * Return an existing action without creating one. Returns null if the * action hasn't been instantiated yet. */ - existingAction(clip: number | string): GlyphcssAnimationAction | null; + existingAction(clip: number | string): GlyphAnimationAction | null; /** * Advance all active actions by `deltaSeconds` and apply the resulting * polygon frame to the root target. Call this once per animation frame. @@ -123,7 +123,7 @@ interface FadeState { function createAction( clip: ParseAnimationClip, controller: ParseAnimationController, -): GlyphcssAnimationAction { +): GlyphAnimationAction { let _time = 0; let _weight = 1; let _timeScale = 1; @@ -135,7 +135,7 @@ function createAction( let _enabled = true; let _paused = false; - const action: GlyphcssAnimationAction = { + const action: GlyphAnimationAction = { clampWhenFinished: false, get timeScale() { return _timeScale; }, @@ -185,13 +185,13 @@ function createAction( return action; }, - crossFadeTo(target: GlyphcssAnimationAction, durationSeconds: number) { + crossFadeTo(target: GlyphAnimationAction, durationSeconds: number) { action.fadeOut(durationSeconds); target.fadeIn(durationSeconds); return action; }, - crossFadeFrom(from: GlyphcssAnimationAction, durationSeconds: number) { + crossFadeFrom(from: GlyphAnimationAction, durationSeconds: number) { from.fadeOut(durationSeconds); action.fadeIn(durationSeconds); return action; @@ -322,7 +322,7 @@ interface ActionInternal { sampleTime(): number; } -function getInternal(action: GlyphcssAnimationAction): ActionInternal { +function getInternal(action: GlyphAnimationAction): ActionInternal { return (action as unknown as { _internal: ActionInternal })._internal; } @@ -334,17 +334,17 @@ function resolveClip( return clips.find((c) => c.name === key); } -export function createGlyphcssAnimationMixer( - root: GlyphcssAnimationTarget, +export function createGlyphAnimationMixer( + root: GlyphAnimationTarget, controller: ParseAnimationController, -): GlyphcssAnimationMixer { - const actionCache = new Map(); +): GlyphAnimationMixer { + const actionCache = new Map(); - function clipAction(key: number | string): GlyphcssAnimationAction { + function clipAction(key: number | string): GlyphAnimationAction { const clip = resolveClip(controller.clips, key); if (!clip) { throw new Error( - `GlyphcssAnimationMixer: no clip found for key "${key}". Available: ${controller.clips.map((c) => c.name).join(", ")}`, + `GlyphAnimationMixer: no clip found for key "${key}". Available: ${controller.clips.map((c) => c.name).join(", ")}`, ); } let action = actionCache.get(clip.index); @@ -355,7 +355,7 @@ export function createGlyphcssAnimationMixer( return action; } - function existingAction(key: number | string): GlyphcssAnimationAction | null { + function existingAction(key: number | string): GlyphAnimationAction | null { const clip = resolveClip(controller.clips, key); if (!clip) return null; return actionCache.get(clip.index) ?? null; diff --git a/packages/core/src/color/lighting.ts b/packages/core/src/color/lighting.ts index 7cfd5570..b5ec0a3f 100644 --- a/packages/core/src/color/lighting.ts +++ b/packages/core/src/color/lighting.ts @@ -11,7 +11,7 @@ * for performance, but the helper exists for users who want to shade * polygons outside the renderer (e.g. SSR, validators, alternate backends). */ -import type { GlyphcssAmbientLight, GlyphcssDirectionalLight, Vec3 } from "../types"; +import type { GlyphAmbientLight, GlyphDirectionalLight, Vec3 } from "../types"; import { type ParsedColor, parsePureColor, @@ -54,13 +54,13 @@ export function shadeColor(base: string, delta: number): string { return formatColor({ rgb, alpha: parsed.alpha }); } -const DEFAULT_DIRECTIONAL: Required = { +const DEFAULT_DIRECTIONAL: Required = { direction: [0, 0, -1], color: "#ffffff", intensity: 1, }; -const DEFAULT_AMBIENT: Required = { +const DEFAULT_AMBIENT: Required = { color: "#ffffff", intensity: 0.4, }; @@ -93,8 +93,8 @@ function tintChannel(base: number, tintHex: string, channel: 0 | 1 | 2): number export function computeShapeLighting( normal: Vec3, baseColor: string, - directional?: GlyphcssDirectionalLight, - ambient?: GlyphcssAmbientLight, + directional?: GlyphDirectionalLight, + ambient?: GlyphAmbientLight, ): string { const base = parseColor(baseColor) ?? defaultColor; const dir = normalizeVec3(directional?.direction ?? DEFAULT_DIRECTIONAL.direction); diff --git a/packages/core/src/helpers/_dualPolyhedron.ts b/packages/core/src/helpers/_dualPolyhedron.ts new file mode 100644 index 00000000..ec10b59f --- /dev/null +++ b/packages/core/src/helpers/_dualPolyhedron.ts @@ -0,0 +1,175 @@ +/** + * Compute the geometric dual of a convex polyhedron. + * + * Strategy: polar-reciprocal dual vertices + angular sort around each input vertex. + * + * 1. For each input face F with outward unit normal n̂ and a vertex v on F, + * the dual vertex is placed at the polar reciprocal of F's supporting plane + * with respect to the input's circumsphere (radius R): + * + * d_F = (R² / (n̂ · v)) * n̂ + * + * This gives an exact Catalan solid for every Archimedean primal (the + * faces produced this way are provably planar). + * + * 2. For each input vertex V, collect all input faces that contain V. + * Sort their dual vertices by angle around V in V's tangent plane (CCW + * when viewed from the outward direction +V). This gives the dual face. + * + * The angular-sort strategy handles all Archimedean solids (including snub + * polyhedra) without requiring an explicit half-edge structure. + * + * Note on the snub duals: the pentagonal faces of the pentagonal icosi- + * tetrahedron and pentagonal hexecontahedron are NOT flat — the polar- + * reciprocal construction still places each face's vertex pair on the correct + * plane, but the 5 dual vertices of a snub vertex do not lie on a common + * plane (this is a known property of these particular Catalan solids). + */ +import type { Polygon, Vec3 } from "../types"; + +/** Squared distance between two Vec3 points. */ +function dist2(a: Vec3, b: Vec3): number { + const dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2]; + return dx * dx + dy * dy + dz * dz; +} + +/** L2 norm of a Vec3. */ +function norm(v: Vec3): number { + return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); +} + +/** Normalize a Vec3 to unit length. */ +function normalize(v: Vec3): Vec3 { + const n = norm(v); + return [v[0] / n, v[1] / n, v[2] / n]; +} + +/** Centroid of an array of Vec3 points. */ +function centroid(pts: readonly Vec3[]): Vec3 { + let x = 0, y = 0, z = 0; + for (const [px, py, pz] of pts) { x += px; y += py; z += pz; } + const n = pts.length; + return [x / n, y / n, z / n]; +} + +/** Outward unit face normal via the first cross-product of the polygon edges. */ +function faceNormal(verts: readonly Vec3[]): Vec3 { + const [ax, ay, az] = verts[0]; + const [bx, by, bz] = verts[1]; + const [cx, cy, cz] = verts[2]; + const e0x = bx - ax, e0y = by - ay, e0z = bz - az; + const e1x = cx - ax, e1y = cy - ay, e1z = cz - az; + const nx = e0y * e1z - e0z * e1y; + const ny = e0z * e1x - e0x * e1z; + const nz = e0x * e1y - e0y * e1x; + const centx = verts.reduce((s, v) => s + v[0], 0) / verts.length; + const centy = verts.reduce((s, v) => s + v[1], 0) / verts.length; + const centz = verts.reduce((s, v) => s + v[2], 0) / verts.length; + // Flip if pointing inward (centroid dot normal < 0 when centroid faces away from origin). + const sign = (nx * centx + ny * centy + nz * centz) > 0 ? 1 : -1; + const len = Math.sqrt(nx * nx + ny * ny + nz * nz); + return [(sign * nx) / len, (sign * ny) / len, (sign * nz) / len]; +} + +/** + * Compute the geometric dual of a convex polyhedron given as an array of Polygon. + * + * Dual vertices are computed as polar reciprocals of the input faces w.r.t. + * the input's circumsphere, which produces exact planar Catalan-solid faces + * for all Archimedean primals. + * + * The returned Polygon[] has `color` set to "#ffffff" — callers override it. + */ +export function polyhedronDual(input: Polygon[]): Polygon[] { + // ── Step 1: deduplicate input vertices and build per-face index arrays ── + const EPS2 = 1e-10; + const uniqueVerts: Vec3[] = []; + const faceVertIdx: number[][] = []; + + function findOrAdd(v: Vec3): number { + for (let i = 0; i < uniqueVerts.length; i++) { + if (dist2(uniqueVerts[i], v) < EPS2) return i; + } + uniqueVerts.push(v); + return uniqueVerts.length - 1; + } + + for (const poly of input) { + faceVertIdx.push(poly.vertices.map((v) => findOrAdd(v as Vec3))); + } + + const nFaces = input.length; + const nVerts = uniqueVerts.length; + + // ── Step 2: polar-reciprocal dual vertices ────────────────────────────── + // For each primal face F with outward unit normal n̂ and a vertex p on F: + // d_F = (R² / (n̂ · p)) * n̂ + // where R = circumradius of the primal. + const R2 = Math.max(...uniqueVerts.map((v) => v[0]*v[0] + v[1]*v[1] + v[2]*v[2])); + + const dualVerts: Vec3[] = input.map((poly, fi) => { + const verts = poly.vertices as Vec3[]; + const n = faceNormal(verts); + // Use the first vertex of this face (any vertex on the supporting plane). + const p = verts[0]; + const h = n[0] * p[0] + n[1] * p[1] + n[2] * p[2]; + // h should be > 0 for outward-facing faces of a convex body centred at origin. + const scale = R2 / h; + return [n[0] * scale, n[1] * scale, n[2] * scale] as Vec3; + }); + + // ── Step 3: for each input vertex, collect the surrounding face indices ── + const vertToFaces: number[][] = Array.from({ length: nVerts }, () => []); + for (let fi = 0; fi < nFaces; fi++) { + for (const vi of faceVertIdx[fi]) { + vertToFaces[vi].push(fi); + } + } + + // ── Step 4: sort dual vertices around each input vertex (CCW from outside) ── + const dualFaces: number[][] = []; + + for (let vi = 0; vi < nVerts; vi++) { + const surrounding = vertToFaces[vi]; + if (surrounding.length < 3) continue; + + const vPos = uniqueVerts[vi]; + const outward = normalize(vPos); + + // Build tangent frame (e0, e1) perpendicular to `outward`. + // e0 = projection of (firstDualVert - vPos) onto tangent plane. + const anyDualVert = dualVerts[surrounding[0]]; + let t0x = anyDualVert[0] - vPos[0]; + let t0y = anyDualVert[1] - vPos[1]; + let t0z = anyDualVert[2] - vPos[2]; + const proj0 = t0x * outward[0] + t0y * outward[1] + t0z * outward[2]; + t0x -= proj0 * outward[0]; + t0y -= proj0 * outward[1]; + t0z -= proj0 * outward[2]; + const t0len = Math.sqrt(t0x * t0x + t0y * t0y + t0z * t0z); + if (t0len < 1e-12) continue; + const e0: Vec3 = [t0x / t0len, t0y / t0len, t0z / t0len]; + // e1 = outward × e0 (CCW on tangent plane when viewed from +outward). + const e1: Vec3 = [ + outward[1] * e0[2] - outward[2] * e0[1], + outward[2] * e0[0] - outward[0] * e0[2], + outward[0] * e0[1] - outward[1] * e0[0], + ]; + + const withAngle = surrounding.map((fi) => { + const dv = dualVerts[fi]; + const dx = dv[0] - vPos[0], dy = dv[1] - vPos[1], dz = dv[2] - vPos[2]; + const u = dx * e0[0] + dy * e0[1] + dz * e0[2]; + const w = dx * e1[0] + dy * e1[1] + dz * e1[2]; + return { fi, angle: Math.atan2(w, u) }; + }); + withAngle.sort((a, b) => a.angle - b.angle); + dualFaces.push(withAngle.map((x) => x.fi)); + } + + // ── Step 5: build output Polygon[] ────────────────────────────────────── + return dualFaces.map((faceIndices) => ({ + vertices: faceIndices.map((fi) => dualVerts[fi]), + color: "#ffffff", + })); +} diff --git a/packages/core/src/helpers/_facesFromEdgeGraph.ts b/packages/core/src/helpers/_facesFromEdgeGraph.ts new file mode 100644 index 00000000..80bbc5ff --- /dev/null +++ b/packages/core/src/helpers/_facesFromEdgeGraph.ts @@ -0,0 +1,195 @@ +/** + * Shared face-discovery utilities for Archimedean polyhedra whose faces cannot + * be trivially read from a truncation table. + * + * Algorithm: for each vertex `start` (the minimum index in the cycle), do a + * depth-first search through the edge graph collecting paths of `length` vertices. + * When a path of `length` vertices ends at a vertex adjacent to `start`, we have + * a candidate cycle. We then: + * 1. Check planarity — all vertices within 1e-6 of the plane defined by the + * first three. + * 2. Check outward-facing — the face centroid is in the same half-space as the + * face normal (origin is the solid's centre). + * 3. Deduplicate — keep one canonical rotation (minimum-index vertex first). + * + * The "nb >= start" pruning guarantees that each cycle is discovered at most + * once: when `start` is the smallest-index vertex in the face, all other + * vertices along the path will be > start. The final closing step (back to + * start) is allowed explicitly. + */ + +export type RawVerts = [number, number, number][]; +export type AdjList = number[][]; + +/** Check that all vertices lie on the same plane (within 1e-6). */ +function isPlanar(raw: RawVerts, indices: number[]): boolean { + if (indices.length <= 3) return true; + const [ax, ay, az] = raw[indices[0]]; + const [bx, by, bz] = raw[indices[1]]; + const [cx, cy, cz] = raw[indices[2]]; + const e0x = bx - ax, e0y = by - ay, e0z = bz - az; + const e1x = cx - ax, e1y = cy - ay, e1z = cz - az; + const nx = e0y * e1z - e0z * e1y; + const ny = e0z * e1x - e0x * e1z; + const nz = e0x * e1y - e0y * e1x; + const len = Math.sqrt(nx * nx + ny * ny + nz * nz); + if (len < 1e-10) return false; + const inv = 1 / len; + const d = (nx * ax + ny * ay + nz * az) * inv; + for (let k = 3; k < indices.length; k++) { + const [px, py, pz] = raw[indices[k]]; + const dist = Math.abs((nx * px + ny * py + nz * pz) * inv - d); + if (dist > 1e-6) return false; + } + return true; +} + +/** Check that the face normal points away from the origin (solid centred at 0). */ +function isOutwardFacing(raw: RawVerts, indices: number[]): boolean { + let gcx = 0, gcy = 0, gcz = 0; + for (const i of indices) { gcx += raw[i][0]; gcy += raw[i][1]; gcz += raw[i][2]; } + const cnt = indices.length; + gcx /= cnt; gcy /= cnt; gcz /= cnt; + + const [ax, ay, az] = raw[indices[0]]; + const [bx, by, bz] = raw[indices[1]]; + const [cx, cy, cz] = raw[indices[2]]; + const e0x = bx - ax, e0y = by - ay, e0z = bz - az; + const e1x = cx - ax, e1y = cy - ay, e1z = cz - az; + const nx = e0y * e1z - e0z * e1y; + const ny = e0z * e1x - e0x * e1z; + const nz = e0x * e1y - e0y * e1x; + return (nx * gcx + ny * gcy + nz * gcz) > 0; +} + +/** Canonical face key: rotate so smallest index is first, then join. */ +function faceKey(indices: number[]): string { + const minPos = indices.indexOf(Math.min(...indices)); + const rotated = [...indices.slice(minPos), ...indices.slice(0, minPos)]; + return rotated.join(","); +} + +/** + * Find all outward-facing planar cycles of `length` in the edge graph. + * + * @param raw Raw (unscaled) vertex coordinates. + * @param adj Adjacency list built from edges. + * @param length Cycle length to search for (4, 5, 6, 8, 10, …). + * @returns Array of vertex index arrays, one per face. + */ +export function findFacesOfLength(raw: RawVerts, adj: AdjList, length: number): number[][] { + const n = raw.length; + const found = new Set(); + const faces: number[][] = []; + + // path is always built with path[0] = start (the minimum vertex in the cycle). + // All intermediate vertices must have index > start to avoid finding the same + // cycle multiple times. + function dfs(path: number[], start: number): void { + const last = path[path.length - 1]; + + if (path.length === length) { + // Close the cycle — last must be adjacent to start. + if (!adj[last].includes(start)) return; + if (!isPlanar(raw, path)) return; + if (!isOutwardFacing(raw, path)) return; + const key = faceKey(path); + if (!found.has(key)) { + found.add(key); + faces.push([...path]); + } + return; + } + + for (const nb of adj[last]) { + // Must not revisit any already-visited vertex. + if (path.includes(nb)) continue; + // Enforce nb > start so each cycle is enumerated at its minimum vertex. + if (nb <= start) continue; + dfs([...path, nb], start); + } + } + + for (let start = 0; start < n; start++) { + dfs([start], start); + } + return faces; +} + +/** + * Build an adjacency list from a raw vertex array. + * Two vertices are connected if their distance equals `edgeLen ± eps`. + */ +export function buildAdjList(raw: RawVerts, eps = 1e-6): { adj: AdjList; edgeLen: number } { + const n = raw.length; + let edgeLen = Infinity; + + // Find the minimum non-zero pairwise distance. + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + const dx = raw[i][0] - raw[j][0]; + const dy = raw[i][1] - raw[j][1]; + const dz = raw[i][2] - raw[j][2]; + const d = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (d > 1e-10 && d < edgeLen) edgeLen = d; + } + } + + const adj: AdjList = Array.from({ length: n }, () => []); + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + const dx = raw[i][0] - raw[j][0]; + const dy = raw[i][1] - raw[j][1]; + const dz = raw[i][2] - raw[j][2]; + const d = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (Math.abs(d - edgeLen) < eps) { + adj[i].push(j); + adj[j].push(i); + } + } + } + + return { adj, edgeLen }; +} + +/** + * Sort a list of vertex indices CCW around their centroid, + * given an outward normal direction. + */ +export function sortCCW(raw: RawVerts, indices: number[], normal: [number, number, number]): number[] { + let gcx = 0, gcy = 0, gcz = 0; + for (const i of indices) { gcx += raw[i][0]; gcy += raw[i][1]; gcz += raw[i][2]; } + const cnt = indices.length; + gcx /= cnt; gcy /= cnt; gcz /= cnt; + + const [nx, ny, nz] = normal; + const [p0x, p0y, p0z] = raw[indices[0]]; + let e0x = p0x - gcx, e0y = p0y - gcy, e0z = p0z - gcz; + const dot0 = e0x * nx + e0y * ny + e0z * nz; + e0x -= dot0 * nx; e0y -= dot0 * ny; e0z -= dot0 * nz; + const len0 = Math.sqrt(e0x * e0x + e0y * e0y + e0z * e0z); + e0x /= len0; e0y /= len0; e0z /= len0; + const e1x = ny * e0z - nz * e0y; + const e1y = nz * e0x - nx * e0z; + const e1z = nx * e0y - ny * e0x; + + const angles = indices.map((i) => { + const [px, py, pz] = raw[i]; + const dx = px - gcx, dy = py - gcy, dz = pz - gcz; + const u = dx * e0x + dy * e0y + dz * e0z; + const w = dx * e1x + dy * e1y + dz * e1z; + return { i, angle: Math.atan2(w, u) }; + }); + angles.sort((a, b) => a.angle - b.angle); + return angles.map((a) => a.i); +} + +/** Compute the outward face normal as the normalised centroid direction. */ +export function faceNormal(raw: RawVerts, indices: number[]): [number, number, number] { + let gcx = 0, gcy = 0, gcz = 0; + for (const i of indices) { gcx += raw[i][0]; gcy += raw[i][1]; gcz += raw[i][2]; } + const cnt = indices.length; + gcx /= cnt; gcy /= cnt; gcz /= cnt; + const nl = Math.sqrt(gcx * gcx + gcy * gcy + gcz * gcz); + return [gcx / nl, gcy / nl, gcz / nl]; +} diff --git a/packages/core/src/helpers/antiprismPolygons.ts b/packages/core/src/helpers/antiprismPolygons.ts new file mode 100644 index 00000000..c7a03cc2 --- /dev/null +++ b/packages/core/src/helpers/antiprismPolygons.ts @@ -0,0 +1,71 @@ +/** + * Geometry for an N-gonal antiprism aligned to the Y axis. The antiprism is + * centered at `center`, with the bottom cap at `y - height/2` and the top + * cap at `y + height/2`. The top N-gon is rotated by π/sides relative to the + * bottom, and the side strip consists of 2·sides alternating triangles. + * + * Output (2·sides + 2 polygons): + * - `2 * sides` side triangles — alternating "up" and "down" triangles, CCW from outside. + * - 1 top cap N-gon — CCW when viewed from +Y. + * - 1 bottom cap N-gon — CCW when viewed from −Y (reversed ring order). + */ +import type { Polygon, Vec3 } from "../types"; + +export interface AntiprismPolygonsOptions { + /** Center of the antiprism in world space. */ + center: Vec3; + /** Circumradius of the N-gon cross-sections. */ + radius: number; + /** Total height along the Y axis. */ + height: number; + /** Number of sides on each N-gon cap. Defaults to 6. */ + sides?: number; + /** Fill color applied to all faces. */ + color?: string; +} + +export function antiprismPolygons(options: AntiprismPolygonsOptions): Polygon[] { + const { center, radius, height, sides = 6, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + const hy = height / 2; + const polygons: Polygon[] = []; + + // Bottom ring at y - hy, top ring at y + hy rotated by π/sides + const bottom: Vec3[] = []; + const top: Vec3[] = []; + for (let i = 0; i < sides; i++) { + const thetaBot = (2 * Math.PI * i) / sides; + const thetaTop = thetaBot + Math.PI / sides; + bottom.push([cx + radius * Math.cos(thetaBot), cy - hy, cz + radius * Math.sin(thetaBot)]); + top.push([cx + radius * Math.cos(thetaTop), cy + hy, cz + radius * Math.sin(thetaTop)]); + } + + // Side triangles: alternating up/down around the belt + for (let i = 0; i < sides; i++) { + const next = (i + 1) % sides; + // "Up" triangle — faces outward with CCW winding from outside + polygons.push({ + vertices: [top[i], bottom[next], bottom[i]], + color, + }); + // "Down" triangle — faces outward with CCW winding from outside + polygons.push({ + vertices: [top[i], top[next], bottom[next]], + color, + }); + } + + // Top cap: CCW from +Y → reversed ring order + polygons.push({ + vertices: [...top].reverse() as Vec3[], + color, + }); + + // Bottom cap: CCW from −Y → natural ring order + polygons.push({ + vertices: [...bottom] as Vec3[], + color, + }); + + return polygons; +} diff --git a/packages/core/src/helpers/bipyramidPolygons.ts b/packages/core/src/helpers/bipyramidPolygons.ts new file mode 100644 index 00000000..5d734e09 --- /dev/null +++ b/packages/core/src/helpers/bipyramidPolygons.ts @@ -0,0 +1,60 @@ +/** + * Geometry for an N-gonal bipyramid (two pyramids glued base-to-base along Y). + * The equatorial ring of `sides` vertices lies at y = `center.y`, the top apex + * at y = `center.y + halfHeight`, and the bottom apex at y = `center.y - halfHeight`. + * `radius` is the circumradius of the equatorial ring. + * + * Output (2·sides polygons): + * - `sides` upper triangles — each `[topApex, ring_(i+1), ring_i]` (CCW from outside). + * - `sides` lower triangles — each `[bottomApex, ring_i, ring_(i+1)]` (CCW from outside). + */ +import type { Polygon, Vec3 } from "../types"; + +export interface BipyramidPolygonsOptions { + /** Center of the bipyramid in world space. */ + center: Vec3; + /** Circumradius of the equatorial N-gon ring. */ + radius: number; + /** Half the total height — distance from equator to each apex. */ + halfHeight: number; + /** Number of sides on the equatorial ring. Defaults to 6. */ + sides?: number; + /** Fill color applied to all faces. */ + color?: string; +} + +export function bipyramidPolygons(options: BipyramidPolygonsOptions): Polygon[] { + const { center, radius, halfHeight, sides = 6, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + const polygons: Polygon[] = []; + + // Equatorial ring vertices + const ring: Vec3[] = []; + for (let i = 0; i < sides; i++) { + const theta = (2 * Math.PI * i) / sides; + ring.push([cx + radius * Math.cos(theta), cy, cz + radius * Math.sin(theta)]); + } + + const topApex: Vec3 = [cx, cy + halfHeight, cz]; + const bottomApex: Vec3 = [cx, cy - halfHeight, cz]; + + // Upper triangles: [topApex, ring_(i+1), ring_i] — CCW from outside (upper half) + for (let i = 0; i < sides; i++) { + const next = (i + 1) % sides; + polygons.push({ + vertices: [topApex, ring[next], ring[i]], + color, + }); + } + + // Lower triangles: [bottomApex, ring_i, ring_(i+1)] — CCW from outside (lower half) + for (let i = 0; i < sides; i++) { + const next = (i + 1) % sides; + polygons.push({ + vertices: [bottomApex, ring[i], ring[next]], + color, + }); + } + + return polygons; +} diff --git a/packages/core/src/helpers/conePolygons.ts b/packages/core/src/helpers/conePolygons.ts new file mode 100644 index 00000000..0ef33e18 --- /dev/null +++ b/packages/core/src/helpers/conePolygons.ts @@ -0,0 +1,57 @@ +/** + * Geometry for a closed cone along the Y axis. The apex sits at + * `y + height/2` and the base ring sits at `y - height/2`, relative to + * `center`. The base is a regular N-gon with circumradius `radius`. + * + * Output (sides + 1 polygons): + * - `sides` side triangles — each `[apex, base_(i+1), base_i]` (CCW from outside). + * - 1 base cap N-gon — CCW when viewed from −Y (reversed ring order). + */ +import type { Polygon, Vec3 } from "../types"; + +export interface ConePolygonsOptions { + /** Center of the cone in world space. */ + center: Vec3; + /** Circumradius of the base circle. */ + radius: number; + /** Total height along the Y axis. */ + height: number; + /** Number of base polygon sides. Defaults to 16. */ + sides?: number; + /** Fill color applied to all faces. */ + color?: string; +} + +export function conePolygons(options: ConePolygonsOptions): Polygon[] { + const { center, radius, height, sides = 16, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + const hy = height / 2; + const polygons: Polygon[] = []; + + // Base ring vertices + const base: Vec3[] = []; + for (let i = 0; i < sides; i++) { + const theta = (2 * Math.PI * i) / sides; + base.push([cx + radius * Math.cos(theta), cy - hy, cz + radius * Math.sin(theta)]); + } + + const apex: Vec3 = [cx, cy + hy, cz]; + + // Side triangles: [apex, base_(i+1), base_i] — CCW from outside + for (let i = 0; i < sides; i++) { + const next = (i + 1) % sides; + polygons.push({ + vertices: [apex, base[next], base[i]], + color, + }); + } + + // Base cap: CCW from −Y → natural ring order (ring is generated CCW in XZ, + // which already gives a −Y-outward normal for the bottom-facing cap). + polygons.push({ + vertices: [...base] as Vec3[], + color, + }); + + return polygons; +} diff --git a/packages/core/src/helpers/cuboctahedronPolygons.ts b/packages/core/src/helpers/cuboctahedronPolygons.ts new file mode 100644 index 00000000..52ead537 --- /dev/null +++ b/packages/core/src/helpers/cuboctahedronPolygons.ts @@ -0,0 +1,92 @@ +/** + * Geometry for a regular cuboctahedron — 8 triangular faces + 6 square faces + * (14 faces total, 12 vertices). Vertices are all permutations of (±1, ±1, 0). + * Scaled so the circumradius equals `size`. + * + * Vertex ordering (12 total): + * Indices 0–3: (±1, ±1, 0) + * Indices 4–7: (±1, 0, ±1) + * Indices 8–11: (0, ±1, ±1) + * + * Face decomposition: 8 equilateral triangles (one per octant) + 6 squares + * (one per cube face — edge midpoints of a cube). Each face is CCW-from-outside. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface CuboctahedronPolygonsOptions { + /** Center of the cuboctahedron in world space. */ + center: Vec3; + /** Circumradius — distance from center to each vertex. */ + size: number; + /** Fill color applied to all fourteen faces. */ + color?: string; +} + +export function cuboctahedronPolygons(options: CuboctahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + // Raw circumradius of the (±1, ±1, 0) form is √2. + // Scale so circumradius equals `size`. + const s = size / Math.sqrt(2); + + // 12 vertices — all permutations of (±1, ±1, 0). + const raw: [number, number, number][] = [ + [ 1, 1, 0], // 0 + [ 1, -1, 0], // 1 + [-1, 1, 0], // 2 + [-1, -1, 0], // 3 + [ 1, 0, 1], // 4 + [ 1, 0, -1], // 5 + [-1, 0, 1], // 6 + [-1, 0, -1], // 7 + [ 0, 1, 1], // 8 + [ 0, 1, -1], // 9 + [ 0, -1, 1], // 10 + [ 0, -1, -1], // 11 + ]; + + const v: Vec3[] = raw.map(([x, y, z]) => [cx + x * s, cy + y * s, cz + z * s]); + + // 8 triangular faces — one per octant (±,±,±). + // For octant (sx,sy,sz): triangle connects (sx,sy,0), (sx,0,sz), (0,sy,sz). + // Winding is CCW when viewed from the outward octant direction. + const triangleFaces: [number, number, number][] = [ + [ 0, 8, 4], // (+,+,+) + [ 0, 5, 9], // (+,+,-) + [ 1, 4, 10], // (+,-,+) + [ 1, 11, 5], // (+,-,-) + [ 2, 6, 8], // (-,+,+) + [ 2, 9, 7], // (-,+,-) + [ 3, 10, 6], // (-,-,+) + [ 3, 7, 11], // (-,-,-) + ]; + + // 6 square faces — each corresponds to one face of the parent cube. + // Vertices are the midpoints of that cube face's 4 edges, ordered CCW from outside. + // +z face: (0,1,1),(−1,0,1),(0,−1,1),(1,0,1) + // -z face: (0,1,−1),(1,0,−1),(0,−1,−1),(−1,0,−1) + // +x face: (1,1,0),(1,0,1),(1,−1,0),(1,0,−1) + // -x face: (−1,1,0),(−1,0,−1),(−1,−1,0),(−1,0,1) + // +y face: (1,1,0),(0,1,−1),(−1,1,0),(0,1,1) + // -y face: (1,−1,0),(0,−1,1),(−1,−1,0),(0,−1,−1) + const squareFaces: [number, number, number, number][] = [ + [ 8, 6, 10, 4], // +Z + [ 9, 5, 11, 7], // -Z + [ 0, 4, 1, 5], // +X + [ 2, 7, 3, 6], // -X + [ 0, 9, 2, 8], // +Y + [ 1, 10, 3, 11], // -Y + ]; + + return [ + ...triangleFaces.map((f) => ({ + vertices: [v[f[0]], v[f[1]], v[f[2]]], + color, + })), + ...squareFaces.map((f) => ({ + vertices: [v[f[0]], v[f[1]], v[f[2]], v[f[3]]], + color, + })), + ]; +} diff --git a/packages/core/src/helpers/cylinderPolygons.ts b/packages/core/src/helpers/cylinderPolygons.ts new file mode 100644 index 00000000..e680f990 --- /dev/null +++ b/packages/core/src/helpers/cylinderPolygons.ts @@ -0,0 +1,70 @@ +/** + * Geometry for a closed cylinder aligned to the Y axis. The cylinder is + * centered at `center`, with the bottom ring at `y - height/2` and the top + * ring at `y + height/2`. The circumference is approximated by `sides` + * evenly-spaced vertices. + * + * Output (sides + 2 polygons): + * - `sides` side quads — each connecting top_i → top_(i+1) → bottom_(i+1) → bottom_i (CCW from outside). + * - 1 top cap N-gon — CCW from +Y. Because the ring is generated going + * counter-clockwise in XZ (increasing θ winds X→+Z), the +Y-outward + * normal requires the REVERSED order. + * - 1 bottom cap N-gon — CCW from −Y. The same generation order (CCW in + * XZ) already produces a −Y-outward normal, so the bottom cap keeps the + * natural ring order. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface CylinderPolygonsOptions { + /** Center of the cylinder in world space. */ + center: Vec3; + /** Radius of the circular cross-section. */ + radius: number; + /** Total height along the Y axis. */ + height: number; + /** Number of circumference divisions. Defaults to 16. */ + sides?: number; + /** Fill color applied to all faces. */ + color?: string; +} + +export function cylinderPolygons(options: CylinderPolygonsOptions): Polygon[] { + const { center, radius, height, sides = 16, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + const hy = height / 2; + const polygons: Polygon[] = []; + + // Generate bottom and top ring vertices + const bottom: Vec3[] = []; + const top: Vec3[] = []; + for (let i = 0; i < sides; i++) { + const theta = (2 * Math.PI * i) / sides; + const x = cx + radius * Math.cos(theta); + const z = cz + radius * Math.sin(theta); + bottom.push([x, cy - hy, z]); + top.push([x, cy + hy, z]); + } + + // Side quads: [top_i, top_(i+1), bottom_(i+1), bottom_i] — CCW from outside + for (let i = 0; i < sides; i++) { + const next = (i + 1) % sides; + polygons.push({ + vertices: [top[i], top[next], bottom[next], bottom[i]], + color, + }); + } + + // Top cap: CCW from +Y → reversed ring order + polygons.push({ + vertices: [...top].reverse() as Vec3[], + color, + }); + + // Bottom cap: CCW from −Y → natural ring order + polygons.push({ + vertices: [...bottom] as Vec3[], + color, + }); + + return polygons; +} diff --git a/packages/core/src/helpers/deltoidalHexecontahedronPolygons.ts b/packages/core/src/helpers/deltoidalHexecontahedronPolygons.ts new file mode 100644 index 00000000..0d18f4f0 --- /dev/null +++ b/packages/core/src/helpers/deltoidalHexecontahedronPolygons.ts @@ -0,0 +1,23 @@ +/** + * Geometry for a deltoidal hexecontahedron — 60 kite faces. + * The dual of the rhombicosidodecahedron. + */ +import type { Polygon, Vec3 } from "../types"; +import { rhombicosidodecahedronPolygons } from "./rhombicosidodecahedronPolygons"; +import { polyhedronDual } from "./_dualPolyhedron"; + +export interface DeltoidalHexecontahedronPolygonsOptions { + center: Vec3; + size: number; + color?: string; +} + +export function deltoidalHexecontahedronPolygons(options: DeltoidalHexecontahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const primal = rhombicosidodecahedronPolygons({ center: [0, 0, 0], size }); + const dual = polyhedronDual(primal); + return dual.map((p) => ({ + vertices: p.vertices.map(([x, y, z]) => [x + center[0], y + center[1], z + center[2]] as Vec3), + color, + })); +} diff --git a/packages/core/src/helpers/deltoidalIcositetrahedronPolygons.ts b/packages/core/src/helpers/deltoidalIcositetrahedronPolygons.ts new file mode 100644 index 00000000..18290005 --- /dev/null +++ b/packages/core/src/helpers/deltoidalIcositetrahedronPolygons.ts @@ -0,0 +1,23 @@ +/** + * Geometry for a deltoidal icositetrahedron — 24 kite faces. + * The dual of the rhombicuboctahedron. + */ +import type { Polygon, Vec3 } from "../types"; +import { rhombicuboctahedronPolygons } from "./rhombicuboctahedronPolygons"; +import { polyhedronDual } from "./_dualPolyhedron"; + +export interface DeltoidalIcositetrahedronPolygonsOptions { + center: Vec3; + size: number; + color?: string; +} + +export function deltoidalIcositetrahedronPolygons(options: DeltoidalIcositetrahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const primal = rhombicuboctahedronPolygons({ center: [0, 0, 0], size }); + const dual = polyhedronDual(primal); + return dual.map((p) => ({ + vertices: p.vertices.map(([x, y, z]) => [x + center[0], y + center[1], z + center[2]] as Vec3), + color, + })); +} diff --git a/packages/core/src/helpers/disdyakisDodecahedronPolygons.ts b/packages/core/src/helpers/disdyakisDodecahedronPolygons.ts new file mode 100644 index 00000000..66ec6c79 --- /dev/null +++ b/packages/core/src/helpers/disdyakisDodecahedronPolygons.ts @@ -0,0 +1,23 @@ +/** + * Geometry for a disdyakis dodecahedron — 48 scalene-triangle faces. + * The dual of the truncated cuboctahedron. + */ +import type { Polygon, Vec3 } from "../types"; +import { truncatedCuboctahedronPolygons } from "./truncatedCuboctahedronPolygons"; +import { polyhedronDual } from "./_dualPolyhedron"; + +export interface DisdyakisDodecahedronPolygonsOptions { + center: Vec3; + size: number; + color?: string; +} + +export function disdyakisDodecahedronPolygons(options: DisdyakisDodecahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const primal = truncatedCuboctahedronPolygons({ center: [0, 0, 0], size }); + const dual = polyhedronDual(primal); + return dual.map((p) => ({ + vertices: p.vertices.map(([x, y, z]) => [x + center[0], y + center[1], z + center[2]] as Vec3), + color, + })); +} diff --git a/packages/core/src/helpers/disdyakisTriacontahedronPolygons.ts b/packages/core/src/helpers/disdyakisTriacontahedronPolygons.ts new file mode 100644 index 00000000..207eb2b2 --- /dev/null +++ b/packages/core/src/helpers/disdyakisTriacontahedronPolygons.ts @@ -0,0 +1,23 @@ +/** + * Geometry for a disdyakis triacontahedron — 120 scalene-triangle faces. + * The dual of the truncated icosidodecahedron. + */ +import type { Polygon, Vec3 } from "../types"; +import { truncatedIcosidodecahedronPolygons } from "./truncatedIcosidodecahedronPolygons"; +import { polyhedronDual } from "./_dualPolyhedron"; + +export interface DisdyakisTriacontahedronPolygonsOptions { + center: Vec3; + size: number; + color?: string; +} + +export function disdyakisTriacontahedronPolygons(options: DisdyakisTriacontahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const primal = truncatedIcosidodecahedronPolygons({ center: [0, 0, 0], size }); + const dual = polyhedronDual(primal); + return dual.map((p) => ({ + vertices: p.vertices.map(([x, y, z]) => [x + center[0], y + center[1], z + center[2]] as Vec3), + color, + })); +} diff --git a/packages/core/src/helpers/geometryRegistry.ts b/packages/core/src/helpers/geometryRegistry.ts new file mode 100644 index 00000000..30ce1f25 --- /dev/null +++ b/packages/core/src/helpers/geometryRegistry.ts @@ -0,0 +1,160 @@ +import type { Polygon, Vec3 } from "../types"; +import { tetrahedronPolygons } from "./tetrahedronPolygons"; +import { cubePolygons } from "./cubePolygons"; +import { octahedronPolygons } from "./octahedronPolygons"; +import { dodecahedronPolygons } from "./dodecahedronPolygons"; +import { icosahedronPolygons } from "./icosahedronPolygons"; +import { smallStellatedDodecahedronPolygons } from "./smallStellatedDodecahedronPolygons"; +import { greatDodecahedronPolygons } from "./greatDodecahedronPolygons"; +import { greatStellatedDodecahedronPolygons } from "./greatStellatedDodecahedronPolygons"; +import { greatIcosahedronPolygons } from "./greatIcosahedronPolygons"; +import { cuboctahedronPolygons } from "./cuboctahedronPolygons"; +import { icosidodecahedronPolygons } from "./icosidodecahedronPolygons"; +import { truncatedTetrahedronPolygons } from "./truncatedTetrahedronPolygons"; +import { truncatedCubePolygons } from "./truncatedCubePolygons"; +import { truncatedOctahedronPolygons } from "./truncatedOctahedronPolygons"; +import { truncatedDodecahedronPolygons } from "./truncatedDodecahedronPolygons"; +import { truncatedIcosahedronPolygons } from "./truncatedIcosahedronPolygons"; +import { truncatedCuboctahedronPolygons } from "./truncatedCuboctahedronPolygons"; +import { truncatedIcosidodecahedronPolygons } from "./truncatedIcosidodecahedronPolygons"; +import { rhombicuboctahedronPolygons } from "./rhombicuboctahedronPolygons"; +import { rhombicosidodecahedronPolygons } from "./rhombicosidodecahedronPolygons"; +import { snubCubePolygons } from "./snubCubePolygons"; +import { snubDodecahedronPolygons } from "./snubDodecahedronPolygons"; +import { rhombicDodecahedronPolygons } from "./rhombicDodecahedronPolygons"; +import { rhombicTriacontahedronPolygons } from "./rhombicTriacontahedronPolygons"; +import { triakisTetrahedronPolygons } from "./triakisTetrahedronPolygons"; +import { triakisOctahedronPolygons } from "./triakisOctahedronPolygons"; +import { triakisIcosahedronPolygons } from "./triakisIcosahedronPolygons"; +import { tetrakisHexahedronPolygons } from "./tetrakisHexahedronPolygons"; +import { pentakisDodecahedronPolygons } from "./pentakisDodecahedronPolygons"; +import { disdyakisDodecahedronPolygons } from "./disdyakisDodecahedronPolygons"; +import { disdyakisTriacontahedronPolygons } from "./disdyakisTriacontahedronPolygons"; +import { deltoidalIcositetrahedronPolygons } from "./deltoidalIcositetrahedronPolygons"; +import { deltoidalHexecontahedronPolygons } from "./deltoidalHexecontahedronPolygons"; +import { pentagonalIcositetrahedronPolygons } from "./pentagonalIcositetrahedronPolygons"; +import { pentagonalHexecontahedronPolygons } from "./pentagonalHexecontahedronPolygons"; +import { spherePolygons } from "./spherePolygons"; +import { cylinderPolygons } from "./cylinderPolygons"; +import { conePolygons } from "./conePolygons"; +import { torusPolygons } from "./torusPolygons"; +import { pyramidPolygons } from "./pyramidPolygons"; +import { prismPolygons } from "./prismPolygons"; +import { antiprismPolygons } from "./antiprismPolygons"; +import { bipyramidPolygons } from "./bipyramidPolygons"; +import { trapezohedronPolygons } from "./trapezohedronPolygons"; + +export type GlyphGeometryName = + | "tetrahedron" + | "cube" + | "octahedron" + | "dodecahedron" + | "icosahedron" + | "smallStellatedDodecahedron" + | "greatDodecahedron" + | "greatStellatedDodecahedron" + | "greatIcosahedron" + | "cuboctahedron" + | "icosidodecahedron" + | "truncatedTetrahedron" + | "truncatedCube" + | "truncatedOctahedron" + | "truncatedDodecahedron" + | "truncatedIcosahedron" + | "truncatedCuboctahedron" + | "truncatedIcosidodecahedron" + | "rhombicuboctahedron" + | "rhombicosidodecahedron" + | "snubCube" + | "snubDodecahedron" + | "rhombicDodecahedron" + | "rhombicTriacontahedron" + | "triakisTetrahedron" + | "triakisOctahedron" + | "triakisIcosahedron" + | "tetrakisHexahedron" + | "pentakisDodecahedron" + | "disdyakisDodecahedron" + | "disdyakisTriacontahedron" + | "deltoidalIcositetrahedron" + | "deltoidalHexecontahedron" + | "pentagonalIcositetrahedron" + | "pentagonalHexecontahedron" + | "sphere" + | "cylinder" + | "cone" + | "torus" + | "pyramid" + | "prism" + | "antiprism" + | "bipyramid" + | "trapezohedron"; + +export interface GlyphGeometryOptions { + center?: Vec3; + size?: number; + color?: string; +} + +/** + * Resolve a built-in geometry name to a `Polygon[]` list. + * + * Precedence for mesh sources: explicit `polygons` > `src` > `geometry`. + * When both `src` and `geometry` are supplied, `src` wins silently. + */ +export function resolveGeometry( + name: GlyphGeometryName, + opts: GlyphGeometryOptions = {}, +): Polygon[] { + const { center = [0, 0, 0] as Vec3, size = 1, color } = opts; + switch (name) { + case "tetrahedron": return tetrahedronPolygons({ center, size, color }); + case "cube": return cubePolygons({ center, size, color }); + case "octahedron": return octahedronPolygons({ center, size, color }); + case "dodecahedron": return dodecahedronPolygons({ center, size, color }); + case "icosahedron": return icosahedronPolygons({ center, size, color }); + case "smallStellatedDodecahedron": return smallStellatedDodecahedronPolygons({ center, size, color }); + case "greatDodecahedron": return greatDodecahedronPolygons({ center, size, color }); + case "greatStellatedDodecahedron": return greatStellatedDodecahedronPolygons({ center, size, color }); + case "greatIcosahedron": return greatIcosahedronPolygons({ center, size, color }); + case "cuboctahedron": return cuboctahedronPolygons({ center, size, color }); + case "icosidodecahedron": return icosidodecahedronPolygons({ center, size, color }); + case "truncatedTetrahedron": return truncatedTetrahedronPolygons({ center, size, color }); + case "truncatedCube": return truncatedCubePolygons({ center, size, color }); + case "truncatedOctahedron": return truncatedOctahedronPolygons({ center, size, color }); + case "truncatedDodecahedron": return truncatedDodecahedronPolygons({ center, size, color }); + case "truncatedIcosahedron": return truncatedIcosahedronPolygons({ center, size, color }); + case "truncatedCuboctahedron": return truncatedCuboctahedronPolygons({ center, size, color }); + case "truncatedIcosidodecahedron": return truncatedIcosidodecahedronPolygons({ center, size, color }); + case "rhombicuboctahedron": return rhombicuboctahedronPolygons({ center, size, color }); + case "rhombicosidodecahedron": return rhombicosidodecahedronPolygons({ center, size, color }); + case "snubCube": return snubCubePolygons({ center, size, color }); + case "snubDodecahedron": return snubDodecahedronPolygons({ center, size, color }); + case "rhombicDodecahedron": return rhombicDodecahedronPolygons({ center, size, color }); + case "rhombicTriacontahedron": return rhombicTriacontahedronPolygons({ center, size, color }); + case "triakisTetrahedron": return triakisTetrahedronPolygons({ center, size, color }); + case "triakisOctahedron": return triakisOctahedronPolygons({ center, size, color }); + case "triakisIcosahedron": return triakisIcosahedronPolygons({ center, size, color }); + case "tetrakisHexahedron": return tetrakisHexahedronPolygons({ center, size, color }); + case "pentakisDodecahedron": return pentakisDodecahedronPolygons({ center, size, color }); + case "disdyakisDodecahedron": return disdyakisDodecahedronPolygons({ center, size, color }); + case "disdyakisTriacontahedron": return disdyakisTriacontahedronPolygons({ center, size, color }); + case "deltoidalIcositetrahedron": return deltoidalIcositetrahedronPolygons({ center, size, color }); + case "deltoidalHexecontahedron": return deltoidalHexecontahedronPolygons({ center, size, color }); + case "pentagonalIcositetrahedron": return pentagonalIcositetrahedronPolygons({ center, size, color }); + case "pentagonalHexecontahedron": return pentagonalHexecontahedronPolygons({ center, size, color }); + case "sphere": return spherePolygons({ center, size, color }); + case "cylinder": return cylinderPolygons({ center, radius: size, height: size * 2, color }); + case "cone": return conePolygons({ center, radius: size, height: size * 2, color }); + case "torus": return torusPolygons({ center, majorRadius: size, minorRadius: size * 0.3, color }); + case "pyramid": return pyramidPolygons({ center, radius: size, height: size * 2, color }); + case "prism": return prismPolygons({ center, radius: size, height: size * 2, color }); + case "antiprism": return antiprismPolygons({ center, radius: size, height: size * 2, color }); + case "bipyramid": return bipyramidPolygons({ center, radius: size, halfHeight: size, color }); + case "trapezohedron": return trapezohedronPolygons({ center, radius: size, halfHeight: size, color }); + default: { + const _exhaust: never = name; + throw new Error(`Unknown geometry: ${String(_exhaust)}`); + } + } +} diff --git a/packages/core/src/helpers/greatDodecahedronPolygons.ts b/packages/core/src/helpers/greatDodecahedronPolygons.ts new file mode 100644 index 00000000..229c33d9 --- /dev/null +++ b/packages/core/src/helpers/greatDodecahedronPolygons.ts @@ -0,0 +1,138 @@ +/** + * Geometry for a great dodecahedron — Schläfli symbol {5, 5/2}. + * 12 convex pentagonal faces on the 12 icosahedron vertices; each face is the + * convex pentagon formed by the 5 nearest vertices to one icosahedron vertex + * (same vertex group as the small stellated dodecahedron, but the 5 outer + * points are emitted in natural angular order rather than pentagram-skip + * order). The rasterizer fan-triangulates convex pentagons correctly from + * vertex 0 without any special decomposition. + * + * Vertices: the 12 standard icosahedron vertices (circumradius = `size`). + */ +import type { Polygon, Vec3 } from "../types"; + +export interface GreatDodecahedronPolygonsOptions { + /** Center of the polyhedron in world space. */ + center: Vec3; + /** Circumradius — distance from center to each vertex. */ + size: number; + /** Fill color applied to all faces. */ + color?: string; +} + +export function greatDodecahedronPolygons( + options: GreatDodecahedronPolygonsOptions +): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + const phi = (1 + Math.sqrt(5)) / 2; + const s = size / Math.sqrt(1 + phi * phi); + + // 12 icosahedron vertices (same ordering as icosahedronPolygons.ts). + const raw: [number, number, number][] = [ + [ 0, -1, -phi], // 0 + [ 0, -1, phi], // 1 + [ 0, 1, -phi], // 2 + [ 0, 1, phi], // 3 + [-1, -phi, 0], // 4 + [-1, phi, 0], // 5 + [ 1, -phi, 0], // 6 + [ 1, phi, 0], // 7 + [-phi, 0, -1], // 8 + [ phi, 0, -1], // 9 + [-phi, 0, 1], // 10 + [ phi, 0, 1], // 11 + ]; + + const v: Vec3[] = raw.map(([x, y, z]) => [cx + x * s, cy + y * s, cz + z * s]); + + function dot3(a: [number, number, number], b: [number, number, number]): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + } + + function perpBasis(axis: [number, number, number]): { + u: [number, number, number]; + w: [number, number, number]; + } { + const ref: [number, number, number] = + Math.abs(axis[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]; + const d = dot3(ref, axis); + const ux = ref[0] - d * axis[0]; + const uy = ref[1] - d * axis[1]; + const uz = ref[2] - d * axis[2]; + const ul = Math.sqrt(ux * ux + uy * uy + uz * uz); + const u: [number, number, number] = [ux / ul, uy / ul, uz / ul]; + const w: [number, number, number] = [ + axis[1] * u[2] - axis[2] * u[1], + axis[2] * u[0] - axis[0] * u[2], + axis[0] * u[1] - axis[1] * u[0], + ]; + return { u, w }; + } + + const polygons: Polygon[] = []; + + for (let c = 0; c < 12; c++) { + const axis = raw[c]; + const axisLenSq = dot3(axis, axis); + + // Find antipode. + let antipode = -1; + let minDot = Infinity; + for (let i = 0; i < 12; i++) { + if (i === c) continue; + const d = dot3(raw[i], axis); + if (d < minDot) { minDot = d; antipode = i; } + } + + // 5 nearest vertices to c (excluding c and its antipode). + const others: { idx: number; d: number }[] = []; + for (let i = 0; i < 12; i++) { + if (i === c || i === antipode) continue; + others.push({ idx: i, d: dot3(raw[i], axis) }); + } + others.sort((a, b) => b.d - a.d); + const nearFive = others.slice(0, 5).map((o) => o.idx); + + // Sort by angle for the convex pentagon winding (natural angular order). + const normAxis: [number, number, number] = [ + axis[0] / Math.sqrt(axisLenSq), + axis[1] / Math.sqrt(axisLenSq), + axis[2] / Math.sqrt(axisLenSq), + ]; + const { u, w } = perpBasis(normAxis); + nearFive.sort((a, b) => { + const ra = raw[a]; + const rb = raw[b]; + return Math.atan2(dot3(ra, w), dot3(ra, u)) - Math.atan2(dot3(rb, w), dot3(rb, u)); + }); + + // Emit as a 5-vertex convex polygon. Check CCW winding from outside. + // "Outside" = away from the polyhedron centroid (cx, cy, cz). + // Pentagon centroid: + let pcx = 0, pcy = 0, pcz = 0; + for (const i of nearFive) { pcx += v[i][0]; pcy += v[i][1]; pcz += v[i][2]; } + pcx /= 5; pcy /= 5; pcz /= 5; + + // Normal of the face (cross product of first two edges of the fan). + const p0 = v[nearFive[0]]; + const p1 = v[nearFive[1]]; + const p2 = v[nearFive[2]]; + const e1x = p1[0] - p0[0], e1y = p1[1] - p0[1], e1z = p1[2] - p0[2]; + const e2x = p2[0] - p0[0], e2y = p2[1] - p0[1], e2z = p2[2] - p0[2]; + const nx = e1y * e2z - e1z * e2y; + const ny = e1z * e2x - e1x * e2z; + const nz = e1x * e2y - e1y * e2x; + const outX = pcx - cx, outY = pcy - cy, outZ = pcz - cz; + const flip = nx * outX + ny * outY + nz * outZ < 0; + + const ordered = flip ? [...nearFive].reverse() : nearFive; + polygons.push({ + vertices: ordered.map((i) => v[i]) as Vec3[], + color, + }); + } + + return polygons; +} diff --git a/packages/core/src/helpers/greatIcosahedronPolygons.ts b/packages/core/src/helpers/greatIcosahedronPolygons.ts new file mode 100644 index 00000000..d5adb0d5 --- /dev/null +++ b/packages/core/src/helpers/greatIcosahedronPolygons.ts @@ -0,0 +1,94 @@ +/** + * Geometry for a great icosahedron — Schläfli symbol {3, 5/2}. + * 20 triangular faces on the 12 icosahedron vertices in a non-convex + * configuration. Each face is derived from the corresponding icosahedron face + * by replacing every vertex with its antipode (the diametrically opposite + * vertex on the circumscribed sphere), then reversing the winding to restore + * CCW orientation (antipode-flipping mirrors the original CCW winding to CW). + * + * Vertices: the 12 standard icosahedron vertices (circumradius = `size`). + * Face table: antipode-flipped icosahedron faces with winding reversed — + * yields 20 distinct, non-degenerate triangles with correct outward normals. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface GreatIcosahedronPolygonsOptions { + /** Center of the polyhedron in world space. */ + center: Vec3; + /** Circumradius — distance from center to each vertex. */ + size: number; + /** Fill color applied to all faces. */ + color?: string; +} + +export function greatIcosahedronPolygons( + options: GreatIcosahedronPolygonsOptions +): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + const phi = (1 + Math.sqrt(5)) / 2; + const s = size / Math.sqrt(1 + phi * phi); + + // 12 icosahedron vertices (same ordering as icosahedronPolygons.ts). + const raw: [number, number, number][] = [ + [ 0, -1, -phi], // 0 + [ 0, -1, phi], // 1 + [ 0, 1, -phi], // 2 + [ 0, 1, phi], // 3 + [-1, -phi, 0], // 4 + [-1, phi, 0], // 5 + [ 1, -phi, 0], // 6 + [ 1, phi, 0], // 7 + [-phi, 0, -1], // 8 + [ phi, 0, -1], // 9 + [-phi, 0, 1], // 10 + [ phi, 0, 1], // 11 + ]; + + const v: Vec3[] = raw.map(([x, y, z]) => [cx + x * s, cy + y * s, cz + z * s]); + + // The 20 icosahedron faces (same table as icosahedronPolygons.ts, CCW from outside). + const icoFaces: [number, number, number][] = [ + [ 0, 2, 9], + [ 0, 4, 8], + [ 0, 6, 4], + [ 0, 8, 2], + [ 0, 9, 6], + [ 1, 3, 10], + [ 1, 4, 6], + [ 1, 6, 11], + [ 1, 10, 4], + [ 1, 11, 3], + [ 2, 5, 7], + [ 2, 7, 9], + [ 2, 8, 5], + [ 3, 5, 10], + [ 3, 7, 5], + [ 3, 11, 7], + [ 4, 10, 8], + [ 5, 8, 10], + [ 6, 9, 11], + [ 7, 11, 9], + ]; + + // Build antipode map: for each vertex, find the index with the most-negative + // dot product (i.e. the diametrically opposite vertex). + const antipode: number[] = Array(12).fill(-1); + for (let i = 0; i < 12; i++) { + let minDot = Infinity; + for (let j = 0; j < 12; j++) { + if (j === i) continue; + const d = raw[i][0] * raw[j][0] + raw[i][1] * raw[j][1] + raw[i][2] * raw[j][2]; + if (d < minDot) { minDot = d; antipode[i] = j; } + } + } + + // For each icosahedron face (a,b,c), the great-icosahedron face is + // (antipode(a), antipode(c), antipode(b)) — reversed winding to compensate + // for the reflection inherent in antipode-flipping. + return icoFaces.map(([a, b, c]) => ({ + vertices: [v[antipode[a]], v[antipode[c]], v[antipode[b]]], + color, + })); +} diff --git a/packages/core/src/helpers/greatStellatedDodecahedronPolygons.ts b/packages/core/src/helpers/greatStellatedDodecahedronPolygons.ts new file mode 100644 index 00000000..6f5a5e50 --- /dev/null +++ b/packages/core/src/helpers/greatStellatedDodecahedronPolygons.ts @@ -0,0 +1,116 @@ +/** + * Geometry for a great stellated dodecahedron — Schläfli symbol {5/2, 3}. + * 12 pentagram faces on the 20 dodecahedron vertices; each pentagram uses + * the same 5-vertex grouping as the dodecahedron faces but reordered into + * pentagram (every-other-vertex) skip order before being decomposed into + * 5 triangles fanned from the face centroid (60 triangles total). + * + * Vertices: the 20 standard dodecahedron vertices (circumradius = `size`). + * Face groups: same 12 pentagonal groupings as dodecahedronPolygons.ts; + * the [0,2,4,1,3] permutation of the convex-pentagon order produces the + * pentagram skip that makes each face a 5-pointed star. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface GreatStellatedDodecahedronPolygonsOptions { + /** Center of the polyhedron in world space. */ + center: Vec3; + /** Circumradius — distance from center to each vertex. */ + size: number; + /** Fill color applied to all faces. */ + color?: string; +} + +export function greatStellatedDodecahedronPolygons( + options: GreatStellatedDodecahedronPolygonsOptions +): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + const phi = (1 + Math.sqrt(5)) / 2; + const invPhi = 1 / phi; + const s = size / Math.sqrt(3); + + // 20 dodecahedron vertices (same ordering as dodecahedronPolygons.ts). + const raw: [number, number, number][] = [ + [-1, -1, -1], // 0 + [-1, -1, 1], // 1 + [-1, 1, -1], // 2 + [-1, 1, 1], // 3 + [ 1, -1, -1], // 4 + [ 1, -1, 1], // 5 + [ 1, 1, -1], // 6 + [ 1, 1, 1], // 7 + [ 0, -phi, -invPhi], // 8 + [ 0, -phi, invPhi], // 9 + [ 0, phi, -invPhi], // 10 + [ 0, phi, invPhi], // 11 + [-invPhi, 0, -phi], // 12 + [ invPhi, 0, -phi], // 13 + [-invPhi, 0, phi], // 14 + [ invPhi, 0, phi], // 15 + [-phi, -invPhi, 0], // 16 + [-phi, invPhi, 0], // 17 + [ phi, -invPhi, 0], // 18 + [ phi, invPhi, 0], // 19 + ]; + + const v: Vec3[] = raw.map(([x, y, z]) => [cx + x * s, cy + y * s, cz + z * s]); + + // The same 12 pentagonal faces as dodecahedronPolygons.ts (CCW convex order). + const dodecFaces: [number, number, number, number, number][] = [ + [ 0, 8, 9, 1, 16], + [ 0, 12, 13, 4, 8], + [ 0, 16, 17, 2, 12], + [ 1, 9, 5, 15, 14], + [ 1, 14, 3, 17, 16], + [ 2, 10, 6, 13, 12], + [ 2, 17, 3, 11, 10], + [ 3, 14, 15, 7, 11], + [ 4, 13, 6, 19, 18], + [ 4, 18, 5, 9, 8], + [ 5, 18, 19, 7, 15], + [ 6, 10, 11, 7, 19], + ]; + + // Pentagram skip order: [0,2,4,1,3] of the convex-pentagon vertex order. + const STAR_PERM = [0, 2, 4, 1, 3] as const; + + const polygons: Polygon[] = []; + + for (const face of dodecFaces) { + // Reorder to pentagram (every-other-vertex) skip order. + const outerIdx = STAR_PERM.map((i) => face[i]); + + // Centroid of the 5 outer points (world space). + let gcx = 0, gcy = 0, gcz = 0; + for (const i of outerIdx) { + gcx += v[i][0]; gcy += v[i][1]; gcz += v[i][2]; + } + const centroid: Vec3 = [gcx / 5, gcy / 5, gcz / 5]; + + // 5 triangles: [centroid, outer_i, outer_{(i+2)%5}]. + for (let i = 0; i < 5; i++) { + const a = v[outerIdx[i]]; + const b = v[outerIdx[(i + 2) % 5]]; + + // Orient normal away from polyhedron centroid. + const ea: Vec3 = [a[0] - centroid[0], a[1] - centroid[1], a[2] - centroid[2]]; + const eb: Vec3 = [b[0] - centroid[0], b[1] - centroid[1], b[2] - centroid[2]]; + const nx = ea[1] * eb[2] - ea[2] * eb[1]; + const ny = ea[2] * eb[0] - ea[0] * eb[2]; + const nz = ea[0] * eb[1] - ea[1] * eb[0]; + const outX = centroid[0] - cx; + const outY = centroid[1] - cy; + const outZ = centroid[2] - cz; + const flip = nx * outX + ny * outY + nz * outZ < 0; + + polygons.push({ + vertices: flip ? [centroid, b, a] : [centroid, a, b], + color, + }); + } + } + + return polygons; +} diff --git a/packages/core/src/helpers/helpers.test.ts b/packages/core/src/helpers/helpers.test.ts index 3a74efe8..42959116 100644 --- a/packages/core/src/helpers/helpers.test.ts +++ b/packages/core/src/helpers/helpers.test.ts @@ -1,10 +1,49 @@ import { describe, expect, it } from "vitest"; +import { cuboctahedronPolygons } from "./cuboctahedronPolygons"; +import { icosidodecahedronPolygons } from "./icosidodecahedronPolygons"; +import { truncatedTetrahedronPolygons } from "./truncatedTetrahedronPolygons"; +import { truncatedCubePolygons } from "./truncatedCubePolygons"; +import { truncatedOctahedronPolygons } from "./truncatedOctahedronPolygons"; +import { truncatedDodecahedronPolygons } from "./truncatedDodecahedronPolygons"; +import { truncatedIcosahedronPolygons } from "./truncatedIcosahedronPolygons"; +import { truncatedCuboctahedronPolygons } from "./truncatedCuboctahedronPolygons"; +import { truncatedIcosidodecahedronPolygons } from "./truncatedIcosidodecahedronPolygons"; +import { rhombicuboctahedronPolygons } from "./rhombicuboctahedronPolygons"; +import { rhombicosidodecahedronPolygons } from "./rhombicosidodecahedronPolygons"; +import { snubCubePolygons } from "./snubCubePolygons"; +import { snubDodecahedronPolygons } from "./snubDodecahedronPolygons"; +import { smallStellatedDodecahedronPolygons } from "./smallStellatedDodecahedronPolygons"; +import { greatDodecahedronPolygons } from "./greatDodecahedronPolygons"; +import { greatStellatedDodecahedronPolygons } from "./greatStellatedDodecahedronPolygons"; +import { greatIcosahedronPolygons } from "./greatIcosahedronPolygons"; import { axesHelperPolygons } from "./axesPolygons"; import { octahedronPolygons } from "./octahedronPolygons"; import { tetrahedronPolygons } from "./tetrahedronPolygons"; import { cubePolygons } from "./cubePolygons"; import { dodecahedronPolygons } from "./dodecahedronPolygons"; import { icosahedronPolygons } from "./icosahedronPolygons"; +import { spherePolygons } from "./spherePolygons"; +import { cylinderPolygons } from "./cylinderPolygons"; +import { conePolygons } from "./conePolygons"; +import { torusPolygons } from "./torusPolygons"; +import { pyramidPolygons } from "./pyramidPolygons"; +import { prismPolygons } from "./prismPolygons"; +import { antiprismPolygons } from "./antiprismPolygons"; +import { bipyramidPolygons } from "./bipyramidPolygons"; +import { trapezohedronPolygons } from "./trapezohedronPolygons"; +import { rhombicDodecahedronPolygons } from "./rhombicDodecahedronPolygons"; +import { rhombicTriacontahedronPolygons } from "./rhombicTriacontahedronPolygons"; +import { triakisTetrahedronPolygons } from "./triakisTetrahedronPolygons"; +import { triakisOctahedronPolygons } from "./triakisOctahedronPolygons"; +import { tetrakisHexahedronPolygons } from "./tetrakisHexahedronPolygons"; +import { triakisIcosahedronPolygons } from "./triakisIcosahedronPolygons"; +import { pentakisDodecahedronPolygons } from "./pentakisDodecahedronPolygons"; +import { disdyakisDodecahedronPolygons } from "./disdyakisDodecahedronPolygons"; +import { disdyakisTriacontahedronPolygons } from "./disdyakisTriacontahedronPolygons"; +import { deltoidalIcositetrahedronPolygons } from "./deltoidalIcositetrahedronPolygons"; +import { deltoidalHexecontahedronPolygons } from "./deltoidalHexecontahedronPolygons"; +import { pentagonalIcositetrahedronPolygons } from "./pentagonalIcositetrahedronPolygons"; +import { pentagonalHexecontahedronPolygons } from "./pentagonalHexecontahedronPolygons"; describe("axesHelperPolygons", () => { it("returns 18 quads (6 per axis × 3 axes)", () => { @@ -306,3 +345,2036 @@ describe("icosahedronPolygons", () => { for (const p of polygons) expect(p.color).toBe("#ffffff"); }); }); + +describe("spherePolygons", () => { + it("returns 80 triangles at default subdivisions=1", () => { + const polygons = spherePolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(80); + for (const p of polygons) expect(p.vertices).toHaveLength(3); + }); + + it("returns 20 triangles at subdivisions=0 (bare icosahedron)", () => { + const polygons = spherePolygons({ center: [0, 0, 0], size: 1, subdivisions: 0 }); + expect(polygons).toHaveLength(20); + }); + + it("returns 320 triangles at subdivisions=2", () => { + const polygons = spherePolygons({ center: [0, 0, 0], size: 1, subdivisions: 2 }); + expect(polygons).toHaveLength(320); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = spherePolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("every vertex lies exactly on the sphere surface (distance from center == size)", () => { + const size = 2.5; + const polygons = spherePolygons({ center: [0, 0, 0], size }); + for (const p of polygons) { + for (const [x, y, z] of p.vertices) { + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 8); + } + } + }); + + it("center offset shifts all vertices by that offset", () => { + const offset: [number, number, number] = [3, -1, 5]; + const base = spherePolygons({ center: [0, 0, 0], size: 1 }); + const moved = spherePolygons({ center: offset, size: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + const bCx = allBase.reduce((s, v) => s + v[0], 0) / n; + const bCy = allBase.reduce((s, v) => s + v[1], 0) / n; + const bCz = allBase.reduce((s, v) => s + v[2], 0) / n; + const mCx = allMoved.reduce((s, v) => s + v[0], 0) / n; + const mCy = allMoved.reduce((s, v) => s + v[1], 0) / n; + const mCz = allMoved.reduce((s, v) => s + v[2], 0) / n; + expect(mCx - bCx).toBeCloseTo(3, 5); + expect(mCy - bCy).toBeCloseTo(-1, 5); + expect(mCz - bCz).toBeCloseTo(5, 5); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = spherePolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = spherePolygons({ center: [0, 0, 0], size: 1, color: "#123456" }); + for (const p of colorPolygons) expect(p.color).toBe("#123456"); + }); +}); + +describe("cylinderPolygons", () => { + it("returns sides + 2 polygons at defaults (16 + 2 = 18)", () => { + const polygons = cylinderPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + expect(polygons).toHaveLength(18); + }); + + it("respects a custom sides value", () => { + const polygons = cylinderPolygons({ center: [0, 0, 0], radius: 1, height: 2, sides: 8 }); + expect(polygons).toHaveLength(10); // 8 + 2 + }); + + it("side quads have 4 vertices, caps have sides vertices", () => { + const sides = 10; + const polygons = cylinderPolygons({ center: [0, 0, 0], radius: 1, height: 2, sides }); + for (let i = 0; i < sides; i++) expect(polygons[i].vertices).toHaveLength(4); + expect(polygons[sides].vertices).toHaveLength(sides); // top cap + expect(polygons[sides + 1].vertices).toHaveLength(sides); // bottom cap + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = cylinderPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("center offset shifts all vertex centroids", () => { + const offset: [number, number, number] = [1, 2, 3]; + const base = cylinderPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + const moved = cylinderPolygons({ center: offset, radius: 1, height: 2 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(1, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(2, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(3, 5); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = cylinderPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = cylinderPolygons({ center: [0, 0, 0], radius: 1, height: 2, color: "#aabbcc" }); + for (const p of colorPolygons) expect(p.color).toBe("#aabbcc"); + }); +}); + +describe("conePolygons", () => { + it("returns sides + 1 polygons at defaults (16 + 1 = 17)", () => { + const polygons = conePolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + expect(polygons).toHaveLength(17); + }); + + it("respects a custom sides value", () => { + const polygons = conePolygons({ center: [0, 0, 0], radius: 1, height: 2, sides: 6 }); + expect(polygons).toHaveLength(7); // 6 + 1 + }); + + it("side faces are triangles, base cap is an N-gon", () => { + const sides = 8; + const polygons = conePolygons({ center: [0, 0, 0], radius: 1, height: 2, sides }); + for (let i = 0; i < sides; i++) expect(polygons[i].vertices).toHaveLength(3); + expect(polygons[sides].vertices).toHaveLength(sides); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = conePolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("center offset shifts all vertex centroids", () => { + const offset: [number, number, number] = [5, 0, -2]; + const base = conePolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + const moved = conePolygons({ center: offset, radius: 1, height: 2 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(5, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(-2, 5); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = conePolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = conePolygons({ center: [0, 0, 0], radius: 1, height: 2, color: "#ff0000" }); + for (const p of colorPolygons) expect(p.color).toBe("#ff0000"); + }); +}); + +describe("torusPolygons", () => { + it("returns segments * sides quads at defaults (24 * 12 = 288)", () => { + const polygons = torusPolygons({ center: [0, 0, 0], majorRadius: 2, minorRadius: 0.5 }); + expect(polygons).toHaveLength(288); + for (const p of polygons) expect(p.vertices).toHaveLength(4); + }); + + it("respects custom segments and sides values", () => { + const polygons = torusPolygons({ center: [0, 0, 0], majorRadius: 2, minorRadius: 0.5, segments: 8, sides: 6 }); + expect(polygons).toHaveLength(48); // 8 * 6 + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = torusPolygons({ center: [0, 0, 0], majorRadius: 2, minorRadius: 0.5 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("center offset shifts all vertex centroids", () => { + const offset: [number, number, number] = [2, 4, -3]; + const base = torusPolygons({ center: [0, 0, 0], majorRadius: 2, minorRadius: 0.5 }); + const moved = torusPolygons({ center: offset, majorRadius: 2, minorRadius: 0.5 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(2, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(4, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(-3, 5); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = torusPolygons({ center: [0, 0, 0], majorRadius: 2, minorRadius: 0.5 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = torusPolygons({ center: [0, 0, 0], majorRadius: 2, minorRadius: 0.5, color: "#00ff00" }); + for (const p of colorPolygons) expect(p.color).toBe("#00ff00"); + }); +}); + +describe("pyramidPolygons", () => { + it("returns sides + 1 polygons at defaults (4 + 1 = 5, square pyramid)", () => { + const polygons = pyramidPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + expect(polygons).toHaveLength(5); + }); + + it("respects a custom sides value", () => { + const polygons = pyramidPolygons({ center: [0, 0, 0], radius: 1, height: 2, sides: 6 }); + expect(polygons).toHaveLength(7); // 6 + 1 + }); + + it("side faces are triangles, base cap is an N-gon", () => { + const sides = 5; + const polygons = pyramidPolygons({ center: [0, 0, 0], radius: 1, height: 2, sides }); + for (let i = 0; i < sides; i++) expect(polygons[i].vertices).toHaveLength(3); + expect(polygons[sides].vertices).toHaveLength(sides); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = pyramidPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("center offset shifts all vertex centroids", () => { + const offset: [number, number, number] = [-1, 3, 2]; + const base = pyramidPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + const moved = pyramidPolygons({ center: offset, radius: 1, height: 2 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(-1, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(3, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(2, 5); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = pyramidPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = pyramidPolygons({ center: [0, 0, 0], radius: 1, height: 2, color: "#654321" }); + for (const p of colorPolygons) expect(p.color).toBe("#654321"); + }); +}); + +describe("prismPolygons", () => { + it("returns sides + 2 polygons at defaults (6 + 2 = 8)", () => { + const polygons = prismPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + expect(polygons).toHaveLength(8); + }); + + it("respects sides=3 (triangular prism, 5 polygons)", () => { + const polygons = prismPolygons({ center: [0, 0, 0], radius: 1, height: 2, sides: 3 }); + expect(polygons).toHaveLength(5); // 3 + 2 + }); + + it("respects sides=8 (octagonal prism, 10 polygons)", () => { + const polygons = prismPolygons({ center: [0, 0, 0], radius: 1, height: 2, sides: 8 }); + expect(polygons).toHaveLength(10); // 8 + 2 + }); + + it("side faces are quads, cap faces are N-gons", () => { + const sides = 6; + const polygons = prismPolygons({ center: [0, 0, 0], radius: 1, height: 2, sides }); + for (let i = 0; i < sides; i++) expect(polygons[i].vertices).toHaveLength(4); + expect(polygons[sides].vertices).toHaveLength(sides); // top cap + expect(polygons[sides + 1].vertices).toHaveLength(sides); // bottom cap + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = prismPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = prismPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = prismPolygons({ center: [0, 0, 0], radius: 1, height: 2, color: "#aabbcc" }); + for (const p of colorPolygons) expect(p.color).toBe("#aabbcc"); + }); + + it("center offset shifts the bounding box centroid by exactly the offset", () => { + const offset: [number, number, number] = [3, -1, 2]; + const base = prismPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + const moved = prismPolygons({ center: offset, radius: 1, height: 2 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(3, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(-1, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(2, 5); + }); +}); + +describe("antiprismPolygons", () => { + it("returns 2*sides + 2 polygons at defaults (2*6 + 2 = 14)", () => { + const polygons = antiprismPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + expect(polygons).toHaveLength(14); + }); + + it("respects sides=3 (triangular antiprism, 8 polygons)", () => { + const polygons = antiprismPolygons({ center: [0, 0, 0], radius: 1, height: 2, sides: 3 }); + expect(polygons).toHaveLength(8); // 2*3 + 2 + }); + + it("respects sides=8 (octagonal antiprism, 18 polygons)", () => { + const polygons = antiprismPolygons({ center: [0, 0, 0], radius: 1, height: 2, sides: 8 }); + expect(polygons).toHaveLength(18); // 2*8 + 2 + }); + + it("side faces are triangles, cap faces are N-gons", () => { + const sides = 6; + const polygons = antiprismPolygons({ center: [0, 0, 0], radius: 1, height: 2, sides }); + for (let i = 0; i < 2 * sides; i++) expect(polygons[i].vertices).toHaveLength(3); + expect(polygons[2 * sides].vertices).toHaveLength(sides); // top cap + expect(polygons[2 * sides + 1].vertices).toHaveLength(sides); // bottom cap + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = antiprismPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = antiprismPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = antiprismPolygons({ center: [0, 0, 0], radius: 1, height: 2, color: "#112233" }); + for (const p of colorPolygons) expect(p.color).toBe("#112233"); + }); + + it("center offset shifts the bounding box centroid by exactly the offset", () => { + const offset: [number, number, number] = [1, 4, -2]; + const base = antiprismPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + const moved = antiprismPolygons({ center: offset, radius: 1, height: 2 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(1, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(4, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(-2, 5); + }); +}); + +describe("bipyramidPolygons", () => { + it("returns 2*sides polygons at defaults (2*6 = 12)", () => { + const polygons = bipyramidPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1 }); + expect(polygons).toHaveLength(12); + }); + + it("respects sides=3 (triangular bipyramid, 6 polygons)", () => { + const polygons = bipyramidPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1, sides: 3 }); + expect(polygons).toHaveLength(6); // 2*3 + }); + + it("respects sides=8 (octagonal bipyramid, 16 polygons)", () => { + const polygons = bipyramidPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1, sides: 8 }); + expect(polygons).toHaveLength(16); // 2*8 + }); + + it("every face is a triangle", () => { + const polygons = bipyramidPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1 }); + for (const p of polygons) expect(p.vertices).toHaveLength(3); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = bipyramidPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = bipyramidPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = bipyramidPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1, color: "#ff8800" }); + for (const p of colorPolygons) expect(p.color).toBe("#ff8800"); + }); + + it("center offset shifts the bounding box centroid by exactly the offset", () => { + const offset: [number, number, number] = [-2, 5, 1]; + const base = bipyramidPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1 }); + const moved = bipyramidPolygons({ center: offset, radius: 1, halfHeight: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(-2, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(5, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(1, 5); + }); +}); + +describe("trapezohedronPolygons", () => { + it("returns 2*sides polygons at defaults (2*5 = 10)", () => { + const polygons = trapezohedronPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1 }); + expect(polygons).toHaveLength(10); + }); + + it("respects sides=3 (trigonal trapezohedron, 6 polygons)", () => { + const polygons = trapezohedronPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1, sides: 3 }); + expect(polygons).toHaveLength(6); // 2*3 + }); + + it("respects sides=8 (octagonal trapezohedron, 16 polygons)", () => { + const polygons = trapezohedronPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1, sides: 8 }); + expect(polygons).toHaveLength(16); // 2*8 + }); + + it("every face is a kite (4 vertices)", () => { + const polygons = trapezohedronPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1 }); + for (const p of polygons) expect(p.vertices).toHaveLength(4); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = trapezohedronPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = trapezohedronPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = trapezohedronPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1, color: "#abcdef" }); + for (const p of colorPolygons) expect(p.color).toBe("#abcdef"); + }); + + it("center offset shifts the bounding box centroid by exactly the offset", () => { + const offset: [number, number, number] = [2, -3, 4]; + const base = trapezohedronPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1 }); + const moved = trapezohedronPolygons({ center: offset, radius: 1, halfHeight: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(2, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(-3, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(4, 5); + }); + + it("each kite face is planar (4th vertex lies on the plane of the first 3)", () => { + const polygons = trapezohedronPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1 }); + for (const p of polygons) { + const [a, b, c, d] = p.vertices as [[number,number,number],[number,number,number],[number,number,number],[number,number,number]]; + // Normal from first 3 vertices + const ab: [number,number,number] = [b[0]-a[0], b[1]-a[1], b[2]-a[2]]; + const ac: [number,number,number] = [c[0]-a[0], c[1]-a[1], c[2]-a[2]]; + const nx = ab[1]*ac[2] - ab[2]*ac[1]; + const ny = ab[2]*ac[0] - ab[0]*ac[2]; + const nz = ab[0]*ac[1] - ab[1]*ac[0]; + // Signed distance of 4th vertex from the plane + const ad: [number,number,number] = [d[0]-a[0], d[1]-a[1], d[2]-a[2]]; + const dot = ad[0]*nx + ad[1]*ny + ad[2]*nz; + const len = Math.sqrt(nx*nx + ny*ny + nz*nz); + const residual = Math.abs(dot) / (len || 1); + expect(residual).toBeLessThan(1e-6); + } + }); +}); + +// ── Winding correctness tests (Bug 1 guard) ─────────────────────────────── +// +// For each axially-symmetric helper we verify that the face normal of the +// first SIDE face (index 0) points OUTWARD — i.e. the dot product of the +// face normal with the face centroid (radial direction) is positive. +// +// Convention: outward normal = (B - A) × (C - A) for CCW winding from outside. + +function crossProduct( + a: [number, number, number], + b: [number, number, number], + c: [number, number, number], +): [number, number, number] { + const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2]; + const vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2]; + return [uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx]; +} + +function faceCentroid(verts: readonly [number, number, number][]): [number, number, number] { + const n = verts.length; + return [ + verts.reduce((s, v) => s + v[0], 0) / n, + verts.reduce((s, v) => s + v[1], 0) / n, + verts.reduce((s, v) => s + v[2], 0) / n, + ]; +} + +/** Positive dot → normal points outward (same general direction as centroid from origin). */ +function normalDotCentroid(poly: { vertices: [number, number, number][] }): number { + const [a, b, c] = poly.vertices as [number, number, number][]; + const n = crossProduct(a, b, c); + const cen = faceCentroid(poly.vertices as [number, number, number][]); + return n[0] * cen[0] + n[1] * cen[1] + n[2] * cen[2]; +} + +describe("conePolygons — outward normals", () => { + it("side triangle [0] has an outward-facing normal", () => { + const polygons = conePolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + // polygons[0] is the first side triangle; its centroid is in the +X half-space. + expect(normalDotCentroid(polygons[0] as { vertices: [number, number, number][] })).toBeGreaterThan(0); + }); + + it("all side triangles have outward-facing normals", () => { + const polygons = conePolygons({ center: [0, 0, 0], radius: 1, height: 2, sides: 8 }); + for (let i = 0; i < 8; i++) { + expect(normalDotCentroid(polygons[i] as { vertices: [number, number, number][] })).toBeGreaterThan(0); + } + }); +}); + +describe("pyramidPolygons — outward normals", () => { + it("side triangle [0] has an outward-facing normal", () => { + const polygons = pyramidPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + expect(normalDotCentroid(polygons[0] as { vertices: [number, number, number][] })).toBeGreaterThan(0); + }); + + it("all side triangles have outward-facing normals", () => { + const polygons = pyramidPolygons({ center: [0, 0, 0], radius: 1, height: 2, sides: 6 }); + for (let i = 0; i < 6; i++) { + expect(normalDotCentroid(polygons[i] as { vertices: [number, number, number][] })).toBeGreaterThan(0); + } + }); +}); + +describe("cylinderPolygons — outward normals", () => { + it("side quad [0] has an outward-facing normal", () => { + const polygons = cylinderPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + expect(normalDotCentroid(polygons[0] as { vertices: [number, number, number][] })).toBeGreaterThan(0); + }); + + it("all side quads have outward-facing normals", () => { + const polygons = cylinderPolygons({ center: [0, 0, 0], radius: 1, height: 2, sides: 8 }); + for (let i = 0; i < 8; i++) { + expect(normalDotCentroid(polygons[i] as { vertices: [number, number, number][] })).toBeGreaterThan(0); + } + }); +}); + +describe("prismPolygons — outward normals", () => { + it("side quad [0] has an outward-facing normal", () => { + const polygons = prismPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + expect(normalDotCentroid(polygons[0] as { vertices: [number, number, number][] })).toBeGreaterThan(0); + }); + + it("all side quads have outward-facing normals", () => { + const polygons = prismPolygons({ center: [0, 0, 0], radius: 1, height: 2, sides: 8 }); + for (let i = 0; i < 8; i++) { + expect(normalDotCentroid(polygons[i] as { vertices: [number, number, number][] })).toBeGreaterThan(0); + } + }); +}); + +describe("bipyramidPolygons — outward normals", () => { + it("first upper triangle has an outward-facing normal", () => { + const polygons = bipyramidPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1 }); + expect(normalDotCentroid(polygons[0] as { vertices: [number, number, number][] })).toBeGreaterThan(0); + }); + + it("first lower triangle has an outward-facing normal", () => { + const sides = 6; + const polygons = bipyramidPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1, sides }); + expect(normalDotCentroid(polygons[sides] as { vertices: [number, number, number][] })).toBeGreaterThan(0); + }); + + it("all faces have outward-facing normals", () => { + const polygons = bipyramidPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1, sides: 8 }); + for (const poly of polygons) { + expect(normalDotCentroid(poly as { vertices: [number, number, number][] })).toBeGreaterThan(0); + } + }); +}); + +describe("antiprismPolygons — outward normals", () => { + it("first up-triangle has an outward-facing normal", () => { + const polygons = antiprismPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + expect(normalDotCentroid(polygons[0] as { vertices: [number, number, number][] })).toBeGreaterThan(0); + }); + + it("first down-triangle has an outward-facing normal", () => { + const polygons = antiprismPolygons({ center: [0, 0, 0], radius: 1, height: 2 }); + expect(normalDotCentroid(polygons[1] as { vertices: [number, number, number][] })).toBeGreaterThan(0); + }); + + it("all side triangles have outward-facing normals", () => { + const sides = 6; + const polygons = antiprismPolygons({ center: [0, 0, 0], radius: 1, height: 2, sides }); + for (let i = 0; i < 2 * sides; i++) { + expect(normalDotCentroid(polygons[i] as { vertices: [number, number, number][] })).toBeGreaterThan(0); + } + }); +}); + +describe("trapezohedronPolygons — outward normals", () => { + it("first upper kite has an outward-facing normal", () => { + const polygons = trapezohedronPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1 }); + expect(normalDotCentroid(polygons[0] as { vertices: [number, number, number][] })).toBeGreaterThan(0); + }); + + it("first lower kite has an outward-facing normal", () => { + const sides = 5; + const polygons = trapezohedronPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1, sides }); + expect(normalDotCentroid(polygons[sides] as { vertices: [number, number, number][] })).toBeGreaterThan(0); + }); + + it("all faces have outward-facing normals", () => { + const polygons = trapezohedronPolygons({ center: [0, 0, 0], radius: 1, halfHeight: 1, sides: 6 }); + for (const poly of polygons) { + expect(normalDotCentroid(poly as { vertices: [number, number, number][] })).toBeGreaterThan(0); + } + }); +}); + +describe("smallStellatedDodecahedronPolygons", () => { + it("returns 60 triangular faces (12 pentagrams × 5 triangles)", () => { + const polygons = smallStellatedDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(60); + for (const p of polygons) expect(p.vertices).toHaveLength(3); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = smallStellatedDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = smallStellatedDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = smallStellatedDodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#abcdef" }); + for (const p of colorPolygons) expect(p.color).toBe("#abcdef"); + }); + + it("center offset shifts the bounding box centroid by the offset", () => { + const offset: [number, number, number] = [3, -2, 5]; + const base = smallStellatedDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = smallStellatedDodecahedronPolygons({ center: offset, size: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(3, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(-2, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(5, 5); + }); + + it("all non-centroid vertices lie on the circumscribing sphere (distance == size)", () => { + const size = 2; + const polygons = smallStellatedDodecahedronPolygons({ center: [0, 0, 0], size }); + // The centroid vertices sit strictly inside the sphere; the outer vertices + // (vertices 1 and 2 of each triangle) are icosahedron vertices on the sphere. + for (const p of polygons) { + for (const vtx of [p.vertices[1], p.vertices[2]]) { + const [x, y, z] = vtx; + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 5); + } + } + }); + + it("the 12 distinct pentagram centroids lie strictly inside the circumscribing sphere", () => { + const size = 1; + const polygons = smallStellatedDodecahedronPolygons({ center: [0, 0, 0], size }); + // vertex[0] of each triangle is the pentagram centroid; 5 triangles share + // each centroid so there are 12 distinct centroids. + const centroids = new Map(); + for (const p of polygons) { + const [x, y, z] = p.vertices[0]; + const key = `${x.toFixed(8)},${y.toFixed(8)},${z.toFixed(8)}`; + centroids.set(key, [x, y, z]); + } + expect(centroids.size).toBe(12); + for (const [x, y, z] of centroids.values()) { + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeLessThan(size); + } + }); +}); + +describe("greatDodecahedronPolygons", () => { + it("returns 12 pentagonal faces", () => { + const polygons = greatDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(12); + for (const p of polygons) expect(p.vertices).toHaveLength(5); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = greatDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = greatDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = greatDodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#123456" }); + for (const p of colorPolygons) expect(p.color).toBe("#123456"); + }); + + it("center offset shifts the bounding box centroid by the offset", () => { + const offset: [number, number, number] = [1, 4, -2]; + const base = greatDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = greatDodecahedronPolygons({ center: offset, size: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(1, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(4, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(-2, 5); + }); + + it("all vertices lie on the circumscribing sphere (distance == size)", () => { + const size = 3; + const polygons = greatDodecahedronPolygons({ center: [0, 0, 0], size }); + for (const p of polygons) { + for (const [x, y, z] of p.vertices) { + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 5); + } + } + }); +}); + +describe("greatStellatedDodecahedronPolygons", () => { + it("returns 60 triangular faces (12 pentagrams × 5 triangles)", () => { + const polygons = greatStellatedDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(60); + for (const p of polygons) expect(p.vertices).toHaveLength(3); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = greatStellatedDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = greatStellatedDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = greatStellatedDodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#ff8800" }); + for (const p of colorPolygons) expect(p.color).toBe("#ff8800"); + }); + + it("center offset shifts the bounding box centroid by the offset", () => { + const offset: [number, number, number] = [-1, 2, 4]; + const base = greatStellatedDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = greatStellatedDodecahedronPolygons({ center: offset, size: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(-1, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(2, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(4, 5); + }); + + it("all non-centroid vertices lie on the circumscribing sphere (distance == size)", () => { + const size = 2; + const polygons = greatStellatedDodecahedronPolygons({ center: [0, 0, 0], size }); + for (const p of polygons) { + for (const vtx of [p.vertices[1], p.vertices[2]]) { + const [x, y, z] = vtx; + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 5); + } + } + }); +}); + +describe("greatIcosahedronPolygons", () => { + it("returns 20 triangular faces", () => { + const polygons = greatIcosahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(20); + for (const p of polygons) expect(p.vertices).toHaveLength(3); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = greatIcosahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = greatIcosahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = greatIcosahedronPolygons({ center: [0, 0, 0], size: 1, color: "#ff0000" }); + for (const p of colorPolygons) expect(p.color).toBe("#ff0000"); + }); + + it("center offset shifts the bounding box centroid by the offset", () => { + const offset: [number, number, number] = [2, -3, 1]; + const base = greatIcosahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = greatIcosahedronPolygons({ center: offset, size: 1 }); + for (let i = 0; i < base.length; i++) { + for (let j = 0; j < 3; j++) { + expect(moved[i].vertices[j][0]).toBeCloseTo(base[i].vertices[j][0] + 2, 10); + expect(moved[i].vertices[j][1]).toBeCloseTo(base[i].vertices[j][1] - 3, 10); + expect(moved[i].vertices[j][2]).toBeCloseTo(base[i].vertices[j][2] + 1, 10); + } + } + }); + + it("all vertices lie on the circumscribing sphere (distance == size)", () => { + const size = 2.5; + const polygons = greatIcosahedronPolygons({ center: [0, 0, 0], size }); + for (const p of polygons) { + for (const [x, y, z] of p.vertices) { + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 8); + } + } + }); + + it("all 20 faces are non-degenerate (positive area)", () => { + const polygons = greatIcosahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + const [a, b, c] = p.vertices as [[number,number,number],[number,number,number],[number,number,number]]; + const abx = b[0]-a[0], aby = b[1]-a[1], abz = b[2]-a[2]; + const acx = c[0]-a[0], acy = c[1]-a[1], acz = c[2]-a[2]; + const nx = aby*acz - abz*acy; + const ny = abz*acx - abx*acz; + const nz = abx*acy - aby*acx; + const area2 = Math.sqrt(nx*nx + ny*ny + nz*nz); + expect(area2).toBeGreaterThan(1e-6); + } + }); +}); + +describe("cuboctahedronPolygons", () => { + it("returns 14 faces total (8 triangles + 6 squares)", () => { + const polygons = cuboctahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(14); + const tris = polygons.filter((p) => p.vertices.length === 3); + const quads = polygons.filter((p) => p.vertices.length === 4); + expect(tris).toHaveLength(8); + expect(quads).toHaveLength(6); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = cuboctahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("every vertex lies on the circumscribing sphere (distance from center == size)", () => { + const size = 2.5; + const polygons = cuboctahedronPolygons({ center: [0, 0, 0], size }); + for (const p of polygons) { + for (const [x, y, z] of p.vertices) { + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 8); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = cuboctahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = cuboctahedronPolygons({ center: [0, 0, 0], size: 1, color: "#aabbcc" }); + for (const p of colorPolygons) expect(p.color).toBe("#aabbcc"); + }); + + it("center offset shifts the bounding box centroid by the offset", () => { + const offset: [number, number, number] = [3, -1, 2]; + const base = cuboctahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = cuboctahedronPolygons({ center: offset, size: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(3, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(-1, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(2, 5); + }); + + it("all 14 faces have outward-facing normals (CCW winding from outside)", () => { + const polygons = cuboctahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const poly of polygons) { + expect(normalDotCentroid(poly as { vertices: [number, number, number][] })).toBeGreaterThan(0); + } + }); +}); + +describe("icosidodecahedronPolygons", () => { + it("returns 32 faces total (20 triangles + 12 pentagons)", () => { + const polygons = icosidodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(32); + const tris = polygons.filter((p) => p.vertices.length === 3); + const pentas = polygons.filter((p) => p.vertices.length === 5); + expect(tris).toHaveLength(20); + expect(pentas).toHaveLength(12); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = icosidodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("every vertex lies on the circumscribing sphere (distance from center == size)", () => { + const size = 3; + const polygons = icosidodecahedronPolygons({ center: [0, 0, 0], size }); + for (const p of polygons) { + for (const [x, y, z] of p.vertices) { + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 8); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = icosidodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = icosidodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#ff8800" }); + for (const p of colorPolygons) expect(p.color).toBe("#ff8800"); + }); + + it("center offset shifts the bounding box centroid by the offset", () => { + const offset: [number, number, number] = [2, -4, 1]; + const base = icosidodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = icosidodecahedronPolygons({ center: offset, size: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(2, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(-4, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(1, 5); + }); +}); + +describe("truncatedTetrahedronPolygons", () => { + it("returns 8 faces total (4 triangles + 4 hexagons)", () => { + const polygons = truncatedTetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(8); + const tris = polygons.filter((p) => p.vertices.length === 3); + const hexes = polygons.filter((p) => p.vertices.length === 6); + expect(tris).toHaveLength(4); + expect(hexes).toHaveLength(4); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = truncatedTetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("every vertex lies on the circumscribing sphere (distance from center == size)", () => { + const size = 2; + const polygons = truncatedTetrahedronPolygons({ center: [0, 0, 0], size }); + for (const p of polygons) { + for (const [x, y, z] of p.vertices) { + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 8); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = truncatedTetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = truncatedTetrahedronPolygons({ center: [0, 0, 0], size: 1, color: "#123456" }); + for (const p of colorPolygons) expect(p.color).toBe("#123456"); + }); + + it("center offset shifts the bounding box centroid by the offset", () => { + const offset: [number, number, number] = [1, 2, -3]; + const base = truncatedTetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = truncatedTetrahedronPolygons({ center: offset, size: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(1, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(2, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(-3, 5); + }); +}); + +describe("truncatedCubePolygons", () => { + it("returns 14 faces total (8 triangles + 6 octagons)", () => { + const polygons = truncatedCubePolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(14); + const tris = polygons.filter((p) => p.vertices.length === 3); + const octs = polygons.filter((p) => p.vertices.length === 8); + expect(tris).toHaveLength(8); + expect(octs).toHaveLength(6); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = truncatedCubePolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("every vertex lies on the circumscribing sphere (distance from center == size)", () => { + const size = 2; + const polygons = truncatedCubePolygons({ center: [0, 0, 0], size }); + for (const p of polygons) { + for (const [x, y, z] of p.vertices) { + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 8); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = truncatedCubePolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = truncatedCubePolygons({ center: [0, 0, 0], size: 1, color: "#abcdef" }); + for (const p of colorPolygons) expect(p.color).toBe("#abcdef"); + }); + + it("center offset shifts the bounding box centroid by the offset", () => { + const offset: [number, number, number] = [-2, 3, 5]; + const base = truncatedCubePolygons({ center: [0, 0, 0], size: 1 }); + const moved = truncatedCubePolygons({ center: offset, size: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(-2, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(3, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(5, 5); + }); +}); + +describe("truncatedOctahedronPolygons", () => { + it("returns 14 faces total (6 squares + 8 hexagons)", () => { + const polygons = truncatedOctahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(14); + const quads = polygons.filter((p) => p.vertices.length === 4); + const hexes = polygons.filter((p) => p.vertices.length === 6); + expect(quads).toHaveLength(6); + expect(hexes).toHaveLength(8); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = truncatedOctahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("every vertex lies on the circumscribing sphere (distance from center == size)", () => { + const size = 3; + const polygons = truncatedOctahedronPolygons({ center: [0, 0, 0], size }); + for (const p of polygons) { + for (const [x, y, z] of p.vertices) { + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 8); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = truncatedOctahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = truncatedOctahedronPolygons({ center: [0, 0, 0], size: 1, color: "#fedcba" }); + for (const p of colorPolygons) expect(p.color).toBe("#fedcba"); + }); + + it("center offset shifts the bounding box centroid by the offset", () => { + const offset: [number, number, number] = [4, -2, 3]; + const base = truncatedOctahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = truncatedOctahedronPolygons({ center: offset, size: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(4, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(-2, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(3, 5); + }); +}); + +describe("truncatedDodecahedronPolygons", () => { + it("returns 32 faces total (20 triangles + 12 decagons)", () => { + const polygons = truncatedDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(32); + const tris = polygons.filter((p) => p.vertices.length === 3); + const decas = polygons.filter((p) => p.vertices.length === 10); + expect(tris).toHaveLength(20); + expect(decas).toHaveLength(12); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = truncatedDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("every vertex lies on the circumscribing sphere (distance from center == size)", () => { + const size = 2; + const polygons = truncatedDodecahedronPolygons({ center: [0, 0, 0], size }); + for (const p of polygons) { + for (const [x, y, z] of p.vertices) { + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 5); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = truncatedDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = truncatedDodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#aabbcc" }); + for (const p of colorPolygons) expect(p.color).toBe("#aabbcc"); + }); + + it("center offset shifts the bounding box centroid by the offset", () => { + const offset: [number, number, number] = [1, -2, 3]; + const base = truncatedDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = truncatedDodecahedronPolygons({ center: offset, size: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(1, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(-2, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(3, 5); + }); +}); + +describe("truncatedIcosahedronPolygons", () => { + it("returns 32 faces total (12 pentagons + 20 hexagons)", () => { + const polygons = truncatedIcosahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(32); + const pentas = polygons.filter((p) => p.vertices.length === 5); + const hexes = polygons.filter((p) => p.vertices.length === 6); + expect(pentas).toHaveLength(12); + expect(hexes).toHaveLength(20); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = truncatedIcosahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("every vertex lies on the circumscribing sphere (distance from center == size)", () => { + const size = 3; + const polygons = truncatedIcosahedronPolygons({ center: [0, 0, 0], size }); + for (const p of polygons) { + for (const [x, y, z] of p.vertices) { + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 5); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = truncatedIcosahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = truncatedIcosahedronPolygons({ center: [0, 0, 0], size: 1, color: "#112233" }); + for (const p of colorPolygons) expect(p.color).toBe("#112233"); + }); + + it("center offset shifts the bounding box centroid by the offset", () => { + const offset: [number, number, number] = [2, 3, -1]; + const base = truncatedIcosahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = truncatedIcosahedronPolygons({ center: offset, size: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(2, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(3, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(-1, 5); + }); +}); + +describe("truncatedCuboctahedronPolygons", () => { + it("returns 26 faces total (12 squares + 8 hexagons + 6 octagons)", () => { + const polygons = truncatedCuboctahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(26); + const squares = polygons.filter((p) => p.vertices.length === 4); + const hexes = polygons.filter((p) => p.vertices.length === 6); + const octs = polygons.filter((p) => p.vertices.length === 8); + expect(squares).toHaveLength(12); + expect(hexes).toHaveLength(8); + expect(octs).toHaveLength(6); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = truncatedCuboctahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("every vertex lies on the circumscribing sphere (distance from center == size)", () => { + const size = 2; + const polygons = truncatedCuboctahedronPolygons({ center: [0, 0, 0], size }); + for (const p of polygons) { + for (const [x, y, z] of p.vertices) { + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 5); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = truncatedCuboctahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = truncatedCuboctahedronPolygons({ center: [0, 0, 0], size: 1, color: "#c0ffee" }); + for (const p of colorPolygons) expect(p.color).toBe("#c0ffee"); + }); + + it("center offset shifts the bounding box centroid by the offset", () => { + const offset: [number, number, number] = [-1, 4, 2]; + const base = truncatedCuboctahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = truncatedCuboctahedronPolygons({ center: offset, size: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(-1, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(4, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(2, 5); + }); + + it("all 26 faces have outward-facing normals (CCW winding from outside)", () => { + const polygons = truncatedCuboctahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const poly of polygons) { + expect(normalDotCentroid(poly as { vertices: [number, number, number][] })).toBeGreaterThan(0); + } + }); +}); + +describe("truncatedIcosidodecahedronPolygons", () => { + it("returns 62 faces total (30 squares + 20 hexagons + 12 decagons)", () => { + const polygons = truncatedIcosidodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(62); + const squares = polygons.filter((p) => p.vertices.length === 4); + const hexes = polygons.filter((p) => p.vertices.length === 6); + const decas = polygons.filter((p) => p.vertices.length === 10); + expect(squares).toHaveLength(30); + expect(hexes).toHaveLength(20); + expect(decas).toHaveLength(12); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = truncatedIcosidodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("every vertex lies on the circumscribing sphere (distance from center == size)", () => { + const size = 2; + const polygons = truncatedIcosidodecahedronPolygons({ center: [0, 0, 0], size }); + for (const p of polygons) { + for (const [x, y, z] of p.vertices) { + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 5); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = truncatedIcosidodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = truncatedIcosidodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#ff8800" }); + for (const p of colorPolygons) expect(p.color).toBe("#ff8800"); + }); + + it("center offset shifts the bounding box centroid by the offset", () => { + const offset: [number, number, number] = [5, -3, 1]; + const base = truncatedIcosidodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = truncatedIcosidodecahedronPolygons({ center: offset, size: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(5, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(-3, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(1, 5); + }); + + it("all 62 faces have outward-facing normals (CCW winding from outside)", () => { + const polygons = truncatedIcosidodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const poly of polygons) { + expect(normalDotCentroid(poly as { vertices: [number, number, number][] })).toBeGreaterThan(0); + } + }); +}); + +describe("rhombicuboctahedronPolygons", () => { + it("returns 26 faces total (8 triangles + 18 squares)", () => { + const polygons = rhombicuboctahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(26); + const tris = polygons.filter((p) => p.vertices.length === 3); + const squares = polygons.filter((p) => p.vertices.length === 4); + expect(tris).toHaveLength(8); + expect(squares).toHaveLength(18); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = rhombicuboctahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("every vertex lies on the circumscribing sphere (distance from center == size)", () => { + const size = 2.5; + const polygons = rhombicuboctahedronPolygons({ center: [0, 0, 0], size }); + for (const p of polygons) { + for (const [x, y, z] of p.vertices) { + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 5); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = rhombicuboctahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = rhombicuboctahedronPolygons({ center: [0, 0, 0], size: 1, color: "#ab4567" }); + for (const p of colorPolygons) expect(p.color).toBe("#ab4567"); + }); + + it("center offset shifts the bounding box centroid by the offset", () => { + const offset: [number, number, number] = [3, 0, -2]; + const base = rhombicuboctahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = rhombicuboctahedronPolygons({ center: offset, size: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(3, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(0, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(-2, 5); + }); + + it("all 26 faces have outward-facing normals (CCW winding from outside)", () => { + const polygons = rhombicuboctahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const poly of polygons) { + expect(normalDotCentroid(poly as { vertices: [number, number, number][] })).toBeGreaterThan(0); + } + }); +}); + +describe("rhombicosidodecahedronPolygons", () => { + it("returns 62 faces total (20 triangles + 30 squares + 12 pentagons)", () => { + const polygons = rhombicosidodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(62); + const tris = polygons.filter((p) => p.vertices.length === 3); + const squares = polygons.filter((p) => p.vertices.length === 4); + const pentas = polygons.filter((p) => p.vertices.length === 5); + expect(tris).toHaveLength(20); + expect(squares).toHaveLength(30); + expect(pentas).toHaveLength(12); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = rhombicosidodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("every vertex lies on the circumscribing sphere (distance from center == size)", () => { + const size = 2; + const polygons = rhombicosidodecahedronPolygons({ center: [0, 0, 0], size }); + for (const p of polygons) { + for (const [x, y, z] of p.vertices) { + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 5); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = rhombicosidodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = rhombicosidodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#fedcba" }); + for (const p of colorPolygons) expect(p.color).toBe("#fedcba"); + }); + + it("center offset shifts the bounding box centroid by the offset", () => { + const offset: [number, number, number] = [-2, 1, 4]; + const base = rhombicosidodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = rhombicosidodecahedronPolygons({ center: offset, size: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(-2, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(1, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(4, 5); + }); + + it("all 62 faces have outward-facing normals (CCW winding from outside)", () => { + const polygons = rhombicosidodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const poly of polygons) { + expect(normalDotCentroid(poly as { vertices: [number, number, number][] })).toBeGreaterThan(0); + } + }); +}); + +describe("snubCubePolygons", () => { + it("returns 38 faces total (32 triangles + 6 squares)", () => { + const polygons = snubCubePolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(38); + const tris = polygons.filter((p) => p.vertices.length === 3); + const squares = polygons.filter((p) => p.vertices.length === 4); + expect(tris).toHaveLength(32); + expect(squares).toHaveLength(6); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = snubCubePolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("every vertex lies on the circumscribing sphere (distance from center == size)", () => { + const size = 3; + const polygons = snubCubePolygons({ center: [0, 0, 0], size }); + for (const p of polygons) { + for (const [x, y, z] of p.vertices) { + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 5); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = snubCubePolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = snubCubePolygons({ center: [0, 0, 0], size: 1, color: "#0077ff" }); + for (const p of colorPolygons) expect(p.color).toBe("#0077ff"); + }); + + it("center offset shifts the bounding box centroid by the offset", () => { + const offset: [number, number, number] = [1, 2, -3]; + const base = snubCubePolygons({ center: [0, 0, 0], size: 1 }); + const moved = snubCubePolygons({ center: offset, size: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(1, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(2, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(-3, 5); + }); + + it("all 38 faces have outward-facing normals (CCW winding from outside)", () => { + const polygons = snubCubePolygons({ center: [0, 0, 0], size: 1 }); + for (const poly of polygons) { + expect(normalDotCentroid(poly as { vertices: [number, number, number][] })).toBeGreaterThan(0); + } + }); +}); + +describe("snubDodecahedronPolygons", () => { + it("returns 92 faces total (80 triangles + 12 pentagons)", () => { + const polygons = snubDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(polygons).toHaveLength(92); + const tris = polygons.filter((p) => p.vertices.length === 3); + const pentas = polygons.filter((p) => p.vertices.length === 5); + expect(tris).toHaveLength(80); + expect(pentas).toHaveLength(12); + }); + + it("all polygon vertices have length 3 and contain finite numbers", () => { + const polygons = snubDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of polygons) { + for (const vtx of p.vertices) { + expect(vtx).toHaveLength(3); + for (const coord of vtx) expect(Number.isFinite(coord)).toBe(true); + } + } + }); + + it("every vertex lies on the circumscribing sphere (distance from center == size)", () => { + const size = 2; + const polygons = snubDodecahedronPolygons({ center: [0, 0, 0], size }); + for (const p of polygons) { + for (const [x, y, z] of p.vertices) { + const dist = Math.sqrt(x * x + y * y + z * z); + expect(dist).toBeCloseTo(size, 5); + } + } + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const defaultPolygons = snubDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const p of defaultPolygons) expect(p.color).toBe("#ffffff"); + const colorPolygons = snubDodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#aaff00" }); + for (const p of colorPolygons) expect(p.color).toBe("#aaff00"); + }); + + it("center offset shifts the bounding box centroid by the offset", () => { + const offset: [number, number, number] = [2, -1, 3]; + const base = snubDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = snubDodecahedronPolygons({ center: offset, size: 1 }); + const allBase = base.flatMap((p) => p.vertices); + const allMoved = moved.flatMap((p) => p.vertices); + const n = allBase.length; + expect(allMoved.reduce((s, v) => s + v[0], 0) / n - allBase.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(2, 5); + expect(allMoved.reduce((s, v) => s + v[1], 0) / n - allBase.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(-1, 5); + expect(allMoved.reduce((s, v) => s + v[2], 0) / n - allBase.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(3, 5); + }); + + it("all 92 faces have outward-facing normals (CCW winding from outside)", () => { + const polygons = snubDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const poly of polygons) { + expect(normalDotCentroid(poly as { vertices: [number, number, number][] })).toBeGreaterThan(0); + } + }); +}); + +// ── Catalan polyhedra ──────────────────────────────────────────────────────── + +/** Residual distance of all vertices after the first 3 from the plane of the first 3. */ +function maxPlanarResidual(verts: readonly (readonly [number, number, number])[]): number { + if (verts.length <= 3) return 0; + const [a, b, c] = verts as [[number,number,number],[number,number,number],[number,number,number]]; + const abx = b[0]-a[0], aby = b[1]-a[1], abz = b[2]-a[2]; + const acx = c[0]-a[0], acy = c[1]-a[1], acz = c[2]-a[2]; + const nx = aby*acz - abz*acy; + const ny = abz*acx - abx*acz; + const nz = abx*acy - aby*acx; + const len = Math.sqrt(nx*nx + ny*ny + nz*nz); + if (len < 1e-12) return 0; + let max = 0; + for (let k = 3; k < verts.length; k++) { + const dx = verts[k][0]-a[0], dy = verts[k][1]-a[1], dz = verts[k][2]-a[2]; + const dist = Math.abs(dx*nx + dy*ny + dz*nz) / len; + if (dist > max) max = dist; + } + return max; +} + +describe("rhombicDodecahedronPolygons", () => { + it("returns 12 rhombic (4-vertex) faces", () => { + const p = rhombicDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(p).toHaveLength(12); + for (const f of p) expect(f.vertices).toHaveLength(4); + }); + + it("all vertex coords are finite", () => { + const p = rhombicDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) for (const v of f.vertices) for (const c of v) expect(Number.isFinite(c)).toBe(true); + }); + + it("each face is planar (4th vertex within 1e-5 of the plane of the first 3)", () => { + const p = rhombicDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) expect(maxPlanarResidual(f.vertices)).toBeLessThan(1e-5); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const def = rhombicDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of def) expect(f.color).toBe("#ffffff"); + const col = rhombicDodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#aabb11" }); + for (const f of col) expect(f.color).toBe("#aabb11"); + }); + + it("center offset shifts bounding-box centroid", () => { + const offset: [number, number, number] = [3, -2, 5]; + const base = rhombicDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = rhombicDodecahedronPolygons({ center: offset, size: 1 }); + const bv = base.flatMap((p) => p.vertices), mv = moved.flatMap((p) => p.vertices); + const n = bv.length; + expect(mv.reduce((s, v) => s + v[0], 0) / n - bv.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(3, 5); + expect(mv.reduce((s, v) => s + v[1], 0) / n - bv.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(-2, 5); + expect(mv.reduce((s, v) => s + v[2], 0) / n - bv.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(5, 5); + }); +}); + +describe("rhombicTriacontahedronPolygons", () => { + it("returns 30 rhombic (4-vertex) faces", () => { + const p = rhombicTriacontahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(p).toHaveLength(30); + for (const f of p) expect(f.vertices).toHaveLength(4); + }); + + it("all vertex coords are finite", () => { + const p = rhombicTriacontahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) for (const v of f.vertices) for (const c of v) expect(Number.isFinite(c)).toBe(true); + }); + + it("each face is planar", () => { + const p = rhombicTriacontahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) expect(maxPlanarResidual(f.vertices)).toBeLessThan(1e-5); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const def = rhombicTriacontahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of def) expect(f.color).toBe("#ffffff"); + const col = rhombicTriacontahedronPolygons({ center: [0, 0, 0], size: 1, color: "#cc1122" }); + for (const f of col) expect(f.color).toBe("#cc1122"); + }); + + it("center offset shifts bounding-box centroid", () => { + const offset: [number, number, number] = [1, 2, 3]; + const base = rhombicTriacontahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = rhombicTriacontahedronPolygons({ center: offset, size: 1 }); + const bv = base.flatMap((p) => p.vertices), mv = moved.flatMap((p) => p.vertices); + const n = bv.length; + expect(mv.reduce((s, v) => s + v[0], 0) / n - bv.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(1, 5); + expect(mv.reduce((s, v) => s + v[1], 0) / n - bv.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(2, 5); + expect(mv.reduce((s, v) => s + v[2], 0) / n - bv.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(3, 5); + }); +}); + +describe("triakisTetrahedronPolygons", () => { + it("returns 12 triangular faces", () => { + const p = triakisTetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(p).toHaveLength(12); + for (const f of p) expect(f.vertices).toHaveLength(3); + }); + + it("all vertex coords are finite", () => { + const p = triakisTetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) for (const v of f.vertices) for (const c of v) expect(Number.isFinite(c)).toBe(true); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const def = triakisTetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of def) expect(f.color).toBe("#ffffff"); + const col = triakisTetrahedronPolygons({ center: [0, 0, 0], size: 1, color: "#001122" }); + for (const f of col) expect(f.color).toBe("#001122"); + }); + + it("center offset shifts bounding-box centroid", () => { + const offset: [number, number, number] = [4, -1, 2]; + const base = triakisTetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = triakisTetrahedronPolygons({ center: offset, size: 1 }); + const bv = base.flatMap((p) => p.vertices), mv = moved.flatMap((p) => p.vertices); + const n = bv.length; + expect(mv.reduce((s, v) => s + v[0], 0) / n - bv.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(4, 5); + expect(mv.reduce((s, v) => s + v[1], 0) / n - bv.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(-1, 5); + expect(mv.reduce((s, v) => s + v[2], 0) / n - bv.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(2, 5); + }); +}); + +describe("triakisOctahedronPolygons", () => { + it("returns 24 triangular faces", () => { + const p = triakisOctahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(p).toHaveLength(24); + for (const f of p) expect(f.vertices).toHaveLength(3); + }); + + it("all vertex coords are finite", () => { + const p = triakisOctahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) for (const v of f.vertices) for (const c of v) expect(Number.isFinite(c)).toBe(true); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const def = triakisOctahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of def) expect(f.color).toBe("#ffffff"); + const col = triakisOctahedronPolygons({ center: [0, 0, 0], size: 1, color: "#ff8800" }); + for (const f of col) expect(f.color).toBe("#ff8800"); + }); + + it("center offset shifts bounding-box centroid", () => { + const offset: [number, number, number] = [-3, 0, 2]; + const base = triakisOctahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = triakisOctahedronPolygons({ center: offset, size: 1 }); + const bv = base.flatMap((p) => p.vertices), mv = moved.flatMap((p) => p.vertices); + const n = bv.length; + expect(mv.reduce((s, v) => s + v[0], 0) / n - bv.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(-3, 5); + expect(mv.reduce((s, v) => s + v[1], 0) / n - bv.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(0, 5); + expect(mv.reduce((s, v) => s + v[2], 0) / n - bv.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(2, 5); + }); +}); + +describe("tetrakisHexahedronPolygons", () => { + it("returns 24 triangular faces", () => { + const p = tetrakisHexahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(p).toHaveLength(24); + for (const f of p) expect(f.vertices).toHaveLength(3); + }); + + it("all vertex coords are finite", () => { + const p = tetrakisHexahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) for (const v of f.vertices) for (const c of v) expect(Number.isFinite(c)).toBe(true); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const def = tetrakisHexahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of def) expect(f.color).toBe("#ffffff"); + const col = tetrakisHexahedronPolygons({ center: [0, 0, 0], size: 1, color: "#00aaff" }); + for (const f of col) expect(f.color).toBe("#00aaff"); + }); + + it("center offset shifts bounding-box centroid", () => { + const offset: [number, number, number] = [2, 3, -1]; + const base = tetrakisHexahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = tetrakisHexahedronPolygons({ center: offset, size: 1 }); + const bv = base.flatMap((p) => p.vertices), mv = moved.flatMap((p) => p.vertices); + const n = bv.length; + expect(mv.reduce((s, v) => s + v[0], 0) / n - bv.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(2, 5); + expect(mv.reduce((s, v) => s + v[1], 0) / n - bv.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(3, 5); + expect(mv.reduce((s, v) => s + v[2], 0) / n - bv.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(-1, 5); + }); +}); + +describe("triakisIcosahedronPolygons", () => { + it("returns 60 triangular faces", () => { + const p = triakisIcosahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(p).toHaveLength(60); + for (const f of p) expect(f.vertices).toHaveLength(3); + }); + + it("all vertex coords are finite", () => { + const p = triakisIcosahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) for (const v of f.vertices) for (const c of v) expect(Number.isFinite(c)).toBe(true); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const def = triakisIcosahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of def) expect(f.color).toBe("#ffffff"); + const col = triakisIcosahedronPolygons({ center: [0, 0, 0], size: 1, color: "#112233" }); + for (const f of col) expect(f.color).toBe("#112233"); + }); + + it("center offset shifts bounding-box centroid", () => { + const offset: [number, number, number] = [5, -5, 5]; + const base = triakisIcosahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = triakisIcosahedronPolygons({ center: offset, size: 1 }); + const bv = base.flatMap((p) => p.vertices), mv = moved.flatMap((p) => p.vertices); + const n = bv.length; + expect(mv.reduce((s, v) => s + v[0], 0) / n - bv.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(5, 5); + expect(mv.reduce((s, v) => s + v[1], 0) / n - bv.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(-5, 5); + expect(mv.reduce((s, v) => s + v[2], 0) / n - bv.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(5, 5); + }); +}); + +describe("pentakisDodecahedronPolygons", () => { + it("returns 60 triangular faces", () => { + const p = pentakisDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(p).toHaveLength(60); + for (const f of p) expect(f.vertices).toHaveLength(3); + }); + + it("all vertex coords are finite", () => { + const p = pentakisDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) for (const v of f.vertices) for (const c of v) expect(Number.isFinite(c)).toBe(true); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const def = pentakisDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of def) expect(f.color).toBe("#ffffff"); + const col = pentakisDodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#abcdef" }); + for (const f of col) expect(f.color).toBe("#abcdef"); + }); + + it("center offset shifts bounding-box centroid", () => { + const offset: [number, number, number] = [0, 4, -2]; + const base = pentakisDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = pentakisDodecahedronPolygons({ center: offset, size: 1 }); + const bv = base.flatMap((p) => p.vertices), mv = moved.flatMap((p) => p.vertices); + const n = bv.length; + expect(mv.reduce((s, v) => s + v[0], 0) / n - bv.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(0, 5); + expect(mv.reduce((s, v) => s + v[1], 0) / n - bv.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(4, 5); + expect(mv.reduce((s, v) => s + v[2], 0) / n - bv.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(-2, 5); + }); +}); + +describe("disdyakisDodecahedronPolygons", () => { + it("returns 48 triangular faces", () => { + const p = disdyakisDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(p).toHaveLength(48); + for (const f of p) expect(f.vertices).toHaveLength(3); + }); + + it("all vertex coords are finite", () => { + const p = disdyakisDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) for (const v of f.vertices) for (const c of v) expect(Number.isFinite(c)).toBe(true); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const def = disdyakisDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of def) expect(f.color).toBe("#ffffff"); + const col = disdyakisDodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#ff0077" }); + for (const f of col) expect(f.color).toBe("#ff0077"); + }); + + it("center offset shifts bounding-box centroid", () => { + const offset: [number, number, number] = [-1, 3, 2]; + const base = disdyakisDodecahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = disdyakisDodecahedronPolygons({ center: offset, size: 1 }); + const bv = base.flatMap((p) => p.vertices), mv = moved.flatMap((p) => p.vertices); + const n = bv.length; + expect(mv.reduce((s, v) => s + v[0], 0) / n - bv.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(-1, 5); + expect(mv.reduce((s, v) => s + v[1], 0) / n - bv.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(3, 5); + expect(mv.reduce((s, v) => s + v[2], 0) / n - bv.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(2, 5); + }); +}); + +describe("disdyakisTriacontahedronPolygons", () => { + it("returns 120 triangular faces", () => { + const p = disdyakisTriacontahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(p).toHaveLength(120); + for (const f of p) expect(f.vertices).toHaveLength(3); + }); + + it("all vertex coords are finite", () => { + const p = disdyakisTriacontahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) for (const v of f.vertices) for (const c of v) expect(Number.isFinite(c)).toBe(true); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const def = disdyakisTriacontahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of def) expect(f.color).toBe("#ffffff"); + const col = disdyakisTriacontahedronPolygons({ center: [0, 0, 0], size: 1, color: "#7700cc" }); + for (const f of col) expect(f.color).toBe("#7700cc"); + }); + + it("center offset shifts bounding-box centroid", () => { + const offset: [number, number, number] = [2, -3, 1]; + const base = disdyakisTriacontahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = disdyakisTriacontahedronPolygons({ center: offset, size: 1 }); + const bv = base.flatMap((p) => p.vertices), mv = moved.flatMap((p) => p.vertices); + const n = bv.length; + expect(mv.reduce((s, v) => s + v[0], 0) / n - bv.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(2, 5); + expect(mv.reduce((s, v) => s + v[1], 0) / n - bv.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(-3, 5); + expect(mv.reduce((s, v) => s + v[2], 0) / n - bv.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(1, 5); + }); +}); + +describe("deltoidalIcositetrahedronPolygons", () => { + it("returns 24 kite (4-vertex) faces", () => { + const p = deltoidalIcositetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(p).toHaveLength(24); + for (const f of p) expect(f.vertices).toHaveLength(4); + }); + + it("all vertex coords are finite", () => { + const p = deltoidalIcositetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) for (const v of f.vertices) for (const c of v) expect(Number.isFinite(c)).toBe(true); + }); + + it("each face is planar", () => { + const p = deltoidalIcositetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) expect(maxPlanarResidual(f.vertices)).toBeLessThan(1e-5); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const def = deltoidalIcositetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of def) expect(f.color).toBe("#ffffff"); + const col = deltoidalIcositetrahedronPolygons({ center: [0, 0, 0], size: 1, color: "#123456" }); + for (const f of col) expect(f.color).toBe("#123456"); + }); + + it("center offset shifts bounding-box centroid", () => { + const offset: [number, number, number] = [1, -4, 3]; + const base = deltoidalIcositetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = deltoidalIcositetrahedronPolygons({ center: offset, size: 1 }); + const bv = base.flatMap((p) => p.vertices), mv = moved.flatMap((p) => p.vertices); + const n = bv.length; + expect(mv.reduce((s, v) => s + v[0], 0) / n - bv.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(1, 5); + expect(mv.reduce((s, v) => s + v[1], 0) / n - bv.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(-4, 5); + expect(mv.reduce((s, v) => s + v[2], 0) / n - bv.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(3, 5); + }); +}); + +describe("deltoidalHexecontahedronPolygons", () => { + it("returns 60 kite (4-vertex) faces", () => { + const p = deltoidalHexecontahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(p).toHaveLength(60); + for (const f of p) expect(f.vertices).toHaveLength(4); + }); + + it("all vertex coords are finite", () => { + const p = deltoidalHexecontahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) for (const v of f.vertices) for (const c of v) expect(Number.isFinite(c)).toBe(true); + }); + + it("each face is planar", () => { + const p = deltoidalHexecontahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) expect(maxPlanarResidual(f.vertices)).toBeLessThan(1e-5); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const def = deltoidalHexecontahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of def) expect(f.color).toBe("#ffffff"); + const col = deltoidalHexecontahedronPolygons({ center: [0, 0, 0], size: 1, color: "#654321" }); + for (const f of col) expect(f.color).toBe("#654321"); + }); + + it("center offset shifts bounding-box centroid", () => { + const offset: [number, number, number] = [-2, 1, 4]; + const base = deltoidalHexecontahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = deltoidalHexecontahedronPolygons({ center: offset, size: 1 }); + const bv = base.flatMap((p) => p.vertices), mv = moved.flatMap((p) => p.vertices); + const n = bv.length; + expect(mv.reduce((s, v) => s + v[0], 0) / n - bv.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(-2, 5); + expect(mv.reduce((s, v) => s + v[1], 0) / n - bv.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(1, 5); + expect(mv.reduce((s, v) => s + v[2], 0) / n - bv.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(4, 5); + }); +}); + +describe("pentagonalIcositetrahedronPolygons", () => { + it("returns 24 pentagonal (5-vertex) faces", () => { + const p = pentagonalIcositetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(p).toHaveLength(24); + for (const f of p) expect(f.vertices).toHaveLength(5); + }); + + it("all vertex coords are finite", () => { + const p = pentagonalIcositetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) for (const v of f.vertices) for (const c of v) expect(Number.isFinite(c)).toBe(true); + }); + + it("each face is planar (all 5 vertices within 1e-5 of the plane of the first 3)", () => { + const p = pentagonalIcositetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) expect(maxPlanarResidual(f.vertices)).toBeLessThan(1e-5); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const def = pentagonalIcositetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of def) expect(f.color).toBe("#ffffff"); + const col = pentagonalIcositetrahedronPolygons({ center: [0, 0, 0], size: 1, color: "#0077ff" }); + for (const f of col) expect(f.color).toBe("#0077ff"); + }); + + it("center offset shifts bounding-box centroid", () => { + const offset: [number, number, number] = [3, 1, -2]; + const base = pentagonalIcositetrahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = pentagonalIcositetrahedronPolygons({ center: offset, size: 1 }); + const bv = base.flatMap((p) => p.vertices), mv = moved.flatMap((p) => p.vertices); + const n = bv.length; + expect(mv.reduce((s, v) => s + v[0], 0) / n - bv.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(3, 5); + expect(mv.reduce((s, v) => s + v[1], 0) / n - bv.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(1, 5); + expect(mv.reduce((s, v) => s + v[2], 0) / n - bv.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(-2, 5); + }); +}); + +describe("pentagonalHexecontahedronPolygons", () => { + it("returns 60 pentagonal (5-vertex) faces", () => { + const p = pentagonalHexecontahedronPolygons({ center: [0, 0, 0], size: 1 }); + expect(p).toHaveLength(60); + for (const f of p) expect(f.vertices).toHaveLength(5); + }); + + it("all vertex coords are finite", () => { + const p = pentagonalHexecontahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) for (const v of f.vertices) for (const c of v) expect(Number.isFinite(c)).toBe(true); + }); + + it("each face is planar (all 5 vertices within 1e-5 of the plane of the first 3)", () => { + const p = pentagonalHexecontahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of p) expect(maxPlanarResidual(f.vertices)).toBeLessThan(1e-5); + }); + + it("color defaults to #ffffff and propagates when supplied", () => { + const def = pentagonalHexecontahedronPolygons({ center: [0, 0, 0], size: 1 }); + for (const f of def) expect(f.color).toBe("#ffffff"); + const col = pentagonalHexecontahedronPolygons({ center: [0, 0, 0], size: 1, color: "#aaff00" }); + for (const f of col) expect(f.color).toBe("#aaff00"); + }); + + it("center offset shifts bounding-box centroid", () => { + const offset: [number, number, number] = [-1, 2, -3]; + const base = pentagonalHexecontahedronPolygons({ center: [0, 0, 0], size: 1 }); + const moved = pentagonalHexecontahedronPolygons({ center: offset, size: 1 }); + const bv = base.flatMap((p) => p.vertices), mv = moved.flatMap((p) => p.vertices); + const n = bv.length; + expect(mv.reduce((s, v) => s + v[0], 0) / n - bv.reduce((s, v) => s + v[0], 0) / n).toBeCloseTo(-1, 5); + expect(mv.reduce((s, v) => s + v[1], 0) / n - bv.reduce((s, v) => s + v[1], 0) / n).toBeCloseTo(2, 5); + expect(mv.reduce((s, v) => s + v[2], 0) / n - bv.reduce((s, v) => s + v[2], 0) / n).toBeCloseTo(-3, 5); + }); +}); diff --git a/packages/core/src/helpers/icosidodecahedronPolygons.ts b/packages/core/src/helpers/icosidodecahedronPolygons.ts new file mode 100644 index 00000000..24a6bad5 --- /dev/null +++ b/packages/core/src/helpers/icosidodecahedronPolygons.ts @@ -0,0 +1,189 @@ +/** + * Geometry for a regular icosidodecahedron — 20 triangular faces + 12 pentagonal + * faces (32 faces total, 30 vertices). Constructed via the edge-midpoint method + * from the icosahedron: the 30 vertices are the midpoints of the icosahedron's 30 + * edges. Scaled so the circumradius equals `size`. + * + * Face decomposition: + * 20 triangles — one per icosahedron face (3 edge midpoints per face). + * 12 pentagons — one per icosahedron vertex (5 incident-edge midpoints per vertex). + * + * Each face is CCW-from-outside. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface IcosidodecahedronPolygonsOptions { + /** Center of the icosidodecahedron in world space. */ + center: Vec3; + /** Circumradius — distance from center to each vertex. */ + size: number; + /** Fill color applied to all thirty-two faces. */ + color?: string; +} + +export function icosidodecahedronPolygons(options: IcosidodecahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + // ── Icosahedron base vertices (raw, unscaled) ────────────────────────────── + // Standard (0, ±1, ±φ) form and even permutations; 12 vertices total. + const phi = (1 + Math.sqrt(5)) / 2; + + const icoRaw: [number, number, number][] = [ + [ 0, -1, -phi], // 0 + [ 0, -1, phi], // 1 + [ 0, 1, -phi], // 2 + [ 0, 1, phi], // 3 + [-1, -phi, 0], // 4 + [-1, phi, 0], // 5 + [ 1, -phi, 0], // 6 + [ 1, phi, 0], // 7 + [-phi, 0, -1], // 8 + [ phi, 0, -1], // 9 + [-phi, 0, 1], // 10 + [ phi, 0, 1], // 11 + ]; + + // Icosahedron face table (same as icosahedronPolygons.ts). + const icoFaces: [number, number, number][] = [ + [ 0, 2, 9], + [ 0, 4, 8], + [ 0, 6, 4], + [ 0, 8, 2], + [ 0, 9, 6], + [ 1, 3, 10], + [ 1, 4, 6], + [ 1, 6, 11], + [ 1, 10, 4], + [ 1, 11, 3], + [ 2, 5, 7], + [ 2, 7, 9], + [ 2, 8, 5], + [ 3, 5, 10], + [ 3, 7, 5], + [ 3, 11, 7], + [ 4, 10, 8], + [ 5, 8, 10], + [ 6, 9, 11], + [ 7, 11, 9], + ]; + + // ── Build 30 edge midpoints ──────────────────────────────────────────────── + // Each edge is shared by exactly 2 faces; we deduplicate by always storing + // edges with lower index first. + const edgeMap = new Map(); + const midpointsRaw: [number, number, number][] = []; + + function edgeKey(a: number, b: number): string { + return a < b ? `${a},${b}` : `${b},${a}`; + } + + function getOrAddMidpoint(a: number, b: number): number { + const key = edgeKey(a, b); + if (edgeMap.has(key)) return edgeMap.get(key)!; + const [ax, ay, az] = icoRaw[a]; + const [bx, by, bz] = icoRaw[b]; + const idx = midpointsRaw.length; + midpointsRaw.push([(ax + bx) / 2, (ay + by) / 2, (az + bz) / 2]); + edgeMap.set(key, idx); + return idx; + } + + // Build midpoint indices for all icosahedron faces. + // Also record the adjacency between each icosahedron vertex and its edge-midpoint indices. + const vertexEdgeMids: number[][] = Array.from({ length: 12 }, () => []); + + const triMidIndices: [number, number, number][] = icoFaces.map(([a, b, c]) => { + const mab = getOrAddMidpoint(a, b); + const mbc = getOrAddMidpoint(b, c); + const mca = getOrAddMidpoint(c, a); + // Record which vertices own which midpoints (for pentagon construction). + if (!vertexEdgeMids[a].includes(mab)) vertexEdgeMids[a].push(mab); + if (!vertexEdgeMids[a].includes(mca)) vertexEdgeMids[a].push(mca); + if (!vertexEdgeMids[b].includes(mab)) vertexEdgeMids[b].push(mab); + if (!vertexEdgeMids[b].includes(mbc)) vertexEdgeMids[b].push(mbc); + if (!vertexEdgeMids[c].includes(mbc)) vertexEdgeMids[c].push(mbc); + if (!vertexEdgeMids[c].includes(mca)) vertexEdgeMids[c].push(mca); + return [mab, mbc, mca]; + }); + + // ── Compute circumradius of the raw midpoints and scale ──────────────────── + // All midpoints are equidistant from origin (icosidodecahedron property). + const [mx, my, mz] = midpointsRaw[0]; + const rawCircumradius = Math.sqrt(mx * mx + my * my + mz * mz); + const s = size / rawCircumradius; + + const v: Vec3[] = midpointsRaw.map(([x, y, z]) => [ + cx + x * s, + cy + y * s, + cz + z * s, + ]); + + // ── Helper: sort a ring of vertex indices CCW around their centroid ──────── + // given the outward normal direction (away from origin). + function sortCCW(indices: number[], normal: [number, number, number]): number[] { + // Centroid of the raw midpoints for these vertices. + let gcx = 0, gcy = 0, gcz = 0; + for (const i of indices) { + gcx += midpointsRaw[i][0]; + gcy += midpointsRaw[i][1]; + gcz += midpointsRaw[i][2]; + } + const n = indices.length; + gcx /= n; gcy /= n; gcz /= n; + + // Build a local 2D basis in the face plane (Gram-Schmidt against normal). + const [nx, ny, nz] = normal; + // First basis: direction from centroid to first vertex, projected perpendicular to normal. + const [p0x, p0y, p0z] = midpointsRaw[indices[0]]; + let e0x = p0x - gcx, e0y = p0y - gcy, e0z = p0z - gcz; + const dot0 = e0x * nx + e0y * ny + e0z * nz; + e0x -= dot0 * nx; e0y -= dot0 * ny; e0z -= dot0 * nz; + const len0 = Math.sqrt(e0x * e0x + e0y * e0y + e0z * e0z); + e0x /= len0; e0y /= len0; e0z /= len0; + // Second basis: cross(normal, e0). + const e1x = ny * e0z - nz * e0y; + const e1y = nz * e0x - nx * e0z; + const e1z = nx * e0y - ny * e0x; + + const angles = indices.map((i) => { + const [px, py, pz] = midpointsRaw[i]; + const dx = px - gcx, dy = py - gcy, dz = pz - gcz; + const u = dx * e0x + dy * e0y + dz * e0z; + const w = dx * e1x + dy * e1y + dz * e1z; + return { i, angle: Math.atan2(w, u) }; + }); + + angles.sort((a, b) => a.angle - b.angle); + return angles.map((a) => a.i); + } + + // ── 20 triangular faces ──────────────────────────────────────────────────── + // For each icosahedron face, the 3 edge midpoints form a triangle. + // We need CCW winding from outside — sort by centroid normal. + const trianglePolygons: Polygon[] = triMidIndices.map(([ma, mb, mc]) => { + const [ax, ay, az] = midpointsRaw[ma]; + const [bx, by, bz] = midpointsRaw[mb]; + const [ccx, ccy, ccz] = midpointsRaw[mc]; + // Face normal direction = centroid of face (since solid is centered at origin). + const normal: [number, number, number] = [ + (ax + bx + ccx) / 3, + (ay + by + ccy) / 3, + (az + bz + ccz) / 3, + ]; + const sorted = sortCCW([ma, mb, mc], normal); + return { vertices: sorted.map((i) => v[i]), color }; + }); + + // ── 12 pentagonal faces ──────────────────────────────────────────────────── + // For each icosahedron vertex, its 5 incident-edge midpoints form a pentagon. + const pentagonPolygons: Polygon[] = icoRaw.map((vRaw, vi) => { + const mids = vertexEdgeMids[vi]; + // Normal direction = the icosahedron vertex itself (which is the centroid direction). + const normal = vRaw as [number, number, number]; + const sorted = sortCCW(mids, normal); + return { vertices: sorted.map((i) => v[i]), color }; + }); + + return [...trianglePolygons, ...pentagonPolygons]; +} diff --git a/packages/core/src/helpers/index.ts b/packages/core/src/helpers/index.ts index 82bc416f..356c81b5 100644 --- a/packages/core/src/helpers/index.ts +++ b/packages/core/src/helpers/index.ts @@ -18,3 +18,83 @@ export { dodecahedronPolygons } from "./dodecahedronPolygons"; export type { DodecahedronPolygonsOptions } from "./dodecahedronPolygons"; export { icosahedronPolygons } from "./icosahedronPolygons"; export type { IcosahedronPolygonsOptions } from "./icosahedronPolygons"; +export { spherePolygons } from "./spherePolygons"; +export type { SpherePolygonsOptions } from "./spherePolygons"; +export { cylinderPolygons } from "./cylinderPolygons"; +export type { CylinderPolygonsOptions } from "./cylinderPolygons"; +export { conePolygons } from "./conePolygons"; +export type { ConePolygonsOptions } from "./conePolygons"; +export { torusPolygons } from "./torusPolygons"; +export type { TorusPolygonsOptions } from "./torusPolygons"; +export { pyramidPolygons } from "./pyramidPolygons"; +export type { PyramidPolygonsOptions } from "./pyramidPolygons"; +export { prismPolygons } from "./prismPolygons"; +export type { PrismPolygonsOptions } from "./prismPolygons"; +export { antiprismPolygons } from "./antiprismPolygons"; +export type { AntiprismPolygonsOptions } from "./antiprismPolygons"; +export { bipyramidPolygons } from "./bipyramidPolygons"; +export type { BipyramidPolygonsOptions } from "./bipyramidPolygons"; +export { trapezohedronPolygons } from "./trapezohedronPolygons"; +export type { TrapezohedronPolygonsOptions } from "./trapezohedronPolygons"; +export { smallStellatedDodecahedronPolygons } from "./smallStellatedDodecahedronPolygons"; +export type { SmallStellatedDodecahedronPolygonsOptions } from "./smallStellatedDodecahedronPolygons"; +export { greatDodecahedronPolygons } from "./greatDodecahedronPolygons"; +export type { GreatDodecahedronPolygonsOptions } from "./greatDodecahedronPolygons"; +export { greatStellatedDodecahedronPolygons } from "./greatStellatedDodecahedronPolygons"; +export type { GreatStellatedDodecahedronPolygonsOptions } from "./greatStellatedDodecahedronPolygons"; +export { greatIcosahedronPolygons } from "./greatIcosahedronPolygons"; +export type { GreatIcosahedronPolygonsOptions } from "./greatIcosahedronPolygons"; +export { cuboctahedronPolygons } from "./cuboctahedronPolygons"; +export type { CuboctahedronPolygonsOptions } from "./cuboctahedronPolygons"; +export { icosidodecahedronPolygons } from "./icosidodecahedronPolygons"; +export type { IcosidodecahedronPolygonsOptions } from "./icosidodecahedronPolygons"; +export { truncatedTetrahedronPolygons } from "./truncatedTetrahedronPolygons"; +export type { TruncatedTetrahedronPolygonsOptions } from "./truncatedTetrahedronPolygons"; +export { truncatedCubePolygons } from "./truncatedCubePolygons"; +export type { TruncatedCubePolygonsOptions } from "./truncatedCubePolygons"; +export { truncatedOctahedronPolygons } from "./truncatedOctahedronPolygons"; +export type { TruncatedOctahedronPolygonsOptions } from "./truncatedOctahedronPolygons"; +export { truncatedDodecahedronPolygons } from "./truncatedDodecahedronPolygons"; +export type { TruncatedDodecahedronPolygonsOptions } from "./truncatedDodecahedronPolygons"; +export { truncatedIcosahedronPolygons } from "./truncatedIcosahedronPolygons"; +export type { TruncatedIcosahedronPolygonsOptions } from "./truncatedIcosahedronPolygons"; +export { truncatedCuboctahedronPolygons } from "./truncatedCuboctahedronPolygons"; +export type { TruncatedCuboctahedronPolygonsOptions } from "./truncatedCuboctahedronPolygons"; +export { truncatedIcosidodecahedronPolygons } from "./truncatedIcosidodecahedronPolygons"; +export type { TruncatedIcosidodecahedronPolygonsOptions } from "./truncatedIcosidodecahedronPolygons"; +export { rhombicuboctahedronPolygons } from "./rhombicuboctahedronPolygons"; +export type { RhombicuboctahedronPolygonsOptions } from "./rhombicuboctahedronPolygons"; +export { rhombicosidodecahedronPolygons } from "./rhombicosidodecahedronPolygons"; +export type { RhombicosidodecahedronPolygonsOptions } from "./rhombicosidodecahedronPolygons"; +export { snubCubePolygons } from "./snubCubePolygons"; +export type { SnubCubePolygonsOptions } from "./snubCubePolygons"; +export { snubDodecahedronPolygons } from "./snubDodecahedronPolygons"; +export type { SnubDodecahedronPolygonsOptions } from "./snubDodecahedronPolygons"; +export { rhombicDodecahedronPolygons } from "./rhombicDodecahedronPolygons"; +export type { RhombicDodecahedronPolygonsOptions } from "./rhombicDodecahedronPolygons"; +export { rhombicTriacontahedronPolygons } from "./rhombicTriacontahedronPolygons"; +export type { RhombicTriacontahedronPolygonsOptions } from "./rhombicTriacontahedronPolygons"; +export { triakisTetrahedronPolygons } from "./triakisTetrahedronPolygons"; +export type { TriakisTetrahedronPolygonsOptions } from "./triakisTetrahedronPolygons"; +export { triakisOctahedronPolygons } from "./triakisOctahedronPolygons"; +export type { TriakisOctahedronPolygonsOptions } from "./triakisOctahedronPolygons"; +export { tetrakisHexahedronPolygons } from "./tetrakisHexahedronPolygons"; +export type { TetrakisHexahedronPolygonsOptions } from "./tetrakisHexahedronPolygons"; +export { triakisIcosahedronPolygons } from "./triakisIcosahedronPolygons"; +export type { TriakisIcosahedronPolygonsOptions } from "./triakisIcosahedronPolygons"; +export { pentakisDodecahedronPolygons } from "./pentakisDodecahedronPolygons"; +export type { PentakisDodecahedronPolygonsOptions } from "./pentakisDodecahedronPolygons"; +export { disdyakisDodecahedronPolygons } from "./disdyakisDodecahedronPolygons"; +export type { DisdyakisDodecahedronPolygonsOptions } from "./disdyakisDodecahedronPolygons"; +export { disdyakisTriacontahedronPolygons } from "./disdyakisTriacontahedronPolygons"; +export type { DisdyakisTriacontahedronPolygonsOptions } from "./disdyakisTriacontahedronPolygons"; +export { deltoidalIcositetrahedronPolygons } from "./deltoidalIcositetrahedronPolygons"; +export type { DeltoidalIcositetrahedronPolygonsOptions } from "./deltoidalIcositetrahedronPolygons"; +export { deltoidalHexecontahedronPolygons } from "./deltoidalHexecontahedronPolygons"; +export type { DeltoidalHexecontahedronPolygonsOptions } from "./deltoidalHexecontahedronPolygons"; +export { pentagonalIcositetrahedronPolygons } from "./pentagonalIcositetrahedronPolygons"; +export type { PentagonalIcositetrahedronPolygonsOptions } from "./pentagonalIcositetrahedronPolygons"; +export { pentagonalHexecontahedronPolygons } from "./pentagonalHexecontahedronPolygons"; +export type { PentagonalHexecontahedronPolygonsOptions } from "./pentagonalHexecontahedronPolygons"; +export { resolveGeometry } from "./geometryRegistry"; +export type { GlyphGeometryName, GlyphGeometryOptions } from "./geometryRegistry"; diff --git a/packages/core/src/helpers/octahedronPolygons.ts b/packages/core/src/helpers/octahedronPolygons.ts index 499bc812..ebc16383 100644 --- a/packages/core/src/helpers/octahedronPolygons.ts +++ b/packages/core/src/helpers/octahedronPolygons.ts @@ -1,6 +1,6 @@ /** * Geometry for a small solid-color octahedron — the marker shape used by - * `GlyphcssDirectionalLightHelper` to indicate where a directional light is + * `GlyphDirectionalLightHelper` to indicate where a directional light is * shining from. Eight CCW-from-outside triangular faces, vertices at * `center ± (size, 0, 0)` etc. */ diff --git a/packages/core/src/helpers/pentagonalHexecontahedronPolygons.ts b/packages/core/src/helpers/pentagonalHexecontahedronPolygons.ts new file mode 100644 index 00000000..4be1ebaf --- /dev/null +++ b/packages/core/src/helpers/pentagonalHexecontahedronPolygons.ts @@ -0,0 +1,23 @@ +/** + * Geometry for a pentagonal hexecontahedron — 60 irregular-pentagon faces. + * The dual of the snub dodecahedron. + */ +import type { Polygon, Vec3 } from "../types"; +import { snubDodecahedronPolygons } from "./snubDodecahedronPolygons"; +import { polyhedronDual } from "./_dualPolyhedron"; + +export interface PentagonalHexecontahedronPolygonsOptions { + center: Vec3; + size: number; + color?: string; +} + +export function pentagonalHexecontahedronPolygons(options: PentagonalHexecontahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const primal = snubDodecahedronPolygons({ center: [0, 0, 0], size }); + const dual = polyhedronDual(primal); + return dual.map((p) => ({ + vertices: p.vertices.map(([x, y, z]) => [x + center[0], y + center[1], z + center[2]] as Vec3), + color, + })); +} diff --git a/packages/core/src/helpers/pentagonalIcositetrahedronPolygons.ts b/packages/core/src/helpers/pentagonalIcositetrahedronPolygons.ts new file mode 100644 index 00000000..e409ca03 --- /dev/null +++ b/packages/core/src/helpers/pentagonalIcositetrahedronPolygons.ts @@ -0,0 +1,23 @@ +/** + * Geometry for a pentagonal icositetrahedron — 24 irregular-pentagon faces. + * The dual of the snub cube. + */ +import type { Polygon, Vec3 } from "../types"; +import { snubCubePolygons } from "./snubCubePolygons"; +import { polyhedronDual } from "./_dualPolyhedron"; + +export interface PentagonalIcositetrahedronPolygonsOptions { + center: Vec3; + size: number; + color?: string; +} + +export function pentagonalIcositetrahedronPolygons(options: PentagonalIcositetrahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const primal = snubCubePolygons({ center: [0, 0, 0], size }); + const dual = polyhedronDual(primal); + return dual.map((p) => ({ + vertices: p.vertices.map(([x, y, z]) => [x + center[0], y + center[1], z + center[2]] as Vec3), + color, + })); +} diff --git a/packages/core/src/helpers/pentakisDodecahedronPolygons.ts b/packages/core/src/helpers/pentakisDodecahedronPolygons.ts new file mode 100644 index 00000000..24d5b82e --- /dev/null +++ b/packages/core/src/helpers/pentakisDodecahedronPolygons.ts @@ -0,0 +1,23 @@ +/** + * Geometry for a pentakis dodecahedron — 60 isosceles-triangle faces. + * The dual of the truncated icosahedron. + */ +import type { Polygon, Vec3 } from "../types"; +import { truncatedIcosahedronPolygons } from "./truncatedIcosahedronPolygons"; +import { polyhedronDual } from "./_dualPolyhedron"; + +export interface PentakisDodecahedronPolygonsOptions { + center: Vec3; + size: number; + color?: string; +} + +export function pentakisDodecahedronPolygons(options: PentakisDodecahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const primal = truncatedIcosahedronPolygons({ center: [0, 0, 0], size }); + const dual = polyhedronDual(primal); + return dual.map((p) => ({ + vertices: p.vertices.map(([x, y, z]) => [x + center[0], y + center[1], z + center[2]] as Vec3), + color, + })); +} diff --git a/packages/core/src/helpers/planePolygons.ts b/packages/core/src/helpers/planePolygons.ts index bfee2153..d6894c31 100644 --- a/packages/core/src/helpers/planePolygons.ts +++ b/packages/core/src/helpers/planePolygons.ts @@ -48,7 +48,7 @@ export function planePolygons(options: PlanePolygonsOptions): Polygon[] { return v; }; // CCW when viewed from the +axis side. The quad is double-sided in CSS - // (no back-face cull when rendered through .glyphcss-mesh), so winding is + // (no back-face cull when rendered through .glyph-mesh), so winding is // primarily a documentation aid here. return [ { diff --git a/packages/core/src/helpers/prismPolygons.ts b/packages/core/src/helpers/prismPolygons.ts new file mode 100644 index 00000000..fb8d8d2b --- /dev/null +++ b/packages/core/src/helpers/prismPolygons.ts @@ -0,0 +1,65 @@ +/** + * Geometry for a right N-gonal prism aligned to the Y axis. The prism is + * centered at `center`, with the bottom cap at `y - height/2` and the top + * cap at `y + height/2`. The N-gon cross-section has circumradius `radius`. + * + * Output (sides + 2 polygons): + * - `sides` side quads — each `[top_i, top_(i+1), bottom_(i+1), bottom_i]` (CCW from outside). + * - 1 top cap N-gon — CCW when viewed from +Y. + * - 1 bottom cap N-gon — CCW when viewed from −Y (reversed ring order). + */ +import type { Polygon, Vec3 } from "../types"; + +export interface PrismPolygonsOptions { + /** Center of the prism in world space. */ + center: Vec3; + /** Circumradius of the N-gon cross-section. */ + radius: number; + /** Total height along the Y axis. */ + height: number; + /** Number of sides on the N-gon cross-section. Defaults to 6. */ + sides?: number; + /** Fill color applied to all faces. */ + color?: string; +} + +export function prismPolygons(options: PrismPolygonsOptions): Polygon[] { + const { center, radius, height, sides = 6, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + const hy = height / 2; + const polygons: Polygon[] = []; + + // Generate bottom and top ring vertices + const bottom: Vec3[] = []; + const top: Vec3[] = []; + for (let i = 0; i < sides; i++) { + const theta = (2 * Math.PI * i) / sides; + const x = cx + radius * Math.cos(theta); + const z = cz + radius * Math.sin(theta); + bottom.push([x, cy - hy, z]); + top.push([x, cy + hy, z]); + } + + // Side quads: [top_i, top_(i+1), bottom_(i+1), bottom_i] — CCW from outside + for (let i = 0; i < sides; i++) { + const next = (i + 1) % sides; + polygons.push({ + vertices: [top[i], top[next], bottom[next], bottom[i]], + color, + }); + } + + // Top cap: CCW from +Y → reversed ring order + polygons.push({ + vertices: [...top].reverse() as Vec3[], + color, + }); + + // Bottom cap: CCW from −Y → natural ring order + polygons.push({ + vertices: [...bottom] as Vec3[], + color, + }); + + return polygons; +} diff --git a/packages/core/src/helpers/pyramidPolygons.ts b/packages/core/src/helpers/pyramidPolygons.ts new file mode 100644 index 00000000..f66216ab --- /dev/null +++ b/packages/core/src/helpers/pyramidPolygons.ts @@ -0,0 +1,59 @@ +/** + * Geometry for a regular N-gonal pyramid along the Y axis. The base polygon + * is centred at `y - height/2` with circumradius `radius`; the apex is at + * `y + height/2`, both relative to `center`. + * + * Output (sides + 1 polygons): + * - `sides` side triangles — each `[apex, base_(i+1), base_i]` (CCW from outside). + * - 1 base cap N-gon — CCW when viewed from −Y (reversed ring order). + * + * Default sides=4 → square pyramid (5 polygons total). + */ +import type { Polygon, Vec3 } from "../types"; + +export interface PyramidPolygonsOptions { + /** Center of the pyramid in world space. */ + center: Vec3; + /** Circumradius of the base polygon. */ + radius: number; + /** Total height along the Y axis. */ + height: number; + /** Number of base polygon sides. Defaults to 4 (square pyramid). */ + sides?: number; + /** Fill color applied to all faces. */ + color?: string; +} + +export function pyramidPolygons(options: PyramidPolygonsOptions): Polygon[] { + const { center, radius, height, sides = 4, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + const hy = height / 2; + const polygons: Polygon[] = []; + + // Base ring vertices + const base: Vec3[] = []; + for (let i = 0; i < sides; i++) { + const theta = (2 * Math.PI * i) / sides; + base.push([cx + radius * Math.cos(theta), cy - hy, cz + radius * Math.sin(theta)]); + } + + const apex: Vec3 = [cx, cy + hy, cz]; + + // Side triangles: [apex, base_(i+1), base_i] — CCW from outside + for (let i = 0; i < sides; i++) { + const next = (i + 1) % sides; + polygons.push({ + vertices: [apex, base[next], base[i]], + color, + }); + } + + // Base cap: CCW from −Y → natural ring order (ring is generated CCW in XZ, + // which already gives a −Y-outward normal for the bottom-facing cap). + polygons.push({ + vertices: [...base] as Vec3[], + color, + }); + + return polygons; +} diff --git a/packages/core/src/helpers/rhombicDodecahedronPolygons.ts b/packages/core/src/helpers/rhombicDodecahedronPolygons.ts new file mode 100644 index 00000000..f9a39e48 --- /dev/null +++ b/packages/core/src/helpers/rhombicDodecahedronPolygons.ts @@ -0,0 +1,23 @@ +/** + * Geometry for a rhombic dodecahedron — 12 rhombic faces (24 vertices of the dual). + * The dual of the cuboctahedron. + */ +import type { Polygon, Vec3 } from "../types"; +import { cuboctahedronPolygons } from "./cuboctahedronPolygons"; +import { polyhedronDual } from "./_dualPolyhedron"; + +export interface RhombicDodecahedronPolygonsOptions { + center: Vec3; + size: number; + color?: string; +} + +export function rhombicDodecahedronPolygons(options: RhombicDodecahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const primal = cuboctahedronPolygons({ center: [0, 0, 0], size }); + const dual = polyhedronDual(primal); + return dual.map((p) => ({ + vertices: p.vertices.map(([x, y, z]) => [x + center[0], y + center[1], z + center[2]] as Vec3), + color, + })); +} diff --git a/packages/core/src/helpers/rhombicTriacontahedronPolygons.ts b/packages/core/src/helpers/rhombicTriacontahedronPolygons.ts new file mode 100644 index 00000000..e96efc06 --- /dev/null +++ b/packages/core/src/helpers/rhombicTriacontahedronPolygons.ts @@ -0,0 +1,23 @@ +/** + * Geometry for a rhombic triacontahedron — 30 rhombic faces. + * The dual of the icosidodecahedron. + */ +import type { Polygon, Vec3 } from "../types"; +import { icosidodecahedronPolygons } from "./icosidodecahedronPolygons"; +import { polyhedronDual } from "./_dualPolyhedron"; + +export interface RhombicTriacontahedronPolygonsOptions { + center: Vec3; + size: number; + color?: string; +} + +export function rhombicTriacontahedronPolygons(options: RhombicTriacontahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const primal = icosidodecahedronPolygons({ center: [0, 0, 0], size }); + const dual = polyhedronDual(primal); + return dual.map((p) => ({ + vertices: p.vertices.map(([x, y, z]) => [x + center[0], y + center[1], z + center[2]] as Vec3), + color, + })); +} diff --git a/packages/core/src/helpers/rhombicosidodecahedronPolygons.ts b/packages/core/src/helpers/rhombicosidodecahedronPolygons.ts new file mode 100644 index 00000000..fcb33a65 --- /dev/null +++ b/packages/core/src/helpers/rhombicosidodecahedronPolygons.ts @@ -0,0 +1,98 @@ +/** + * Geometry for a rhombicosidodecahedron — 20 triangular faces + 30 square faces + * + 12 pentagonal faces (62 faces total, 60 vertices). Vertices fall into three + * families of cyclic-permutation + sign-combination groups: + * + * (±1, ±1, ±φ³) — 3 cyclic perms × 8 signs = 24 vertices + * (±φ², ±φ, ±2φ) — 3 cyclic perms × 8 signs = 24 vertices + * (±(2+φ), 0, ±φ²) — 3 cyclic perms × 4 signs = 12 vertices + * + * where φ = (1+√5)/2. Total: 60 vertices. Scaled so the circumradius equals `size`. + * + * Face decomposition: + * 20 triangles — corner faces (one per icosahedron vertex). + * 30 squares — edge faces (one per icosahedron edge). + * 12 pentagons — cap faces (one per dodecahedron face). + * + * Faces discovered via edge-graph enumeration (planar, outward-facing cycles of + * length 3, 4, 5). Each face is CCW-from-outside. + */ +import type { Polygon, Vec3 } from "../types"; +import { buildAdjList, findFacesOfLength, sortCCW, faceNormal } from "./_facesFromEdgeGraph"; + +export interface RhombicosidodecahedronPolygonsOptions { + /** Center of the rhombicosidodecahedron in world space. */ + center: Vec3; + /** Circumradius — distance from center to each vertex. */ + size: number; + /** Fill color applied to all sixty-two faces. */ + color?: string; +} + +export function rhombicosidodecahedronPolygons(options: RhombicosidodecahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + const phi = (1 + Math.sqrt(5)) / 2; + const phi2 = phi * phi; + const phi3 = phi2 * phi; + + // Family A: (±1, ±1, ±φ³) and cyclic permutations — 24 vertices. + const rawAll: [number, number, number][] = []; + for (const [px, py, pz] of [[1, 1, phi3], [1, phi3, 1], [phi3, 1, 1]] as [number,number,number][]) { + for (const sx of [-1, 1]) for (const sy of [-1, 1]) for (const sz of [-1, 1]) { + rawAll.push([sx * px, sy * py, sz * pz]); + } + } + + // Family B: (±φ², ±φ, ±2φ) and cyclic permutations — 24 vertices. + for (const [px, py, pz] of [[phi2, phi, 2 * phi], [phi, 2 * phi, phi2], [2 * phi, phi2, phi]] as [number,number,number][]) { + for (const sx of [-1, 1]) for (const sy of [-1, 1]) for (const sz of [-1, 1]) { + rawAll.push([sx * px, sy * py, sz * pz]); + } + } + + // Family C: (±(2+φ), 0, ±φ²) and cyclic permutations — 12 vertices. + // Each cyclic permutation has the zero in a different position. Using all 8 + // sign combinations and deduplicating handles the zero-sign invariance. + for (const [px, py, pz] of [[2 + phi, 0, phi2], [0, phi2, 2 + phi], [phi2, 2 + phi, 0]] as [number,number,number][]) { + for (const sx of [-1, 1]) for (const sy of [-1, 1]) for (const sz of [-1, 1]) { + rawAll.push([sx * px, sy * py, sz * pz]); + } + } + + // Deduplicate. + const seen = new Set(); + const raw: [number, number, number][] = []; + for (const pt of rawAll) { + const key = `${pt[0].toFixed(8)},${pt[1].toFixed(8)},${pt[2].toFixed(8)}`; + if (!seen.has(key)) { seen.add(key); raw.push(pt); } + } + + // Raw circumradius. + const [rx, ry, rz] = raw[0]; + const rawCircumradius = Math.sqrt(rx * rx + ry * ry + rz * rz); + const s = size / rawCircumradius; + + const v: Vec3[] = raw.map(([x, y, z]) => [cx + x * s, cy + y * s, cz + z * s]); + + // Build the edge adjacency list. + const { adj } = buildAdjList(raw); + + // Discover all planar outward-facing cycles. + const triangles = findFacesOfLength(raw, adj, 3); // 20 triangles + const squares = findFacesOfLength(raw, adj, 4); // 30 squares + const pentagons = findFacesOfLength(raw, adj, 5); // 12 pentagons + + function toPolygon(indices: number[]): Polygon { + const normal = faceNormal(raw, indices); + const sorted = sortCCW(raw, indices, normal); + return { vertices: sorted.map((i) => v[i]), color }; + } + + return [ + ...triangles.map(toPolygon), + ...squares.map(toPolygon), + ...pentagons.map(toPolygon), + ]; +} diff --git a/packages/core/src/helpers/rhombicuboctahedronPolygons.ts b/packages/core/src/helpers/rhombicuboctahedronPolygons.ts new file mode 100644 index 00000000..0a2a9fdb --- /dev/null +++ b/packages/core/src/helpers/rhombicuboctahedronPolygons.ts @@ -0,0 +1,72 @@ +/** + * Geometry for a rhombicuboctahedron — 8 triangular faces + 18 square faces + * (26 faces total, 24 vertices). Vertices are all permutations of + * (±1, ±1, ±(1+√2)) — 3 axis choices × 8 sign combinations = 24 vertices. + * Scaled so the circumradius equals `size`. + * + * Face decomposition: + * 8 triangles — the 8 corner faces (one per octant). + * 18 squares — 6 axial squares + 12 edge squares. + * + * Faces discovered via edge-graph enumeration (planar, outward-facing cycles of + * length 3 and 4). Each face is CCW-from-outside. + */ +import type { Polygon, Vec3 } from "../types"; +import { buildAdjList, findFacesOfLength, sortCCW, faceNormal } from "./_facesFromEdgeGraph"; + +export interface RhombicuboctahedronPolygonsOptions { + /** Center of the rhombicuboctahedron in world space. */ + center: Vec3; + /** Circumradius — distance from center to each vertex. */ + size: number; + /** Fill color applied to all twenty-six faces. */ + color?: string; +} + +export function rhombicuboctahedronPolygons(options: RhombicuboctahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + const q = 1 + Math.sqrt(2); + + // Generate all 24 vertices: 3 axis choices for the `q` coordinate × 8 sign combos. + // The 3 axis choices give: (±1, ±1, ±q), (±1, ±q, ±1), (±q, ±1, ±1). + const raw: [number, number, number][] = []; + const axes: [number, number, number][] = [ + [1, 1, q], [1, q, 1], [q, 1, 1], + ]; + for (const [px, py, pz] of axes) { + for (const sx of [-1, 1]) { + for (const sy of [-1, 1]) { + for (const sz of [-1, 1]) { + raw.push([sx * px, sy * py, sz * pz]); + } + } + } + } + + // Raw circumradius: √(1 + 1 + q²) = √(2 + (1+√2)²) = √(5 + 2√2). + const [rx, ry, rz] = raw[0]; + const rawCircumradius = Math.sqrt(rx * rx + ry * ry + rz * rz); + const s = size / rawCircumradius; + + const v: Vec3[] = raw.map(([x, y, z]) => [cx + x * s, cy + y * s, cz + z * s]); + + // Build the edge adjacency list. + const { adj } = buildAdjList(raw); + + // Discover all planar outward-facing cycles of each expected face length. + const triangles = findFacesOfLength(raw, adj, 3); // 8 triangles + const squares = findFacesOfLength(raw, adj, 4); // 18 squares + + function toPolygon(indices: number[]): Polygon { + const normal = faceNormal(raw, indices); + const sorted = sortCCW(raw, indices, normal); + return { vertices: sorted.map((i) => v[i]), color }; + } + + return [ + ...triangles.map(toPolygon), + ...squares.map(toPolygon), + ]; +} diff --git a/packages/core/src/helpers/smallStellatedDodecahedronPolygons.ts b/packages/core/src/helpers/smallStellatedDodecahedronPolygons.ts new file mode 100644 index 00000000..51b34420 --- /dev/null +++ b/packages/core/src/helpers/smallStellatedDodecahedronPolygons.ts @@ -0,0 +1,169 @@ +/** + * Geometry for a small stellated dodecahedron — Schläfli symbol {5/2, 5}. + * 12 pentagram faces on the 12 icosahedron vertices; each pentagram is + * decomposed into 5 triangles fanned from the face centroid (60 triangles + * total). Using star-polygon faces directly would fan-triangulate into a + * convex pentagon; the fan-from-centroid approach produces the correct + * 5-pointed-star silhouette. + * + * Vertices: the 12 standard icosahedron vertices (circumradius = `size`). + * Each pentagram face: centroid + 5 outer points picked from the icosahedron + * vertex set and ordered angularly in the plane perpendicular to the face + * axis; the [0,2,4,1,3] permutation of that angular order produces the + * every-other-vertex (pentagram) skip that makes a star. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface SmallStellatedDodecahedronPolygonsOptions { + /** Center of the polyhedron in world space. */ + center: Vec3; + /** Circumradius — distance from center to each vertex. */ + size: number; + /** Fill color applied to all faces. */ + color?: string; +} + +export function smallStellatedDodecahedronPolygons( + options: SmallStellatedDodecahedronPolygonsOptions +): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + const phi = (1 + Math.sqrt(5)) / 2; + const s = size / Math.sqrt(1 + phi * phi); + + // 12 icosahedron vertices (same ordering as icosahedronPolygons.ts). + const raw: [number, number, number][] = [ + [ 0, -1, -phi], // 0 + [ 0, -1, phi], // 1 + [ 0, 1, -phi], // 2 + [ 0, 1, phi], // 3 + [-1, -phi, 0], // 4 + [-1, phi, 0], // 5 + [ 1, -phi, 0], // 6 + [ 1, phi, 0], // 7 + [-phi, 0, -1], // 8 + [ phi, 0, -1], // 9 + [-phi, 0, 1], // 10 + [ phi, 0, 1], // 11 + ]; + + const v: Vec3[] = raw.map(([x, y, z]) => [cx + x * s, cy + y * s, cz + z * s]); + + // For each icosahedron vertex c (0..11): + // 1. Find its antipode (the vertex with dot product closest to -|c|²). + // 2. Exclude c itself and its antipode; the remaining 10 vertices split + // into two pentagons around c and its antipode. + // 3. Sort all 10 by dot product with raw[c] descending — the top 5 are + // the pentagon closest to c. + // 4. Sort those 5 by angle in the plane perpendicular to raw[c] to get a + // consistent angular order. + // 5. Apply [0,2,4,1,3] permutation for pentagram (every-other) skip order. + // 6. Emit 5 triangles: [centroid, star_i, star_{(i+2)%5}]. + + function dot3(a: [number, number, number], b: [number, number, number]): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + } + + // Build a local 2-D basis in the plane perpendicular to `axis`. + function perpBasis(axis: [number, number, number]): { + u: [number, number, number]; + w: [number, number, number]; + } { + // Pick a vector not parallel to axis. + const ref: [number, number, number] = + Math.abs(axis[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]; + // u = normalize(ref − (ref·axis)axis) + const d = dot3(ref, axis); + const ux = ref[0] - d * axis[0]; + const uy = ref[1] - d * axis[1]; + const uz = ref[2] - d * axis[2]; + const ul = Math.sqrt(ux * ux + uy * uy + uz * uz); + const u: [number, number, number] = [ux / ul, uy / ul, uz / ul]; + // w = axis × u + const w: [number, number, number] = [ + axis[1] * u[2] - axis[2] * u[1], + axis[2] * u[0] - axis[0] * u[2], + axis[0] * u[1] - axis[1] * u[0], + ]; + return { u, w }; + } + + const polygons: Polygon[] = []; + + for (let c = 0; c < 12; c++) { + const axis = raw[c]; + const axisLenSq = dot3(axis, axis); + + // Find antipode: most negative dot product with axis. + let antipode = -1; + let minDot = Infinity; + for (let i = 0; i < 12; i++) { + if (i === c) continue; + const d = dot3(raw[i], axis); + if (d < minDot) { minDot = d; antipode = i; } + } + + // Collect the other 10 vertices; sort descending by dot with axis to find + // the 5 nearest to c. + const others: { idx: number; d: number }[] = []; + for (let i = 0; i < 12; i++) { + if (i === c || i === antipode) continue; + others.push({ idx: i, d: dot3(raw[i], axis) }); + } + others.sort((a, b) => b.d - a.d); + const nearFive = others.slice(0, 5).map((o) => o.idx); + + // Sort by angle in the plane perpendicular to axis. + const normAxis: [number, number, number] = [ + axis[0] / Math.sqrt(axisLenSq), + axis[1] / Math.sqrt(axisLenSq), + axis[2] / Math.sqrt(axisLenSq), + ]; + const { u, w } = perpBasis(normAxis); + nearFive.sort((a, b) => { + const ra = raw[a]; + const rb = raw[b]; + const angA = Math.atan2(dot3(ra, w), dot3(ra, u)); + const angB = Math.atan2(dot3(rb, w), dot3(rb, u)); + return angA - angB; + }); + + // Pentagram skip order: [0,2,4,1,3] of the angularly-sorted pentagon. + const starOrder = [0, 2, 4, 1, 3]; + const outerIdx = starOrder.map((i) => nearFive[i]); + + // Centroid of the 5 outer points (in world space). + let gcx = 0, gcy = 0, gcz = 0; + for (const i of outerIdx) { + gcx += v[i][0]; gcy += v[i][1]; gcz += v[i][2]; + } + const centroid: Vec3 = [gcx / 5, gcy / 5, gcz / 5]; + + // 5 triangles: [centroid, outer_i, outer_{(i+2)%5}]. + for (let i = 0; i < 5; i++) { + const a = v[outerIdx[i]]; + const b = v[outerIdx[(i + 2) % 5]]; + + // Orient normal away from polyhedron centroid (origin in local coords). + // Cross product (a-centroid) × (b-centroid) should point away from origin. + const ea: Vec3 = [a[0] - centroid[0], a[1] - centroid[1], a[2] - centroid[2]]; + const eb: Vec3 = [b[0] - centroid[0], b[1] - centroid[1], b[2] - centroid[2]]; + const nx = ea[1] * eb[2] - ea[2] * eb[1]; + const ny = ea[2] * eb[0] - ea[0] * eb[2]; + const nz = ea[0] * eb[1] - ea[1] * eb[0]; + // The "outward" direction is from polyhedron center (cx,cy,cz) toward centroid. + const outX = centroid[0] - cx; + const outY = centroid[1] - cy; + const outZ = centroid[2] - cz; + const flip = nx * outX + ny * outY + nz * outZ < 0; + + polygons.push({ + vertices: flip ? [centroid, b, a] : [centroid, a, b], + color, + }); + } + } + + return polygons; +} diff --git a/packages/core/src/helpers/snubCubePolygons.ts b/packages/core/src/helpers/snubCubePolygons.ts new file mode 100644 index 00000000..d150d144 --- /dev/null +++ b/packages/core/src/helpers/snubCubePolygons.ts @@ -0,0 +1,101 @@ +/** + * Geometry for a snub cube — 32 triangular faces + 6 square faces (38 faces + * total, 24 vertices). This is a chiral Archimedean solid; the right-handed + * enantiomorph is produced here (see chirality note below). + * + * The 24 vertices involve the tribonacci constant t ≈ 1.83929, the real root of + * t³ = t² + t + 1. The vertex set is: + * - even cyclic permutations of (±1, ±1/t, ±t) with an even number of minuses, AND + * - odd (anti-cyclic) permutations of (±1, ±1/t, ±t) with an odd number of minuses. + * + * Chirality: the right-handed convention is fixed by the choice of + * "even-perm × even-sign + odd-perm × odd-sign" — the alternative pairing + * produces the left-handed mirror image. + * + * Face decomposition: + * 32 triangles — 8 sets of 4 snub triangles (filling the gaps between squares). + * 6 squares — one per face of the parent cube. + * + * Faces discovered via edge-graph enumeration (planar, outward-facing cycles of + * length 3 and 4). Each face is CCW-from-outside. + */ +import type { Polygon, Vec3 } from "../types"; +import { buildAdjList, findFacesOfLength, sortCCW, faceNormal } from "./_facesFromEdgeGraph"; + +export interface SnubCubePolygonsOptions { + /** Center of the snub cube in world space. */ + center: Vec3; + /** Circumradius — distance from center to each vertex. */ + size: number; + /** Fill color applied to all thirty-eight faces. */ + color?: string; +} + +export function snubCubePolygons(options: SnubCubePolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + // Tribonacci constant: real root of t³ - t² - t - 1 = 0. + // Computed as: t = (1 + cbrt(19 + 3√33) + cbrt(19 - 3√33)) / 3. + const t = (1 + Math.cbrt(19 + 3 * Math.sqrt(33)) + Math.cbrt(19 - 3 * Math.sqrt(33))) / 3; + const a = 1; + const b = 1 / t; + const c = t; + + // Even cyclic permutations (same handedness as the identity permutation). + const evenPerms: [number, number, number][] = [[a, b, c], [b, c, a], [c, a, b]]; + // Odd (anti-cyclic) permutations. + const oddPerms: [number, number, number][] = [[c, b, a], [b, a, c], [a, c, b]]; + + const raw: [number, number, number][] = []; + + // Even perm + even number of minus signs (0 or 2 minuses → parity 0). + for (const [px, py, pz] of evenPerms) { + for (const sx of [-1, 1]) { + for (const sy of [-1, 1]) { + for (const sz of [-1, 1]) { + const minuses = (sx === -1 ? 1 : 0) + (sy === -1 ? 1 : 0) + (sz === -1 ? 1 : 0); + if (minuses % 2 === 0) raw.push([sx * px, sy * py, sz * pz]); + } + } + } + } + + // Odd perm + odd number of minus signs (1 or 3 minuses → parity 1). + for (const [px, py, pz] of oddPerms) { + for (const sx of [-1, 1]) { + for (const sy of [-1, 1]) { + for (const sz of [-1, 1]) { + const minuses = (sx === -1 ? 1 : 0) + (sy === -1 ? 1 : 0) + (sz === -1 ? 1 : 0); + if (minuses % 2 === 1) raw.push([sx * px, sy * py, sz * pz]); + } + } + } + } + // raw now has 3×4 + 3×4 = 24 vertices. + + // Raw circumradius: √(a² + b² + c²) = √(1 + 1/t² + t²). + const [rx, ry, rz] = raw[0]; + const rawCircumradius = Math.sqrt(rx * rx + ry * ry + rz * rz); + const s = size / rawCircumradius; + + const v: Vec3[] = raw.map(([x, y, z]) => [cx + x * s, cy + y * s, cz + z * s]); + + // Build the edge adjacency list. + const { adj } = buildAdjList(raw); + + // Discover all planar outward-facing cycles. + const triangles = findFacesOfLength(raw, adj, 3); // 32 triangles + const squares = findFacesOfLength(raw, adj, 4); // 6 squares + + function toPolygon(indices: number[]): Polygon { + const normal = faceNormal(raw, indices); + const sorted = sortCCW(raw, indices, normal); + return { vertices: sorted.map((i) => v[i]), color }; + } + + return [ + ...triangles.map(toPolygon), + ...squares.map(toPolygon), + ]; +} diff --git a/packages/core/src/helpers/snubDodecahedronPolygons.ts b/packages/core/src/helpers/snubDodecahedronPolygons.ts new file mode 100644 index 00000000..5e6dc636 --- /dev/null +++ b/packages/core/src/helpers/snubDodecahedronPolygons.ts @@ -0,0 +1,173 @@ +/** + * Geometry for a snub dodecahedron — 80 triangular faces + 12 pentagonal faces + * (92 faces total, 60 vertices). This is a chiral Archimedean solid; the + * right-handed enantiomorph is produced here. + * + * Construction (numerical snub operation on the icosahedron): + * For each of the 60 directed icosahedron edges (i→j), one snub vertex is placed at: + * + * v_ij = normalise((1−t)·ico[i] + t·ico[j] + s·normalise(ico[i] × ico[j])) + * + * where t ≈ 0.3554 and s ≈ 0.1342 are the unique snub parameters satisfying two + * simultaneous conditions: + * (a) inner-triangle edge length = cross-edge length (v_ij to v_ji) + * (b) inner-triangle edge length = pentagon edge length (v_ij to v_ik for adjacent k) + * + * These parameters are solved numerically via Newton's method to machine precision. + * All 60 vertices lie exactly on the unit sphere before scaling. + * + * Chirality: the right-handed enantiomorph is produced. The chirality is fixed + * by the sign of the s component: positive s causes the snub vertices to be + * displaced in the direction of ico[i] × ico[j] (the right-hand cross product). + * The opposite sign produces the left-handed mirror image. + * + * Face decomposition: + * 80 triangles — inner snub triangles (one per icosahedron face) plus outer + * snub triangles connecting adjacent face pairs. + * 12 pentagons — one per icosahedron vertex (5 snub vertices surrounding each). + * + * Faces discovered via edge-graph enumeration (planar, outward-facing cycles of + * length 3 and 5). Each face is CCW-from-outside. + */ +import type { Polygon, Vec3 } from "../types"; +import { buildAdjList, findFacesOfLength, sortCCW, faceNormal } from "./_facesFromEdgeGraph"; + +export interface SnubDodecahedronPolygonsOptions { + /** Center of the snub dodecahedron in world space. */ + center: Vec3; + /** Circumradius — distance from center to each vertex. */ + size: number; + /** Fill color applied to all ninety-two faces. */ + color?: string; +} + +export function snubDodecahedronPolygons(options: SnubDodecahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + const phi = (1 + Math.sqrt(5)) / 2; + + // ── Icosahedron base (unit sphere) ───────────────────────────────────────── + // Same vertex set as icosahedronPolygons.ts but normalised to unit circumradius. + const icoRaw: [number, number, number][] = [ + [ 0, -1, -phi], // 0 + [ 0, -1, phi], // 1 + [ 0, 1, -phi], // 2 + [ 0, 1, phi], // 3 + [-1, -phi, 0], // 4 + [-1, phi, 0], // 5 + [ 1, -phi, 0], // 6 + [ 1, phi, 0], // 7 + [-phi, 0, -1], // 8 + [ phi, 0, -1], // 9 + [-phi, 0, 1], // 10 + [ phi, 0, 1], // 11 + ]; + const icoR = Math.sqrt(1 + phi * phi); + const ico: [number, number, number][] = icoRaw.map(([x, y, z]) => [x / icoR, y / icoR, z / icoR]); + + const icoFaces: [number, number, number][] = [ + [ 0, 2, 9], [ 0, 4, 8], [ 0, 6, 4], [ 0, 8, 2], [ 0, 9, 6], + [ 1, 3, 10], [ 1, 4, 6], [ 1, 6, 11], [ 1, 10, 4], [ 1, 11, 3], + [ 2, 5, 7], [ 2, 7, 9], [ 2, 8, 5], [ 3, 5, 10], [ 3, 7, 5], + [ 3, 11, 7], [ 4, 10, 8], [ 5, 8, 10], [ 6, 9, 11], [ 7, 11, 9], + ]; + + // ── Helper math ─────────────────────────────────────────────────────────── + function cross3(a: [number, number, number], b: [number, number, number]): [number, number, number] { + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; + } + function norm3(v: [number, number, number]): number { + return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); + } + function normalise3(v: [number, number, number]): [number, number, number] { + const l = norm3(v); + return [v[0] / l, v[1] / l, v[2] / l]; + } + function dist3(a: [number, number, number], b: [number, number, number]): number { + return Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2); + } + + // Compute a snub vertex for directed edge i→j with parameters t and s. + function snubVertex(i: number, j: number, t: number, s: number): [number, number, number] { + const [ax, ay, az] = ico[i]; + const [bx, by, bz] = ico[j]; + const raw: [number, number, number] = [ + (1 - t) * ax + t * bx, + (1 - t) * ay + t * by, + (1 - t) * az + t * bz, + ]; + const c = normalise3(cross3(ico[i], ico[j])); + return normalise3([raw[0] + s * c[0], raw[1] + s * c[1], raw[2] + s * c[2]]); + } + + // ── Solve for the snub parameters t, s ──────────────────────────────────── + // Two conditions on the face [0, 2, 9] and the pentagon around vertex 0: + // c1: triangle-edge = cross-edge dist(v_02, v_29) = dist(v_02, v_20) + // c2: triangle-edge = pentagon-edge dist(v_02, v_29) = dist(v_02, v_09) + function c1(t: number, s: number): number { + const v02 = snubVertex(0, 2, t, s); + const v29 = snubVertex(2, 9, t, s); + const v20 = snubVertex(2, 0, t, s); + return dist3(v02, v29) - dist3(v02, v20); + } + function c2(t: number, s: number): number { + const v02 = snubVertex(0, 2, t, s); + const v29 = snubVertex(2, 9, t, s); + const v09 = snubVertex(0, 9, t, s); + return dist3(v02, v29) - dist3(v02, v09); + } + + // Newton's method for 2×2 system. + let snubT = 0.35, snubS = 0.13; + const eps = 1e-7; + for (let iter = 0; iter < 80; iter++) { + const f1 = c1(snubT, snubS); + const f2 = c2(snubT, snubS); + if (Math.abs(f1) < 1e-13 && Math.abs(f2) < 1e-13) break; + const df1dt = (c1(snubT + eps, snubS) - c1(snubT - eps, snubS)) / (2 * eps); + const df1ds = (c1(snubT, snubS + eps) - c1(snubT, snubS - eps)) / (2 * eps); + const df2dt = (c2(snubT + eps, snubS) - c2(snubT - eps, snubS)) / (2 * eps); + const df2ds = (c2(snubT, snubS + eps) - c2(snubT, snubS - eps)) / (2 * eps); + const det = df1dt * df2ds - df1ds * df2dt; + if (Math.abs(det) < 1e-12) break; + snubT -= (f1 * df2ds - f2 * df1ds) / det; + snubS -= (f2 * df1dt - f1 * df2dt) / det; + } + + // ── Generate all 60 snub vertices ───────────────────────────────────────── + // Each directed icosahedron edge (i→j) gives one snub vertex. + // The 60 directed edges come from 20 faces × 3 edges × 2 directions = 120, + // but deduplicated to 60. + const dirEdgeSet = new Set(); + const snubVerts: [number, number, number][] = []; + + for (const [a, b, c] of icoFaces) { + for (const [u, v] of [[a, b], [b, c], [c, a], [b, a], [c, b], [a, c]] as [number, number][]) { + const key = `${u},${v}`; + if (!dirEdgeSet.has(key)) { + dirEdgeSet.add(key); + snubVerts.push(snubVertex(u, v, snubT, snubS)); + } + } + } + // All 60 vertices are on the unit sphere. Scale to circumradius `size`. + const raw = snubVerts; // on unit sphere + const scaled: Vec3[] = raw.map(([x, y, z]) => [cx + x * size, cy + y * size, cz + z * size]); + + // ── Build edge graph and discover faces ─────────────────────────────────── + const { adj } = buildAdjList(raw); + const triangles = findFacesOfLength(raw, adj, 3); // 80 triangles + const pentagons = findFacesOfLength(raw, adj, 5); // 12 pentagons + + function toPolygon(indices: number[]): Polygon { + const normal = faceNormal(raw, indices); + const sorted = sortCCW(raw, indices, normal); + return { vertices: sorted.map((i) => scaled[i]), color }; + } + + return [ + ...triangles.map(toPolygon), + ...pentagons.map(toPolygon), + ]; +} diff --git a/packages/core/src/helpers/spherePolygons.ts b/packages/core/src/helpers/spherePolygons.ts new file mode 100644 index 00000000..65f0e1af --- /dev/null +++ b/packages/core/src/helpers/spherePolygons.ts @@ -0,0 +1,105 @@ +/** + * Icosphere geometry — a sphere approximated by subdividing an icosahedron's + * 20 triangles. At each subdivision level every triangle is split into 4 by + * inserting edge midpoints, and every midpoint is projected back onto the + * circumscribed sphere of radius `size`. Shared edge midpoints are deduped + * with a `Map` keyed on sorted endpoint indices so no seam doubles occur. + * + * `subdivisions=0` → 20 triangles (bare icosahedron). + * `subdivisions=1` → 80 triangles (default). + * `subdivisions=2` → 320 triangles. + * + * Base vertices are the golden-ratio (0, ±1, ±φ) form and cyclic permutations, + * matching `icosahedronPolygons.ts`, scaled so circumradius equals `size`. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface SpherePolygonsOptions { + /** Center of the sphere in world space. */ + center: Vec3; + /** Circumradius — distance from center to every surface vertex. */ + size: number; + /** Subdivision level. 0 = icosahedron (20 tri), 1 = 80 tri (default). */ + subdivisions?: number; + /** Fill color applied to all faces. */ + color?: string; +} + +function normalize(v: Vec3, radius: number): Vec3 { + const len = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); + return [v[0] / len * radius, v[1] / len * radius, v[2] / len * radius]; +} + +export function spherePolygons(options: SpherePolygonsOptions): Polygon[] { + const { center, size, subdivisions = 1, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + // φ (golden ratio) + const phi = (1 + Math.sqrt(5)) / 2; + // Raw circumradius of the (0, ±1, ±φ) form is √(1 + φ²). + const s = size / Math.sqrt(1 + phi * phi); + + // 12 base icosahedron vertices (origin-centered, on the sphere) + const baseRaw: [number, number, number][] = [ + [ 0, -1, -phi], // 0 + [ 0, -1, phi], // 1 + [ 0, 1, -phi], // 2 + [ 0, 1, phi], // 3 + [-1, -phi, 0], // 4 + [-1, phi, 0], // 5 + [ 1, -phi, 0], // 6 + [ 1, phi, 0], // 7 + [-phi, 0, -1], // 8 + [ phi, 0, -1], // 9 + [-phi, 0, 1], // 10 + [ phi, 0, 1], // 11 + ]; + + // Start with vertices on the unit sphere (size=1 circumradius), add center later. + let verts: Vec3[] = baseRaw.map(([x, y, z]) => normalize([x * s, y * s, z * s], size)); + + // 20 base faces matching icosahedronPolygons.ts + let faces: [number, number, number][] = [ + [ 0, 2, 9], [ 0, 4, 8], [ 0, 6, 4], [ 0, 8, 2], [ 0, 9, 6], + [ 1, 3, 10], [ 1, 4, 6], [ 1, 6, 11], [ 1, 10, 4], [ 1, 11, 3], + [ 2, 5, 7], [ 2, 7, 9], [ 2, 8, 5], [ 3, 5, 10], [ 3, 7, 5], + [ 3, 11, 7], [ 4, 10, 8], [ 5, 8, 10], [ 6, 9, 11], [ 7, 11, 9], + ]; + + // Subdivide + for (let d = 0; d < subdivisions; d++) { + const midCache = new Map(); + const newFaces: [number, number, number][] = []; + + const getMid = (a: number, b: number): number => { + const key = a < b ? `${a}_${b}` : `${b}_${a}`; + const cached = midCache.get(key); + if (cached !== undefined) return cached; + const va = verts[a]; + const vb = verts[b]; + const mid: Vec3 = [(va[0] + vb[0]) / 2, (va[1] + vb[1]) / 2, (va[2] + vb[2]) / 2]; + const idx = verts.length; + verts.push(normalize(mid, size)); + midCache.set(key, idx); + return idx; + }; + + for (const [a, b, c] of faces) { + const ab = getMid(a, b); + const bc = getMid(b, c); + const ca = getMid(c, a); + newFaces.push([a, ab, ca], [ab, b, bc], [ca, bc, c], [ab, bc, ca]); + } + faces = newFaces; + } + + // Apply center offset and emit polygons + return faces.map(([a, b, c]) => ({ + vertices: [ + [verts[a][0] + cx, verts[a][1] + cy, verts[a][2] + cz], + [verts[b][0] + cx, verts[b][1] + cy, verts[b][2] + cz], + [verts[c][0] + cx, verts[c][1] + cy, verts[c][2] + cz], + ] as Vec3[], + color, + })); +} diff --git a/packages/core/src/helpers/tetrakisHexahedronPolygons.ts b/packages/core/src/helpers/tetrakisHexahedronPolygons.ts new file mode 100644 index 00000000..f0e467da --- /dev/null +++ b/packages/core/src/helpers/tetrakisHexahedronPolygons.ts @@ -0,0 +1,23 @@ +/** + * Geometry for a tetrakis hexahedron — 24 isosceles-triangle faces. + * The dual of the truncated octahedron. + */ +import type { Polygon, Vec3 } from "../types"; +import { truncatedOctahedronPolygons } from "./truncatedOctahedronPolygons"; +import { polyhedronDual } from "./_dualPolyhedron"; + +export interface TetrakisHexahedronPolygonsOptions { + center: Vec3; + size: number; + color?: string; +} + +export function tetrakisHexahedronPolygons(options: TetrakisHexahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const primal = truncatedOctahedronPolygons({ center: [0, 0, 0], size }); + const dual = polyhedronDual(primal); + return dual.map((p) => ({ + vertices: p.vertices.map(([x, y, z]) => [x + center[0], y + center[1], z + center[2]] as Vec3), + color, + })); +} diff --git a/packages/core/src/helpers/torusPolygons.ts b/packages/core/src/helpers/torusPolygons.ts new file mode 100644 index 00000000..70686ca2 --- /dev/null +++ b/packages/core/src/helpers/torusPolygons.ts @@ -0,0 +1,70 @@ +/** + * Geometry for a torus (donut) centred at `center`, lying in the XZ plane + * with the tube axis parallel to Y. Parameterised by: + * + * θ = 2π * u / segments (around the donut ring) + * φ = 2π * v / sides (around the tube cross-section) + * + * x = (majorRadius + minorRadius * cos φ) * cos θ + * y = minorRadius * sin φ + * z = (majorRadius + minorRadius * cos φ) * sin θ + * + * Output: `segments × sides` quads, one per (u, v) cell, with modular + * index wrap and CCW-from-outside winding. + * Default segments=24, sides=12 → 288 quads. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface TorusPolygonsOptions { + /** Center of the torus in world space. */ + center: Vec3; + /** Distance from the torus center to the center of the tube. */ + majorRadius: number; + /** Radius of the tube cross-section. */ + minorRadius: number; + /** Number of divisions around the donut ring. Defaults to 24. */ + segments?: number; + /** Number of divisions around the tube cross-section. Defaults to 12. */ + sides?: number; + /** Fill color applied to all quads. */ + color?: string; +} + +export function torusPolygons(options: TorusPolygonsOptions): Polygon[] { + const { center, majorRadius, minorRadius, segments = 24, sides = 12, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + // Pre-compute all (u, v) vertices + const verts: Vec3[][] = []; + for (let u = 0; u < segments; u++) { + const theta = (2 * Math.PI * u) / segments; + const cosT = Math.cos(theta); + const sinT = Math.sin(theta); + const row: Vec3[] = []; + for (let v = 0; v < sides; v++) { + const phi = (2 * Math.PI * v) / sides; + const r = majorRadius + minorRadius * Math.cos(phi); + row.push([ + cx + r * cosT, + cy + minorRadius * Math.sin(phi), + cz + r * sinT, + ]); + } + verts.push(row); + } + + const polygons: Polygon[] = []; + for (let u = 0; u < segments; u++) { + const u1 = (u + 1) % segments; + for (let v = 0; v < sides; v++) { + const v1 = (v + 1) % sides; + // Quad: (u,v) → (u+1,v) → (u+1,v+1) → (u,v+1) — CCW from outside + polygons.push({ + vertices: [verts[u][v], verts[u1][v], verts[u1][v1], verts[u][v1]], + color, + }); + } + } + + return polygons; +} diff --git a/packages/core/src/helpers/trapezohedronPolygons.ts b/packages/core/src/helpers/trapezohedronPolygons.ts new file mode 100644 index 00000000..33f52ba2 --- /dev/null +++ b/packages/core/src/helpers/trapezohedronPolygons.ts @@ -0,0 +1,78 @@ +/** + * Geometry for an N-gonal trapezohedron (kite-faced solid, dual of the N-gonal + * antiprism). The solid has `2 * sides` kite (quadrilateral) faces, a top apex + * at y = `+halfHeight`, a bottom apex at y = `−halfHeight`, and two interleaved + * rings of `sides` vertices each at y = ±`zRing`. + * + * The belt ring elevation is derived analytically so that every kite face is + * exactly planar. Setting zR = halfHeight × (1 − cos(π/sides)) / (1 + cos(π/sides)) + * is the unique value that places all four vertices of each kite in the same plane + * (derived by requiring the fourth vertex to satisfy the plane equation of the + * first three). + * + * The top ring sits at y = +zR; the bottom ring sits at y = −zR, rotated by + * π/sides so the two rings interleave like an antiprism. + * + * Output (2·sides polygons): + * - `sides` upper kites around the top apex (CCW from outside). + * - `sides` lower kites around the bottom apex (CCW from outside). + */ +import type { Polygon, Vec3 } from "../types"; + +export interface TrapezohedronPolygonsOptions { + /** Center of the trapezohedron in world space. */ + center: Vec3; + /** Circumradius of each belt ring. */ + radius: number; + /** Half the total height — distance from equator to each apex. */ + halfHeight: number; + /** Number of kite faces per hemisphere (= number of antiprism sides). Defaults to 5. */ + sides?: number; + /** Fill color applied to all faces. */ + color?: string; +} + +export function trapezohedronPolygons(options: TrapezohedronPolygonsOptions): Polygon[] { + const { center, radius, halfHeight, sides = 5, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + // Analytically-derived belt ring elevation for exact kite planarity: + // zRing = halfHeight * (1 - cos(π/sides)) / (1 + cos(π/sides)) + const cosPN = Math.cos(Math.PI / sides); + const zRing = halfHeight * (1 - cosPN) / (1 + cosPN); + const polygons: Polygon[] = []; + + // Top ring at y = cy + zRing, bottom ring at y = cy - zRing (rotated by π/sides) + const topRing: Vec3[] = []; + const botRing: Vec3[] = []; + for (let i = 0; i < sides; i++) { + const thetaTop = (2 * Math.PI * i) / sides; + const thetaBot = thetaTop + Math.PI / sides; + topRing.push([cx + radius * Math.cos(thetaTop), cy + zRing, cz + radius * Math.sin(thetaTop)]); + botRing.push([cx + radius * Math.cos(thetaBot), cy - zRing, cz + radius * Math.sin(thetaBot)]); + } + + const topApex: Vec3 = [cx, cy + halfHeight, cz]; + const botApex: Vec3 = [cx, cy - halfHeight, cz]; + + // Upper kites: [topApex, topRing[(i+1)%sides], botRing[i], topRing[i]] + // — botRing[i] sits angularly between topRing[i] and topRing[i+1]. + for (let i = 0; i < sides; i++) { + const next = (i + 1) % sides; + polygons.push({ + vertices: [topApex, topRing[next], botRing[i], topRing[i]], + color, + }); + } + + // Lower kites: [botApex, botRing[i], topRing[(i+1)%sides], botRing[(i+1)%sides]] + // — topRing[i+1] sits angularly between botRing[i] and botRing[i+1]. + for (let i = 0; i < sides; i++) { + const next = (i + 1) % sides; + polygons.push({ + vertices: [botApex, botRing[i], topRing[next], botRing[next]], + color, + }); + } + + return polygons; +} diff --git a/packages/core/src/helpers/triakisIcosahedronPolygons.ts b/packages/core/src/helpers/triakisIcosahedronPolygons.ts new file mode 100644 index 00000000..ceb89a11 --- /dev/null +++ b/packages/core/src/helpers/triakisIcosahedronPolygons.ts @@ -0,0 +1,23 @@ +/** + * Geometry for a triakis icosahedron — 60 isosceles-triangle faces. + * The dual of the truncated dodecahedron. + */ +import type { Polygon, Vec3 } from "../types"; +import { truncatedDodecahedronPolygons } from "./truncatedDodecahedronPolygons"; +import { polyhedronDual } from "./_dualPolyhedron"; + +export interface TriakisIcosahedronPolygonsOptions { + center: Vec3; + size: number; + color?: string; +} + +export function triakisIcosahedronPolygons(options: TriakisIcosahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const primal = truncatedDodecahedronPolygons({ center: [0, 0, 0], size }); + const dual = polyhedronDual(primal); + return dual.map((p) => ({ + vertices: p.vertices.map(([x, y, z]) => [x + center[0], y + center[1], z + center[2]] as Vec3), + color, + })); +} diff --git a/packages/core/src/helpers/triakisOctahedronPolygons.ts b/packages/core/src/helpers/triakisOctahedronPolygons.ts new file mode 100644 index 00000000..f9294fb1 --- /dev/null +++ b/packages/core/src/helpers/triakisOctahedronPolygons.ts @@ -0,0 +1,23 @@ +/** + * Geometry for a triakis octahedron — 24 isosceles-triangle faces. + * The dual of the truncated cube. + */ +import type { Polygon, Vec3 } from "../types"; +import { truncatedCubePolygons } from "./truncatedCubePolygons"; +import { polyhedronDual } from "./_dualPolyhedron"; + +export interface TriakisOctahedronPolygonsOptions { + center: Vec3; + size: number; + color?: string; +} + +export function triakisOctahedronPolygons(options: TriakisOctahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const primal = truncatedCubePolygons({ center: [0, 0, 0], size }); + const dual = polyhedronDual(primal); + return dual.map((p) => ({ + vertices: p.vertices.map(([x, y, z]) => [x + center[0], y + center[1], z + center[2]] as Vec3), + color, + })); +} diff --git a/packages/core/src/helpers/triakisTetrahedronPolygons.ts b/packages/core/src/helpers/triakisTetrahedronPolygons.ts new file mode 100644 index 00000000..4d5b1915 --- /dev/null +++ b/packages/core/src/helpers/triakisTetrahedronPolygons.ts @@ -0,0 +1,23 @@ +/** + * Geometry for a triakis tetrahedron — 12 isosceles-triangle faces. + * The dual of the truncated tetrahedron. + */ +import type { Polygon, Vec3 } from "../types"; +import { truncatedTetrahedronPolygons } from "./truncatedTetrahedronPolygons"; +import { polyhedronDual } from "./_dualPolyhedron"; + +export interface TriakisTetrahedronPolygonsOptions { + center: Vec3; + size: number; + color?: string; +} + +export function triakisTetrahedronPolygons(options: TriakisTetrahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const primal = truncatedTetrahedronPolygons({ center: [0, 0, 0], size }); + const dual = polyhedronDual(primal); + return dual.map((p) => ({ + vertices: p.vertices.map(([x, y, z]) => [x + center[0], y + center[1], z + center[2]] as Vec3), + color, + })); +} diff --git a/packages/core/src/helpers/truncatedCubePolygons.ts b/packages/core/src/helpers/truncatedCubePolygons.ts new file mode 100644 index 00000000..7dee1295 --- /dev/null +++ b/packages/core/src/helpers/truncatedCubePolygons.ts @@ -0,0 +1,160 @@ +/** + * Geometry for a truncated cube — 8 triangular faces + 6 octagonal faces + * (14 faces total, 24 vertices). Constructed by cutting each corner of a unit + * cube at depth t = (2 − √2)/2 along each of its 3 incident edges. + * This truncation depth produces regular octagons on the original square faces. + * Scaled so the circumradius equals `size`. + * + * Face decomposition: + * 8 triangles — one per original cube vertex (corner caps). + * 6 octagons — one per original cube face (expanded from square to octagon). + * + * Each face is CCW-from-outside. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface TruncatedCubePolygonsOptions { + /** Center of the truncated cube in world space. */ + center: Vec3; + /** Circumradius — distance from center to each vertex. */ + size: number; + /** Fill color applied to all fourteen faces. */ + color?: string; +} + +export function truncatedCubePolygons(options: TruncatedCubePolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + // Cube base: vertices at (±1, ±1, ±1), edge length = 2. + // Truncation parameter t = (2 − √2)/2 gives regular octagons. + const t = (2 - Math.sqrt(2)) / 2; + + const cubeRaw: [number, number, number][] = [ + [-1, -1, -1], // 0 --- + [ 1, -1, -1], // 1 +-- + [ 1, 1, -1], // 2 ++- + [-1, 1, -1], // 3 -+- + [-1, -1, 1], // 4 --+ + [ 1, -1, 1], // 5 +-+ + [ 1, 1, 1], // 6 +++ + [-1, 1, 1], // 7 -++ + ]; + + // Cube edges (12 total). + const cubeEdges: [number, number][] = [ + [0, 1], [1, 2], [2, 3], [3, 0], // -z face ring + [4, 5], [5, 6], [6, 7], [7, 4], // +z face ring + [0, 4], [1, 5], [2, 6], [3, 7], // vertical edges + ]; + + // Build 24 truncation points: for each edge (a,b), two points p_ab and p_ba. + // p_ab = (1-t)*a + t*b (point on edge from a toward b, at distance t from a) + // p_ba = t*a + (1-t)*b (point on edge from b toward a, at distance t from b) + const midMap = new Map(); + + function truncPt(from: number, to: number): [number, number, number] { + const key = `${from},${to}`; + if (midMap.has(key)) return midMap.get(key)!; + const [ax, ay, az] = cubeRaw[from]; + const [bx, by, bz] = cubeRaw[to]; + const pt: [number, number, number] = [ + (1 - t) * ax + t * bx, + (1 - t) * ay + t * by, + (1 - t) * az + t * bz, + ]; + midMap.set(key, pt); + return pt; + } + + // Register all truncation points. + for (const [a, b] of cubeEdges) { + truncPt(a, b); + truncPt(b, a); + } + + // Compute raw circumradius and scale factor. + let rawCircumradius = 0; + for (const pt of midMap.values()) { + const [x, y, z] = pt; + const d = Math.sqrt(x * x + y * y + z * z); + if (d > rawCircumradius) rawCircumradius = d; + } + const s = size / rawCircumradius; + + const scaleVec = (pt: [number, number, number]): Vec3 => [ + cx + pt[0] * s, + cy + pt[1] * s, + cz + pt[2] * s, + ]; + + function scaleTP(from: number, to: number): Vec3 { + return scaleVec(truncPt(from, to)); + } + + // ── Helper: check and fix winding ───────────────────────────────────────── + function fixWinding(verts: Vec3[], outward: [number, number, number]): Vec3[] { + const [ox, oy, oz] = outward; + const p0 = verts[0], p1 = verts[1], p2 = verts[2]; + const e0x = p1[0] - p0[0], e0y = p1[1] - p0[1], e0z = p1[2] - p0[2]; + const e1x = p2[0] - p0[0], e1y = p2[1] - p0[1], e1z = p2[2] - p0[2]; + const nx = e0y * e1z - e0z * e1y; + const ny = e0z * e1x - e0x * e1z; + const nz = e0x * e1y - e0y * e1x; + if (nx * ox + ny * oy + nz * oz < 0) return [...verts].reverse(); + return verts; + } + + // ── 8 triangular corner faces ────────────────────────────────────────────── + // Cube vertex adjacencies (each vertex connects to 3 others via edges): + const cubeAdj: [number, number, number][] = [ + [1, 3, 4], // 0 --- → +-- , -+- , --+ + [0, 2, 5], // 1 +-- → --- , ++- , +-+ + [1, 3, 6], // 2 ++- → +-- , -+- , +++ + [0, 2, 7], // 3 -+- → --- , ++- , -++ + [0, 5, 7], // 4 --+ → --- , +-+ , -++ + [1, 4, 6], // 5 +-+ → +-- , --+ , +++ + [2, 5, 7], // 6 +++ → ++- , +-+ , -++ + [3, 4, 6], // 7 -++ → -+- , --+ , +++ + ]; + + const triangles: Polygon[] = cubeRaw.map(([ox, oy, oz], i) => { + const [j, k, l] = cubeAdj[i]; + const verts: Vec3[] = [scaleTP(i, j), scaleTP(i, k), scaleTP(i, l)]; + return { vertices: fixWinding(verts, [ox, oy, oz]), color }; + }); + + // ── 6 octagonal faces ────────────────────────────────────────────────────── + // Each cube face becomes an octagon — 8 vertices interleaving two truncation + // points per original edge. + // Original cube faces (CCW from outside): + // +Z: 4,5,6,7 (normal +z) + // -Z: 1,0,3,2 (normal -z) + // +X: 5,1,2,6 (normal +x) + // -X: 0,4,7,3 (normal -x) + // +Y: 7,6,2,3 (normal +y) + // -Y: 0,1,5,4 (normal -y) + const cubeFaces: { corners: [number, number, number, number]; normal: [number, number, number] }[] = [ + { corners: [4, 5, 6, 7], normal: [ 0, 0, 1] }, + { corners: [1, 0, 3, 2], normal: [ 0, 0, -1] }, + { corners: [5, 1, 2, 6], normal: [ 1, 0, 0] }, + { corners: [0, 4, 7, 3], normal: [-1, 0, 0] }, + { corners: [7, 6, 2, 3], normal: [ 0, 1, 0] }, + { corners: [0, 1, 5, 4], normal: [ 0, -1, 0] }, + ]; + + const octagons: Polygon[] = cubeFaces.map(({ corners, normal }) => { + const [a, b, c, d] = corners; + // For each consecutive edge pair in the face ring, insert two truncation points: + // the one from the leading vertex toward the trailing, then from the trailing toward the leading. + const verts: Vec3[] = [ + scaleTP(a, b), scaleTP(b, a), + scaleTP(b, c), scaleTP(c, b), + scaleTP(c, d), scaleTP(d, c), + scaleTP(d, a), scaleTP(a, d), + ]; + return { vertices: fixWinding(verts, normal), color }; + }); + + return [...triangles, ...octagons]; +} diff --git a/packages/core/src/helpers/truncatedCuboctahedronPolygons.ts b/packages/core/src/helpers/truncatedCuboctahedronPolygons.ts new file mode 100644 index 00000000..a8cb261d --- /dev/null +++ b/packages/core/src/helpers/truncatedCuboctahedronPolygons.ts @@ -0,0 +1,87 @@ +/** + * Geometry for a truncated cuboctahedron (great rhombicuboctahedron) — + * 12 square faces + 8 hexagonal faces + 6 octagonal faces (26 faces total, + * 48 vertices). Vertices are all distinct ordered permutations of + * (±1, ±(1+√2), ±(1+2√2)) — 3! orderings × 2³ sign combinations = 48 vertices. + * Scaled so the circumradius equals `size`. + * + * Face decomposition: + * 12 squares — one per edge of the parent cuboctahedron. + * 8 hexagons — one per triangular face of the parent cuboctahedron. + * 6 octagons — one per square face of the parent cuboctahedron. + * + * Faces discovered via edge-graph enumeration (all planar, outward-facing + * cycles of length 4, 6, 8). Each face is CCW-from-outside. + */ +import type { Polygon, Vec3 } from "../types"; +import { buildAdjList, findFacesOfLength, sortCCW, faceNormal } from "./_facesFromEdgeGraph"; + +export interface TruncatedCuboctahedronPolygonsOptions { + /** Center of the truncated cuboctahedron in world space. */ + center: Vec3; + /** Circumradius — distance from center to each vertex. */ + size: number; + /** Fill color applied to all twenty-six faces. */ + color?: string; +} + +export function truncatedCuboctahedronPolygons(options: TruncatedCuboctahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + // The 3 distinct coordinate values. + const a = 1; + const b = 1 + Math.sqrt(2); + const c = 1 + 2 * Math.sqrt(2); + + // Generate all 48 vertices: all 6 orderings of (a, b, c) × all 8 sign combos. + const perms: [number, number, number][] = [ + [a, b, c], [a, c, b], + [b, a, c], [b, c, a], + [c, a, b], [c, b, a], + ]; + const rawAll: [number, number, number][] = []; + for (const [px, py, pz] of perms) { + for (const sx of [-1, 1]) { + for (const sy of [-1, 1]) { + for (const sz of [-1, 1]) { + rawAll.push([sx * px, sy * py, sz * pz]); + } + } + } + } + // Deduplicate with rounded keys (the 6 orderings are all distinct, but be safe). + const seen = new Set(); + const raw: [number, number, number][] = []; + for (const pt of rawAll) { + const key = `${pt[0].toFixed(9)},${pt[1].toFixed(9)},${pt[2].toFixed(9)}`; + if (!seen.has(key)) { seen.add(key); raw.push(pt); } + } + + // Raw circumradius and scale factor. + const [rx, ry, rz] = raw[0]; + const rawCircumradius = Math.sqrt(rx * rx + ry * ry + rz * rz); + const s = size / rawCircumradius; + + const v: Vec3[] = raw.map(([x, y, z]) => [cx + x * s, cy + y * s, cz + z * s]); + + // Build the edge adjacency list (edge length in raw coords = 2). + const { adj } = buildAdjList(raw); + + // Discover all planar outward-facing cycles of each expected face length. + const squares = findFacesOfLength(raw, adj, 4); // 12 squares + const hexagons = findFacesOfLength(raw, adj, 6); // 8 hexagons + const octagons = findFacesOfLength(raw, adj, 8); // 6 octagons + + function toPolygon(indices: number[]): Polygon { + const normal = faceNormal(raw, indices); + const sorted = sortCCW(raw, indices, normal); + return { vertices: sorted.map((i) => v[i]), color }; + } + + return [ + ...squares.map(toPolygon), + ...hexagons.map(toPolygon), + ...octagons.map(toPolygon), + ]; +} diff --git a/packages/core/src/helpers/truncatedDodecahedronPolygons.ts b/packages/core/src/helpers/truncatedDodecahedronPolygons.ts new file mode 100644 index 00000000..89d7431b --- /dev/null +++ b/packages/core/src/helpers/truncatedDodecahedronPolygons.ts @@ -0,0 +1,222 @@ +/** + * Geometry for a truncated dodecahedron — 20 triangular faces + 12 decagonal + * faces (32 faces total, 60 vertices). Constructed by truncating each vertex of + * a regular dodecahedron at parameter t = (3 − √5)/4 ≈ 0.191, the fraction along + * each incident edge that produces regular decagons on the original pentagonal faces. + * Scaled so the circumradius equals `size`. + * + * Face decomposition: + * 20 triangles — one per truncated dodecahedron vertex (corner caps). + * 12 decagons — one per original dodecahedron face (expanded pentagon → decagon). + * + * Each face is CCW-from-outside. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface TruncatedDodecahedronPolygonsOptions { + /** Center of the truncated dodecahedron in world space. */ + center: Vec3; + /** Circumradius — distance from center to each vertex. */ + size: number; + /** Fill color applied to all thirty-two faces. */ + color?: string; +} + +export function truncatedDodecahedronPolygons(options: TruncatedDodecahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + // Dodecahedron base vertices (raw, unscaled) — same as dodecahedronPolygons.ts. + const phi = (1 + Math.sqrt(5)) / 2; + const invPhi = 1 / phi; + + const dodRaw: [number, number, number][] = [ + [-1, -1, -1], // 0 + [-1, -1, 1], // 1 + [-1, 1, -1], // 2 + [-1, 1, 1], // 3 + [ 1, -1, -1], // 4 + [ 1, -1, 1], // 5 + [ 1, 1, -1], // 6 + [ 1, 1, 1], // 7 + [ 0, -phi, -invPhi], // 8 + [ 0, -phi, invPhi], // 9 + [ 0, phi, -invPhi], // 10 + [ 0, phi, invPhi], // 11 + [-invPhi, 0, -phi], // 12 + [ invPhi, 0, -phi], // 13 + [-invPhi, 0, phi], // 14 + [ invPhi, 0, phi], // 15 + [-phi, -invPhi, 0], // 16 + [-phi, invPhi, 0], // 17 + [ phi, -invPhi, 0], // 18 + [ phi, invPhi, 0], // 19 + ]; + + // Dodecahedron face table (12 pentagons, CCW from outside). + const dodFaces: [number, number, number, number, number][] = [ + [ 0, 8, 9, 1, 16], + [ 0, 12, 13, 4, 8], + [ 0, 16, 17, 2, 12], + [ 1, 9, 5, 15, 14], + [ 1, 14, 3, 17, 16], + [ 2, 10, 6, 13, 12], + [ 2, 17, 3, 11, 10], + [ 3, 14, 15, 7, 11], + [ 4, 13, 6, 19, 18], + [ 4, 18, 5, 9, 8], + [ 5, 18, 19, 7, 15], + [ 6, 10, 11, 7, 19], + ]; + + // Build the deduplicated edge list from the face table. + const edgeSet = new Set(); + const edges: [number, number][] = []; + + for (const face of dodFaces) { + for (let i = 0; i < 5; i++) { + const a = face[i]; + const b = face[(i + 1) % 5]; + const key = a < b ? `${a},${b}` : `${b},${a}`; + if (!edgeSet.has(key)) { + edgeSet.add(key); + edges.push([a, b]); + } + } + } + // 30 edges for a dodecahedron. + + // Truncation parameter: t = (3 − √5)/4 ≈ 0.191. + // At this value each resulting decagon is a regular decagon. + const t = (3 - Math.sqrt(5)) / 4; + + // For each directed edge (from → to), emit the truncation point at distance t + // from `from`. p_ab = (1-t)*a + t*b (point on edge from a, at fraction t toward b). + const truncMap = new Map(); + + function truncPt(from: number, to: number): [number, number, number] { + const key = `${from},${to}`; + if (truncMap.has(key)) return truncMap.get(key)!; + const [ax, ay, az] = dodRaw[from]; + const [bx, by, bz] = dodRaw[to]; + const pt: [number, number, number] = [ + (1 - t) * ax + t * bx, + (1 - t) * ay + t * by, + (1 - t) * az + t * bz, + ]; + truncMap.set(key, pt); + return pt; + } + + // Register all 60 truncation points. + for (const [a, b] of edges) { + truncPt(a, b); + truncPt(b, a); + } + + // Compute raw circumradius and scale factor. + let rawCircumradius = 0; + for (const pt of truncMap.values()) { + const [x, y, z] = pt; + const d = Math.sqrt(x * x + y * y + z * z); + if (d > rawCircumradius) rawCircumradius = d; + } + const s = size / rawCircumradius; + + // Build the adjacency list for the dodecahedron vertex graph (3 edges per vertex). + const dodAdj: number[][] = Array.from({ length: 20 }, () => []); + for (const [a, b] of edges) { + dodAdj[a].push(b); + dodAdj[b].push(a); + } + + function scaleTP(from: number, to: number): Vec3 { + const [x, y, z] = truncPt(from, to); + return [cx + x * s, cy + y * s, cz + z * s]; + } + + // ── Helper: check and fix winding ────────────────────────────────────────── + // Ensures the cross-product of the first two edges points in the given outward direction. + function fixWinding(verts: Vec3[], outward: [number, number, number]): Vec3[] { + const [ox, oy, oz] = outward; + const p0 = verts[0], p1 = verts[1], p2 = verts[2]; + const e0x = p1[0] - p0[0], e0y = p1[1] - p0[1], e0z = p1[2] - p0[2]; + const e1x = p2[0] - p0[0], e1y = p2[1] - p0[1], e1z = p2[2] - p0[2]; + const nx = e0y * e1z - e0z * e1y; + const ny = e0z * e1x - e0x * e1z; + const nz = e0x * e1y - e0y * e1x; + if (nx * ox + ny * oy + nz * oz < 0) return [...verts].reverse(); + return verts; + } + + // ── Helper: sort a list of raw points CCW around their centroid ─────────── + function sortCCWRaw(pts: [number, number, number][], normal: [number, number, number]): [number, number, number][] { + let gcx = 0, gcy = 0, gcz = 0; + for (const [x, y, z] of pts) { gcx += x; gcy += y; gcz += z; } + const n = pts.length; + gcx /= n; gcy /= n; gcz /= n; + + const [nx, ny, nz] = normal; + const [p0x, p0y, p0z] = pts[0]; + let e0x = p0x - gcx, e0y = p0y - gcy, e0z = p0z - gcz; + const dot0 = e0x * nx + e0y * ny + e0z * nz; + e0x -= dot0 * nx; e0y -= dot0 * ny; e0z -= dot0 * nz; + const len0 = Math.sqrt(e0x * e0x + e0y * e0y + e0z * e0z); + e0x /= len0; e0y /= len0; e0z /= len0; + const e1x = ny * e0z - nz * e0y; + const e1y = nz * e0x - nx * e0z; + const e1z = nx * e0y - ny * e0x; + + const indexed = pts.map((pt) => { + const dx = pt[0] - gcx, dy = pt[1] - gcy, dz = pt[2] - gcz; + const u = dx * e0x + dy * e0y + dz * e0z; + const w = dx * e1x + dy * e1y + dz * e1z; + return { pt, angle: Math.atan2(w, u) }; + }); + indexed.sort((a, b) => a.angle - b.angle); + return indexed.map((a) => a.pt); + } + + // ── 20 triangular faces ──────────────────────────────────────────────────── + // One triangle per dodecahedron vertex. Each dodecahedron vertex has degree 3; + // its triangle connects the 3 truncation points on its incident edges. + const triangles: Polygon[] = dodRaw.map(([ox, oy, oz], i) => { + const [j, k, l] = dodAdj[i]; + const pts = [truncPt(i, j), truncPt(i, k), truncPt(i, l)]; + // Sort CCW around the outward direction (= the raw vertex itself as a direction). + const sortedPts = sortCCWRaw(pts, [ox, oy, oz]); + const verts: Vec3[] = sortedPts.map(([x, y, z]) => [cx + x * s, cy + y * s, cz + z * s]); + return { vertices: fixWinding(verts, [ox, oy, oz]), color }; + }); + + // ── 12 decagonal faces ───────────────────────────────────────────────────── + // One decagon per original dodecahedron face. For each pentagonal face + // (a0 → a1 → a2 → a3 → a4 → a0), the decagon visits: + // p(a0,a1), p(a1,a0), p(a1,a2), p(a2,a1), p(a2,a3), p(a3,a2), + // p(a3,a4), p(a4,a3), p(a4,a0), p(a0,a4) + // These 10 points lie in the original face plane — outward normal = centroid + // direction of the original face vertices. + const decagons: Polygon[] = dodFaces.map((face) => { + const [a0, a1, a2, a3, a4] = face; + const pts: [number, number, number][] = [ + truncPt(a0, a1), truncPt(a1, a0), + truncPt(a1, a2), truncPt(a2, a1), + truncPt(a2, a3), truncPt(a3, a2), + truncPt(a3, a4), truncPt(a4, a3), + truncPt(a4, a0), truncPt(a0, a4), + ]; + // Outward normal = centroid of original face vertices (the dodecahedron is + // centered at origin, so centroid direction = outward normal). + const [d0, d1, d2, d3, d4] = face.map((vi) => dodRaw[vi]); + const nx = (d0[0] + d1[0] + d2[0] + d3[0] + d4[0]) / 5; + const ny = (d0[1] + d1[1] + d2[1] + d3[1] + d4[1]) / 5; + const nz = (d0[2] + d1[2] + d2[2] + d3[2] + d4[2]) / 5; + const normal: [number, number, number] = [nx, ny, nz]; + const sortedPts = sortCCWRaw(pts, normal); + const verts: Vec3[] = sortedPts.map(([x, y, z]) => [cx + x * s, cy + y * s, cz + z * s]); + return { vertices: fixWinding(verts, normal), color }; + }); + + // Triangles come first (20), then decagons (12) = 32 polygons total. + return [...triangles, ...decagons]; +} diff --git a/packages/core/src/helpers/truncatedIcosahedronPolygons.ts b/packages/core/src/helpers/truncatedIcosahedronPolygons.ts new file mode 100644 index 00000000..a22212d8 --- /dev/null +++ b/packages/core/src/helpers/truncatedIcosahedronPolygons.ts @@ -0,0 +1,203 @@ +/** + * Geometry for a truncated icosahedron (soccer ball / Buckminster fullerene) — + * 12 pentagonal faces + 20 hexagonal faces (32 faces total, 60 vertices). + * Constructed by truncating each vertex of a regular icosahedron at t = 1/3 + * (the unique rational truncation fraction for Archimedean solids — makes the + * hexagons regular). Scaled so the circumradius equals `size`. + * + * Face decomposition: + * 12 pentagons — one per icosahedron vertex (5 truncation points on incident edges). + * 20 hexagons — one per icosahedron face (6 truncation points around each triangle). + * + * Each face is CCW-from-outside. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface TruncatedIcosahedronPolygonsOptions { + /** Center of the truncated icosahedron in world space. */ + center: Vec3; + /** Circumradius — distance from center to each vertex. */ + size: number; + /** Fill color applied to all thirty-two faces. */ + color?: string; +} + +export function truncatedIcosahedronPolygons(options: TruncatedIcosahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + // Icosahedron base vertices (raw, unscaled) — same as icosahedronPolygons.ts. + const phi = (1 + Math.sqrt(5)) / 2; + + const icoRaw: [number, number, number][] = [ + [ 0, -1, -phi], // 0 + [ 0, -1, phi], // 1 + [ 0, 1, -phi], // 2 + [ 0, 1, phi], // 3 + [-1, -phi, 0], // 4 + [-1, phi, 0], // 5 + [ 1, -phi, 0], // 6 + [ 1, phi, 0], // 7 + [-phi, 0, -1], // 8 + [ phi, 0, -1], // 9 + [-phi, 0, 1], // 10 + [ phi, 0, 1], // 11 + ]; + + // Icosahedron face table (same as icosahedronPolygons.ts). + const icoFaces: [number, number, number][] = [ + [ 0, 2, 9], + [ 0, 4, 8], + [ 0, 6, 4], + [ 0, 8, 2], + [ 0, 9, 6], + [ 1, 3, 10], + [ 1, 4, 6], + [ 1, 6, 11], + [ 1, 10, 4], + [ 1, 11, 3], + [ 2, 5, 7], + [ 2, 7, 9], + [ 2, 8, 5], + [ 3, 5, 10], + [ 3, 7, 5], + [ 3, 11, 7], + [ 4, 10, 8], + [ 5, 8, 10], + [ 6, 9, 11], + [ 7, 11, 9], + ]; + + // Build the deduplicated edge list. + const edgeSet = new Set(); + const edges: [number, number][] = []; + + for (const [a, b, c] of icoFaces) { + for (const [u, v] of [[a, b], [b, c], [c, a]] as [number, number][]) { + const key = u < v ? `${u},${v}` : `${v},${u}`; + if (!edgeSet.has(key)) { + edgeSet.add(key); + edges.push([u, v]); + } + } + } + // 30 edges for an icosahedron. + + // Truncation parameter: t = 1/3 exactly — unique rational value giving regular hexagons. + const t = 1 / 3; + + // For each directed edge (from → to), emit the truncation point at fraction t from `from`. + const truncMap = new Map(); + + function truncPt(from: number, to: number): [number, number, number] { + const key = `${from},${to}`; + if (truncMap.has(key)) return truncMap.get(key)!; + const [ax, ay, az] = icoRaw[from]; + const [bx, by, bz] = icoRaw[to]; + const pt: [number, number, number] = [ + (1 - t) * ax + t * bx, + (1 - t) * ay + t * by, + (1 - t) * az + t * bz, + ]; + truncMap.set(key, pt); + return pt; + } + + // Register all 60 truncation points. + for (const [a, b] of edges) { + truncPt(a, b); + truncPt(b, a); + } + + // Compute raw circumradius and scale factor. + let rawCircumradius = 0; + for (const pt of truncMap.values()) { + const [x, y, z] = pt; + const d = Math.sqrt(x * x + y * y + z * z); + if (d > rawCircumradius) rawCircumradius = d; + } + const s = size / rawCircumradius; + + // Build the icosahedron vertex adjacency list (5 edges per vertex). + const icoAdj: number[][] = Array.from({ length: 12 }, () => []); + for (const [a, b] of edges) { + icoAdj[a].push(b); + icoAdj[b].push(a); + } + + // ── Helper: check and fix winding ────────────────────────────────────────── + function fixWinding(verts: Vec3[], outward: [number, number, number]): Vec3[] { + const [ox, oy, oz] = outward; + const p0 = verts[0], p1 = verts[1], p2 = verts[2]; + const e0x = p1[0] - p0[0], e0y = p1[1] - p0[1], e0z = p1[2] - p0[2]; + const e1x = p2[0] - p0[0], e1y = p2[1] - p0[1], e1z = p2[2] - p0[2]; + const nx = e0y * e1z - e0z * e1y; + const ny = e0z * e1x - e0x * e1z; + const nz = e0x * e1y - e0y * e1x; + if (nx * ox + ny * oy + nz * oz < 0) return [...verts].reverse(); + return verts; + } + + // ── Helper: sort a list of raw points CCW around their centroid ─────────── + function sortCCWRaw(pts: [number, number, number][], normal: [number, number, number]): [number, number, number][] { + let gcx = 0, gcy = 0, gcz = 0; + for (const [x, y, z] of pts) { gcx += x; gcy += y; gcz += z; } + const n = pts.length; + gcx /= n; gcy /= n; gcz /= n; + + const [nx, ny, nz] = normal; + const [p0x, p0y, p0z] = pts[0]; + let e0x = p0x - gcx, e0y = p0y - gcy, e0z = p0z - gcz; + const dot0 = e0x * nx + e0y * ny + e0z * nz; + e0x -= dot0 * nx; e0y -= dot0 * ny; e0z -= dot0 * nz; + const len0 = Math.sqrt(e0x * e0x + e0y * e0y + e0z * e0z); + e0x /= len0; e0y /= len0; e0z /= len0; + const e1x = ny * e0z - nz * e0y; + const e1y = nz * e0x - nx * e0z; + const e1z = nx * e0y - ny * e0x; + + const indexed = pts.map((pt) => { + const dx = pt[0] - gcx, dy = pt[1] - gcy, dz = pt[2] - gcz; + const u = dx * e0x + dy * e0y + dz * e0z; + const w = dx * e1x + dy * e1y + dz * e1z; + return { pt, angle: Math.atan2(w, u) }; + }); + indexed.sort((a, b) => a.angle - b.angle); + return indexed.map((a) => a.pt); + } + + // ── 12 pentagonal faces ──────────────────────────────────────────────────── + // One pentagon per icosahedron vertex. Each vertex has 5 incident edges; + // the pentagon visits the 5 truncation points closest to this vertex. + const pentagons: Polygon[] = icoRaw.map(([ox, oy, oz], vi) => { + const pts: [number, number, number][] = icoAdj[vi].map((nb) => truncPt(vi, nb)); + const sortedPts = sortCCWRaw(pts, [ox, oy, oz]); + const verts: Vec3[] = sortedPts.map(([x, y, z]) => [cx + x * s, cy + y * s, cz + z * s]); + return { vertices: fixWinding(verts, [ox, oy, oz]), color }; + }); + + // ── 20 hexagonal faces ───────────────────────────────────────────────────── + // One hexagon per icosahedron face. For each triangular face (a → b → c → a), + // the hexagon visits the 6 truncation points around the triangle: + // p(a,b), p(b,a), p(b,c), p(c,b), p(c,a), p(a,c) + const hexagons: Polygon[] = icoFaces.map(([a, b, c]) => { + const pts: [number, number, number][] = [ + truncPt(a, b), truncPt(b, a), + truncPt(b, c), truncPt(c, b), + truncPt(c, a), truncPt(a, c), + ]; + // Outward normal = centroid of original triangle face vertices. + const d0 = icoRaw[a], d1 = icoRaw[b], d2 = icoRaw[c]; + const normal: [number, number, number] = [ + (d0[0] + d1[0] + d2[0]) / 3, + (d0[1] + d1[1] + d2[1]) / 3, + (d0[2] + d1[2] + d2[2]) / 3, + ]; + const sortedPts = sortCCWRaw(pts, normal); + const verts: Vec3[] = sortedPts.map(([x, y, z]) => [cx + x * s, cy + y * s, cz + z * s]); + return { vertices: fixWinding(verts, normal), color }; + }); + + // Pentagons first (12), then hexagons (20) = 32 polygons total. + return [...pentagons, ...hexagons]; +} diff --git a/packages/core/src/helpers/truncatedIcosidodecahedronPolygons.ts b/packages/core/src/helpers/truncatedIcosidodecahedronPolygons.ts new file mode 100644 index 00000000..fd328c0d --- /dev/null +++ b/packages/core/src/helpers/truncatedIcosidodecahedronPolygons.ts @@ -0,0 +1,103 @@ +/** + * Geometry for a truncated icosidodecahedron (great rhombicosidodecahedron) — + * 30 square faces + 20 hexagonal faces + 12 decagonal faces (62 faces total, + * 120 vertices). Vertices are all even-permutation (cyclic) sign combinations + * of five base triples involving the golden ratio φ = (1+√5)/2. + * Scaled so the circumradius equals `size`. + * + * Vertex construction: 5 base triples × 3 cyclic permutations × 8 sign + * combinations = 120 vertices. All lie on a common sphere. + * + * Face decomposition: + * 30 squares — one per edge of the parent icosidodecahedron. + * 20 hexagons — one per triangular face of the parent icosidodecahedron. + * 12 decagons — one per pentagonal face of the parent icosidodecahedron. + * + * Faces discovered via edge-graph enumeration (planar, outward-facing cycles of + * length 4, 6, 10). Each face is CCW-from-outside. + */ +import type { Polygon, Vec3 } from "../types"; +import { buildAdjList, findFacesOfLength, sortCCW, faceNormal } from "./_facesFromEdgeGraph"; + +export interface TruncatedIcosidodecahedronPolygonsOptions { + /** Center of the truncated icosidodecahedron in world space. */ + center: Vec3; + /** Circumradius — distance from center to each vertex. */ + size: number; + /** Fill color applied to all sixty-two faces. */ + color?: string; +} + +export function truncatedIcosidodecahedronPolygons(options: TruncatedIcosidodecahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + const phi = (1 + Math.sqrt(5)) / 2; + + // Five base triples whose cyclic permutations + sign combinations give 120 vertices. + // Each base triple (x, y, z) generates (x,y,z), (y,z,x), (z,x,y) × 8 sign combos. + const baseTriples: [number, number, number][] = [ + [1 / phi, 1 / phi, 3 + phi ], + [2 / phi, phi, 1 + 2 * phi ], + [1 / phi, phi * phi, -1 + 3 * phi ], + [2 * phi - 1, 2, 2 + phi ], + [phi, 3, 2 * phi ], + ]; + + // Generate all 120 vertices. + const rawAll: [number, number, number][] = []; + for (const [bx, by, bz] of baseTriples) { + // 3 cyclic permutations. + const cyclics: [number, number, number][] = [ + [bx, by, bz], + [by, bz, bx], + [bz, bx, by], + ]; + for (const [px, py, pz] of cyclics) { + for (const sx of [-1, 1]) { + for (const sy of [-1, 1]) { + for (const sz of [-1, 1]) { + rawAll.push([sx * px, sy * py, sz * pz]); + } + } + } + } + } + + // Deduplicate with rounded keys. + const seen = new Set(); + const raw: [number, number, number][] = []; + for (const pt of rawAll) { + const key = `${pt[0].toFixed(8)},${pt[1].toFixed(8)},${pt[2].toFixed(8)}`; + if (!seen.has(key)) { seen.add(key); raw.push(pt); } + } + + // Verify we have 120 vertices. + // Raw circumradius: all vertices should be equidistant from origin. + const [rx, ry, rz] = raw[0]; + const rawCircumradius = Math.sqrt(rx * rx + ry * ry + rz * rz); + const s = size / rawCircumradius; + + const v: Vec3[] = raw.map(([x, y, z]) => [cx + x * s, cy + y * s, cz + z * s]); + + // Build the edge adjacency list. + const { adj } = buildAdjList(raw); + + // Discover all planar outward-facing cycles of each expected face length. + // Each vertex is 3-valent, so DFS over 10-cycles is tractable (≤ 3^9 paths per start). + const squares = findFacesOfLength(raw, adj, 4); // 30 squares + const hexagons = findFacesOfLength(raw, adj, 6); // 20 hexagons + const decagons = findFacesOfLength(raw, adj, 10); // 12 decagons + + function toPolygon(indices: number[]): Polygon { + const normal = faceNormal(raw, indices); + const sorted = sortCCW(raw, indices, normal); + return { vertices: sorted.map((i) => v[i]), color }; + } + + return [ + ...squares.map(toPolygon), + ...hexagons.map(toPolygon), + ...decagons.map(toPolygon), + ]; +} diff --git a/packages/core/src/helpers/truncatedOctahedronPolygons.ts b/packages/core/src/helpers/truncatedOctahedronPolygons.ts new file mode 100644 index 00000000..3f0be7fb --- /dev/null +++ b/packages/core/src/helpers/truncatedOctahedronPolygons.ts @@ -0,0 +1,153 @@ +/** + * Geometry for a truncated octahedron (permutohedron) — 6 square faces + 8 hexagonal + * faces (14 faces total, 24 vertices). Vertices are all permutations of (0, ±1, ±2). + * Scaled so the circumradius equals `size`. + * + * Vertex ordering (24 total): + * All ordered triples using one 0, one ±1, one ±2 (3! × 4 = 24 vertices). + * + * Face decomposition: + * 6 squares — one per axis direction ±x/±y/±z, lying in the plane where one + * coordinate equals ±2. + * 8 hexagons — one per octant normal (±1,±1,±1); vertices satisfy x+y+z = ±3 + * (for all-positive/all-negative octants) or mixed-sign combinations. + * + * Each face is CCW-from-outside. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface TruncatedOctahedronPolygonsOptions { + /** Center of the truncated octahedron in world space. */ + center: Vec3; + /** Circumradius — distance from center to each vertex. */ + size: number; + /** Fill color applied to all fourteen faces. */ + color?: string; +} + +export function truncatedOctahedronPolygons(options: TruncatedOctahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + // Raw circumradius: √(0² + 1² + 2²) = √5. + const s = size / Math.sqrt(5); + + // Generate all 24 vertices: each is a permutation of (0, ±1, ±2). + // One coordinate is 0 (3 choices of axis), one is ±1 (2 signs), one is ±2 (2 signs), + // and the ±1 and ±2 values can be assigned to either of the two non-zero axes (2 ways). + // 3 × 2 × 2 × 2 = 24 total. + const seen = new Set(); + const uniqueRaw: [number, number, number][] = []; + + for (let zeroAxis = 0; zeroAxis < 3; zeroAxis++) { + const other1 = (zeroAxis + 1) % 3; + const other2 = (zeroAxis + 2) % 3; + for (const s1 of [-1, 1]) { + for (const s2 of [-1, 1]) { + for (const swap of [false, true]) { + const pt: [number, number, number] = [0, 0, 0]; + // zeroAxis coordinate stays 0. + pt[other1] = swap ? s1 * 2 : s1 * 1; + pt[other2] = swap ? s2 * 1 : s2 * 2; + const key = `${pt[0]},${pt[1]},${pt[2]}`; + if (!seen.has(key)) { seen.add(key); uniqueRaw.push(pt); } + } + } + } + } + + const v: Vec3[] = uniqueRaw.map(([x, y, z]) => [cx + x * s, cy + y * s, cz + z * s]); + + // Index lookup: raw coord → vertex index. + const vertIdx = new Map(); + for (let i = 0; i < uniqueRaw.length; i++) { + const [x, y, z] = uniqueRaw[i]; + vertIdx.set(`${x},${y},${z}`, i); + } + + function idx(x: number, y: number, z: number): number { + const key = `${x},${y},${z}`; + const i = vertIdx.get(key); + if (i === undefined) throw new Error(`Vertex not found: (${x},${y},${z})`); + return i; + } + + // ── Helper: sort vertices CCW around a face normal ───────────────────────── + function sortCCW(indices: number[], normal: [number, number, number]): number[] { + let gcx = 0, gcy = 0, gcz = 0; + for (const i of indices) { + gcx += uniqueRaw[i][0]; gcy += uniqueRaw[i][1]; gcz += uniqueRaw[i][2]; + } + const n = indices.length; + gcx /= n; gcy /= n; gcz /= n; + + const [nx, ny, nz] = normal; + const [p0x, p0y, p0z] = uniqueRaw[indices[0]]; + let e0x = p0x - gcx, e0y = p0y - gcy, e0z = p0z - gcz; + const dot0 = e0x * nx + e0y * ny + e0z * nz; + e0x -= dot0 * nx; e0y -= dot0 * ny; e0z -= dot0 * nz; + const len0 = Math.sqrt(e0x * e0x + e0y * e0y + e0z * e0z); + e0x /= len0; e0y /= len0; e0z /= len0; + const e1x = ny * e0z - nz * e0y; + const e1y = nz * e0x - nx * e0z; + const e1z = nx * e0y - ny * e0x; + + const angles = indices.map((i) => { + const [px, py, pz] = uniqueRaw[i]; + const dx = px - gcx, dy = py - gcy, dz = pz - gcz; + const u = dx * e0x + dy * e0y + dz * e0z; + const w = dx * e1x + dy * e1y + dz * e1z; + return { i, angle: Math.atan2(w, u) }; + }); + angles.sort((a, b) => a.angle - b.angle); + return angles.map((a) => a.i); + } + + // ── 6 square faces ───────────────────────────────────────────────────────── + // Each square lies in a plane where one coordinate equals ±2. + // The 4 vertices in the x=+2 plane: (2,0,±1),(2,±1,0) → (2,0,1),(2,1,0),(2,0,-1),(2,-1,0). + const squareFaceSpecs: { indices: number[]; normal: [number, number, number] }[] = [ + { indices: [idx(2,0,1), idx(2,1,0), idx(2,0,-1), idx(2,-1,0)], normal: [1,0,0] }, + { indices: [idx(-2,0,1), idx(-2,1,0), idx(-2,0,-1), idx(-2,-1,0)], normal: [-1,0,0] }, + { indices: [idx(0,2,1), idx(1,2,0), idx(0,2,-1), idx(-1,2,0)], normal: [0,1,0] }, + { indices: [idx(0,-2,1), idx(1,-2,0), idx(0,-2,-1), idx(-1,-2,0)], normal: [0,-1,0] }, + { indices: [idx(1,0,2), idx(0,1,2), idx(-1,0,2), idx(0,-1,2)], normal: [0,0,1] }, + { indices: [idx(1,0,-2), idx(0,1,-2), idx(-1,0,-2), idx(0,-1,-2)], normal: [0,0,-1] }, + ]; + + const squares: Polygon[] = squareFaceSpecs.map(({ indices, normal }) => ({ + vertices: sortCCW(indices, normal).map((i) => v[i]), + color, + })); + + // ── 8 hexagonal faces ────────────────────────────────────────────────────── + // Each hexagon lies on a plane with normal (±1,±1,±1)/√3. + // For normal (1,1,1), the plane equation is x+y+z = constant. + // We need x+y+z = 3 (max value achievable: 2+1+0=3, 2+0+1=3, 0+2+1=3, etc.). + // Vertices with x+y+z=3: (2,1,0),(2,0,1),(1,2,0),(0,2,1),(1,0,2),(0,1,2) — 6 vertices. + // Similarly for the other 7 octant normals. + const hexNormals: [number, number, number][] = [ + [ 1, 1, 1], + [ 1, 1, -1], + [ 1, -1, 1], + [ 1, -1, -1], + [-1, 1, 1], + [-1, 1, -1], + [-1, -1, 1], + [-1, -1, -1], + ]; + + // For a normal (sx,sy,sz), the hexagon plane sum is sx*x + sy*y + sz*z = 3. + const hexagons: Polygon[] = hexNormals.map(([sx, sy, sz]) => { + // Find the 6 vertices satisfying sx*x + sy*y + sz*z = 3 (all have absolute value |x|+|y|+|z|=3). + const indices: number[] = []; + for (let i = 0; i < uniqueRaw.length; i++) { + const [x, y, z] = uniqueRaw[i]; + if (Math.abs(sx * x + sy * y + sz * z - 3) < 1e-9) indices.push(i); + } + const normal: [number, number, number] = [sx / Math.sqrt(3), sy / Math.sqrt(3), sz / Math.sqrt(3)]; + return { vertices: sortCCW(indices, normal).map((i) => v[i]), color }; + }); + + return [...squares, ...hexagons]; +} diff --git a/packages/core/src/helpers/truncatedTetrahedronPolygons.ts b/packages/core/src/helpers/truncatedTetrahedronPolygons.ts new file mode 100644 index 00000000..46338d5f --- /dev/null +++ b/packages/core/src/helpers/truncatedTetrahedronPolygons.ts @@ -0,0 +1,156 @@ +/** + * Geometry for a truncated tetrahedron — 4 triangular faces + 4 hexagonal faces + * (8 faces total, 12 vertices). Constructed by cutting each corner of a regular + * tetrahedron at 1/3 of the edge length from the vertex. + * Scaled so the circumradius equals `size`. + * + * Vertex ordering (12 total): + * For each of the 4 tetrahedron vertices v[i], 3 truncation points are produced + * at p_ij = (2/3)v[i] + (1/3)v[j] for each of the 3 incident edges. + * + * Face decomposition: + * 4 triangles — one per original tetrahedron vertex (corner caps). + * 4 hexagons — one per original tetrahedron face (expanded from triangle to hexagon). + * + * Each face is CCW-from-outside. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface TruncatedTetrahedronPolygonsOptions { + /** Center of the truncated tetrahedron in world space. */ + center: Vec3; + /** Circumradius — distance from center to each vertex. */ + size: number; + /** Fill color applied to all eight faces. */ + color?: string; +} + +export function truncatedTetrahedronPolygons(options: TruncatedTetrahedronPolygonsOptions): Polygon[] { + const { center, size, color = "#ffffff" } = options; + const [cx, cy, cz] = center; + + // Base tetrahedron vertices — alternating cube corners at (±1,±1,±1). + // Raw circumradius of this form is √3. + const icoScale = 1 / Math.sqrt(3); + const tetraRaw: [number, number, number][] = [ + [ 1, 1, 1], // 0 + [-1, -1, 1], // 1 + [-1, 1, -1], // 2 + [ 1, -1, -1], // 3 + ]; + + // Build 12 truncation points: for each vertex v[i] and each of its 3 neighbours v[j], + // p_ij = (2/3)v[i] + (1/3)v[j]. + // All 4 tetrahedron vertices are fully connected (complete graph K4), so every + // (i,j) pair where i≠j defines a truncation point from i toward j. + // We index them as: midIdx[i][j] = index of p_ij. + const midRaw: [number, number, number][][] = Array.from({ length: 4 }, () => new Array(4)); + const allMidRaw: [number, number, number][] = []; + + for (let i = 0; i < 4; i++) { + for (let j = 0; j < 4; j++) { + if (i === j) continue; + const [ax, ay, az] = tetraRaw[i]; + const [bx, by, bz] = tetraRaw[j]; + const pt: [number, number, number] = [ + (2 / 3) * ax + (1 / 3) * bx, + (2 / 3) * ay + (1 / 3) * by, + (2 / 3) * az + (1 / 3) * bz, + ]; + midRaw[i][j] = pt; + allMidRaw.push(pt); + } + } + + // Compute the actual circumradius of the truncation points and scale. + // The maximum distance from origin among all truncation points is the circumradius. + let rawCircumradius = 0; + for (const [x, y, z] of allMidRaw) { + const d = Math.sqrt(x * x + y * y + z * z); + if (d > rawCircumradius) rawCircumradius = d; + } + const s = size / rawCircumradius; + void icoScale; // tetraRaw coordinates are already correct ratios; icoScale not needed separately. + + // Build scaled vertex lookup: midIdx[i][j] → Vec3 in world space. + const mid = (i: number, j: number): Vec3 => { + const [x, y, z] = midRaw[i][j]; + return [cx + x * s, cy + y * s, cz + z * s]; + }; + + // ── 4 triangular corner faces ────────────────────────────────────────────── + // Corner at v[i] connects p_ij, p_ik, p_il (the 3 outgoing truncation points). + // The neighbours of v[i] are the other 3 vertices. + // Winding: CCW when viewed from v[i] direction (outward from center). + // Since v[i] is known, we need the outward-facing winding. + // We determine the correct winding by checking the cross-product sign. + function makeTriangle(i: number): Vec3[] { + const others = [0, 1, 2, 3].filter((x) => x !== i); + const [j, k, l] = others; + const a = mid(i, j); + const b = mid(i, k); + const c = mid(i, l); + // Outward normal should point away from the solid center (≈ in direction of tetraRaw[i]). + const [ox, oy, oz] = tetraRaw[i]; + // Compute cross product (b-a) × (c-a). + const abx = b[0] - a[0], aby = b[1] - a[1], abz = b[2] - a[2]; + const acx = c[0] - a[0], acy = c[1] - a[1], acz = c[2] - a[2]; + const nx = aby * acz - abz * acy; + const ny = abz * acx - abx * acz; + const nz = abx * acy - aby * acx; + // If cross product points inward (dot with outward direction < 0), reverse. + if (nx * ox + ny * oy + nz * oz < 0) return [a, c, b]; + return [a, b, c]; + } + + // ── 4 hexagonal faces ────────────────────────────────────────────────────── + // The hexagon at original face (a,b,c) uses 6 truncation points in order: + // p_ab, p_ba, p_bc, p_cb, p_ca, p_ac (going around the triangle edge-by-edge, + // taking the point closer to the leading vertex first, then closer to the trailing). + function makeHexagon(a: number, b: number, c: number): Vec3[] { + // Go around face a→b→c→a; for each edge take the point from the leading vertex, + // then the point from the trailing vertex. + const verts: Vec3[] = [ + mid(a, b), mid(b, a), + mid(b, c), mid(c, b), + mid(c, a), mid(a, c), + ]; + // Outward normal direction = centroid of the original face vertices (in raw coords). + const [ax, ay, az] = tetraRaw[a]; + const [bx, by, bz] = tetraRaw[b]; + const [ccx, ccy, ccz] = tetraRaw[c]; + const ox = (ax + bx + ccx) / 3; + const oy = (ay + by + ccy) / 3; + const oz = (az + bz + ccz) / 3; + // Check winding: cross of (verts[1]-verts[0]) × (verts[2]-verts[0]). + const p0 = verts[0], p1 = verts[1], p2 = verts[2]; + const e0x = p1[0] - p0[0], e0y = p1[1] - p0[1], e0z = p1[2] - p0[2]; + const e1x = p2[0] - p0[0], e1y = p2[1] - p0[1], e1z = p2[2] - p0[2]; + const nx = e0y * e1z - e0z * e1y; + const ny = e0z * e1x - e0x * e1z; + const nz = e0x * e1y - e0y * e1x; + if (nx * ox + ny * oy + nz * oz < 0) verts.reverse(); + return verts; + } + + const triangles: Polygon[] = [0, 1, 2, 3].map((i) => ({ + vertices: makeTriangle(i), + color, + })); + + // The 4 tetrahedron faces (CCW from outside, consistent with tetrahedronPolygons.ts): + // [0,2,1], [0,1,3], [0,3,2], [1,2,3] — but here we just need the 3 vertex indices. + const tetraFaces: [number, number, number][] = [ + [0, 2, 1], + [0, 1, 3], + [0, 3, 2], + [1, 2, 3], + ]; + + const hexagons: Polygon[] = tetraFaces.map(([a, b, c]) => ({ + vertices: makeHexagon(a, b, c), + color, + })); + + return [...triangles, ...hexagons]; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 05037e93..0230ec45 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,8 +9,8 @@ export type { Vec3, TextureTriangle, Polygon, - GlyphcssDirectionalLight, - GlyphcssAmbientLight, + GlyphDirectionalLight, + GlyphAmbientLight, MeshResolution, } from "./types"; export { DEFAULT_PROJECTION } from "./types"; @@ -112,21 +112,21 @@ export type { } from "./cull/cameraBackfaceCulling"; // ── Helper-gizmo geometry (axes, light marker, transform arrows / rings) ─ -export { axesHelperPolygons, arrowPolygons, ringPolygons, ringQuadPolygons, planePolygons, octahedronPolygons, tetrahedronPolygons, cubePolygons, dodecahedronPolygons, icosahedronPolygons } from "./helpers"; -export type { AxesHelperOptions, ArrowPolygonsOptions, RingPolygonsOptions, RingQuadPolygonsOptions, PlanePolygonsOptions, OctahedronPolygonsOptions, TetrahedronPolygonsOptions, CubePolygonsOptions, DodecahedronPolygonsOptions, IcosahedronPolygonsOptions } from "./helpers"; +export { axesHelperPolygons, arrowPolygons, ringPolygons, ringQuadPolygons, planePolygons, octahedronPolygons, tetrahedronPolygons, cubePolygons, dodecahedronPolygons, icosahedronPolygons, spherePolygons, cylinderPolygons, conePolygons, torusPolygons, pyramidPolygons, prismPolygons, antiprismPolygons, bipyramidPolygons, trapezohedronPolygons, smallStellatedDodecahedronPolygons, greatDodecahedronPolygons, greatStellatedDodecahedronPolygons, greatIcosahedronPolygons, cuboctahedronPolygons, icosidodecahedronPolygons, truncatedTetrahedronPolygons, truncatedCubePolygons, truncatedOctahedronPolygons, truncatedDodecahedronPolygons, truncatedIcosahedronPolygons, truncatedCuboctahedronPolygons, truncatedIcosidodecahedronPolygons, rhombicuboctahedronPolygons, rhombicosidodecahedronPolygons, snubCubePolygons, snubDodecahedronPolygons, rhombicDodecahedronPolygons, rhombicTriacontahedronPolygons, triakisTetrahedronPolygons, triakisOctahedronPolygons, tetrakisHexahedronPolygons, triakisIcosahedronPolygons, pentakisDodecahedronPolygons, disdyakisDodecahedronPolygons, disdyakisTriacontahedronPolygons, deltoidalIcositetrahedronPolygons, deltoidalHexecontahedronPolygons, pentagonalIcositetrahedronPolygons, pentagonalHexecontahedronPolygons, resolveGeometry } from "./helpers"; +export type { AxesHelperOptions, ArrowPolygonsOptions, RingPolygonsOptions, RingQuadPolygonsOptions, PlanePolygonsOptions, OctahedronPolygonsOptions, TetrahedronPolygonsOptions, CubePolygonsOptions, DodecahedronPolygonsOptions, IcosahedronPolygonsOptions, SpherePolygonsOptions, CylinderPolygonsOptions, ConePolygonsOptions, TorusPolygonsOptions, PyramidPolygonsOptions, PrismPolygonsOptions, AntiprismPolygonsOptions, BipyramidPolygonsOptions, TrapezohedronPolygonsOptions, SmallStellatedDodecahedronPolygonsOptions, GreatDodecahedronPolygonsOptions, GreatStellatedDodecahedronPolygonsOptions, GreatIcosahedronPolygonsOptions, CuboctahedronPolygonsOptions, IcosidodecahedronPolygonsOptions, TruncatedTetrahedronPolygonsOptions, TruncatedCubePolygonsOptions, TruncatedOctahedronPolygonsOptions, TruncatedDodecahedronPolygonsOptions, TruncatedIcosahedronPolygonsOptions, TruncatedCuboctahedronPolygonsOptions, TruncatedIcosidodecahedronPolygonsOptions, RhombicuboctahedronPolygonsOptions, RhombicosidodecahedronPolygonsOptions, SnubCubePolygonsOptions, SnubDodecahedronPolygonsOptions, RhombicDodecahedronPolygonsOptions, RhombicTriacontahedronPolygonsOptions, TriakisTetrahedronPolygonsOptions, TriakisOctahedronPolygonsOptions, TetrakisHexahedronPolygonsOptions, TriakisIcosahedronPolygonsOptions, PentakisDodecahedronPolygonsOptions, DisdyakisDodecahedronPolygonsOptions, DisdyakisTriacontahedronPolygonsOptions, DeltoidalIcositetrahedronPolygonsOptions, DeltoidalHexecontahedronPolygonsOptions, PentagonalIcositetrahedronPolygonsOptions, PentagonalHexecontahedronPolygonsOptions, GlyphGeometryName, GlyphGeometryOptions } from "./helpers"; // ── Animation ───────────────────────────────────────────────────── export { - createGlyphcssAnimationMixer, + createGlyphAnimationMixer, LoopOnce, LoopRepeat, LoopPingPong, } from "./animation"; export type { - GlyphcssAnimationClip, - GlyphcssAnimationAction, - GlyphcssAnimationMixer, - GlyphcssAnimationTarget, + GlyphAnimationClip, + GlyphAnimationAction, + GlyphAnimationMixer, + GlyphAnimationTarget, LoopMode, } from "./animation"; diff --git a/packages/core/src/scene/normalize.ts b/packages/core/src/scene/normalize.ts index c0903639..fd9036d5 100644 --- a/packages/core/src/scene/normalize.ts +++ b/packages/core/src/scene/normalize.ts @@ -45,9 +45,9 @@ function bboxDiagonal(verts: Vec3[]): number { * (NODE_ENV !== "production" or a runtime DEV flag on globalThis) as dev. */ function isDevMode(): boolean { - const g = globalThis as unknown as { __GLYPHCSS_DEV__?: boolean; process?: { env?: { NODE_ENV?: string } } }; - if (g.__GLYPHCSS_DEV__ === true) return true; - if (g.__GLYPHCSS_DEV__ === false) return false; + const g = globalThis as unknown as { __GLYPH_DEV__?: boolean; process?: { env?: { NODE_ENV?: string } } }; + if (g.__GLYPH_DEV__ === true) return true; + if (g.__GLYPH_DEV__ === false) return false; const env = g.process?.env?.NODE_ENV; if (typeof env === "string") return env !== "production"; return false; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index eaa49b23..e3f53ecf 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -41,7 +41,7 @@ export interface TextureTriangle { * scene-local coords and does not need to be pre-normalized. * Mirrors three.js's `DirectionalLight`. */ -export interface GlyphcssDirectionalLight { +export interface GlyphDirectionalLight { /** Direction the light shines TOWARD (typical convention). */ direction: Vec3; /** Light tint, hex string. White by default. */ @@ -56,7 +56,7 @@ export interface GlyphcssDirectionalLight { * directional contribution: the two add independently rather than * splitting a fixed energy budget. */ -export interface GlyphcssAmbientLight { +export interface GlyphAmbientLight { /** Tint, hex string. White by default. */ color?: string; /** Scalar multiplier on the ambient contribution. Default 0.4. */ diff --git a/packages/glyphcss/src/api/createGlyphcssCamera.ts b/packages/glyphcss/src/api/createGlyphCamera.ts similarity index 62% rename from packages/glyphcss/src/api/createGlyphcssCamera.ts rename to packages/glyphcss/src/api/createGlyphCamera.ts index c6fb3b87..7476b915 100644 --- a/packages/glyphcss/src/api/createGlyphcssCamera.ts +++ b/packages/glyphcss/src/api/createGlyphCamera.ts @@ -1,13 +1,16 @@ /** - * createGlyphcssPerspectiveCamera / createGlyphcssOrthographicCamera / - * createGlyphcssFirstPersonCamera — vanilla camera factories for glyphcss. + * createGlyphPerspectiveCamera / createGlyphOrthographicCamera — vanilla camera + * factories for glyphcss. * - * These mirror the asciss camera factories and provide a `GlyphcssCamera` + * These mirror the asciss camera factories and provide a `GlyphCamera` * handle with a `project()` method that maps world-space vertices to * [col, row, depth] in character-cell grid space. * - * Public names are Glyphcss-prefixed to mirror glyphcss's naming convention. + * Public names use the Glyph prefix per glyphcss naming convention. * The internal camera algorithms are byte-identical to asciss's createCamera.ts. + * + * `createGlyphCamera` is the ergonomic default alias — it creates an + * orthographic camera, matching the voxel/iso identity of glyphcss. */ import type { Vec3 } from "@glyphcss/core"; @@ -16,7 +19,7 @@ import type { Vec3 } from "@glyphcss/core"; * Rotate `v` to match glyphcss's world→screen transform for identical (rotY, rotX) values. * * This is asciss's rotateVec3 (radians, (rotY, rotX) parameter order) — NOT the - * glyphcss-core rotateVec3 which takes degrees and (rx, ry, rz). Kept internal + * glyph-core rotateVec3 which takes degrees and (rx, ry, rz). Kept internal * so the camera math stays byte-identical to asciss. */ function rotateVec3(v: Vec3, a: number, b: number): Vec3 { @@ -32,8 +35,8 @@ function rotateVec3(v: Vec3, a: number, b: number): Vec3 { return [x1, y2, z2]; } -export interface GlyphcssCamera { - readonly kind: "perspective" | "orthographic" | "firstPerson"; +export interface GlyphCamera { + readonly kind: "perspective" | "orthographic"; rotX: number; rotY: number; /** Distance from origin along the view axis. Only meaningful for perspective cameras. */ @@ -47,11 +50,18 @@ export interface GlyphcssCamera { * Subtracted from world coords before projection so the mesh appears to pan without re-baking. */ target: Vec3; + /** + * Eye-at-origin projection mode. When true, the perspective camera uses a + * first-person formulation: `target` is treated as the eye position and + * vertices behind the eye (`r[2] >= 0`) are NaN-culled. Toggled by + * `createGlyphFirstPersonControls` at attach / detach time. + */ + eyeMode: boolean; /** Project a world-space vector to `[col, row, depth]`. Same projection used by the renderer and the hit layer. */ project(v: Vec3, cols: number, rows: number, cellAspect: number): [number, number, number]; } -export interface GlyphcssPerspectiveCameraOptions { +export interface GlyphPerspectiveCameraOptions { /** Y rotation (radians). The "spin" axis. Default 0. */ rotY?: number; /** X rotation (radians). The "tilt" axis. Default 0. */ @@ -72,31 +82,19 @@ export interface GlyphcssPerspectiveCameraOptions { center?: [number, number]; } -export interface GlyphcssOrthographicCameraOptions { +export interface GlyphOrthographicCameraOptions { rotY?: number; rotX?: number; zoom?: number; center?: [number, number]; } -export interface GlyphcssFirstPersonCameraOptions { - rotY?: number; - rotX?: number; - /** Focal length in world units. Smaller = wider FOV. */ - focal?: number; - /** Eye position in world space. Used as the projection origin. */ - origin?: Vec3; - center?: [number, number]; -} +/** Handle alias — same surface as `GlyphCamera`, names matched to glyphcss. */ +export type GlyphPerspectiveCameraHandle = GlyphCamera; +/** Handle alias — same surface as `GlyphCamera`, names matched to glyphcss. */ +export type GlyphOrthographicCameraHandle = GlyphCamera; -/** Handle alias — same surface as `GlyphcssCamera`, names matched to glyphcss. */ -export type GlyphcssPerspectiveCameraHandle = GlyphcssCamera; -/** Handle alias — same surface as `GlyphcssCamera`, names matched to glyphcss. */ -export type GlyphcssOrthographicCameraHandle = GlyphcssCamera; -/** Handle alias — same surface as `GlyphcssCamera`, names matched to glyphcss. */ -export type GlyphcssFirstPersonCameraHandle = GlyphcssCamera; - -export function createGlyphcssPerspectiveCamera(opts: GlyphcssPerspectiveCameraOptions = {}): GlyphcssPerspectiveCameraHandle { +export function createGlyphPerspectiveCamera(opts: GlyphPerspectiveCameraOptions = {}): GlyphPerspectiveCameraHandle { const state = { rotX: opts.rotX ?? 0, rotY: opts.rotY ?? 0, @@ -104,6 +102,10 @@ export function createGlyphcssPerspectiveCamera(opts: GlyphcssPerspectiveCameraO zoom: opts.zoom ?? 0.4, stretch: opts.stretch ?? 1.0, target: [0, 0, 0] as Vec3, + eyeMode: false, + // Focal length used in eye mode. Tuned so the scene fills the viewport + // at a similar fraction as a standard perspective view from ~3 units back. + focal: 5, }; const [cxN, cyN] = opts.center ?? [0.5, 0.5]; @@ -121,9 +123,22 @@ export function createGlyphcssPerspectiveCamera(opts: GlyphcssPerspectiveCameraO set stretch(v: number) { state.stretch = v; }, get target(): Vec3 { return state.target; }, set target(v: Vec3) { state.target = v; }, + get eyeMode(): boolean { return state.eyeMode; }, + set eyeMode(v: boolean) { state.eyeMode = v; }, project(v, cols, rows, cellAspect) { const shifted: Vec3 = [v[0] - state.target[0], v[1] - state.target[1], v[2] - state.target[2]]; const r = rotateVec3(shifted, state.rotY, state.rotX); + if (state.eyeMode) { + // Eye-at-origin projection: target is the eye position, vertices at or + // behind the eye plane are culled. Used by GlyphFirstPersonControls. + const NEAR = 0.001; + if (r[2] >= -NEAR) return [NaN, NaN, r[2]]; + const inv = state.focal / -r[2]; + const radius = Math.min(cols, rows) * state.zoom * inv; + const col = cols * cxN + r[0] * radius * cellAspect * state.stretch; + const row = rows * cyN + r[1] * radius; + return [col, row, r[2]]; + } const MESH_UNIT = 30; const ZOOM_TO_RADIUS = 1.5; const zPx = r[2] * MESH_UNIT; @@ -139,7 +154,7 @@ export function createGlyphcssPerspectiveCamera(opts: GlyphcssPerspectiveCameraO }; } -export function createGlyphcssOrthographicCamera(opts: GlyphcssOrthographicCameraOptions = {}): GlyphcssOrthographicCameraHandle { +export function createGlyphOrthographicCamera(opts: GlyphOrthographicCameraOptions = {}): GlyphOrthographicCameraHandle { const state = { rotX: opts.rotX ?? 0, rotY: opts.rotY ?? 0, @@ -164,6 +179,10 @@ export function createGlyphcssOrthographicCamera(opts: GlyphcssOrthographicCamer set stretch(v: number) { state.stretch = v; }, get target(): Vec3 { return state.target; }, set target(v: Vec3) { state.target = v; }, + // Orthographic cameras never use eye-mode projection. The setter is a no-op + // so the field satisfies the GlyphCamera interface. + get eyeMode(): boolean { return false; }, + set eyeMode(_v: boolean) { /* no-op — orthographic projection has no eye mode */ }, project(v, cols, rows, cellAspect) { const shifted: Vec3 = [v[0] - state.target[0], v[1] - state.target[1], v[2] - state.target[2]]; const r = rotateVec3(shifted, state.rotY, state.rotX); @@ -177,45 +196,8 @@ export function createGlyphcssOrthographicCamera(opts: GlyphcssOrthographicCamer } /** - * First-person camera. Projection origin = eye (`target`). Vertices - * behind the eye (`r[2] >= 0`) are NaN-culled. + * Default camera alias — orthographic projection. The voxel render mode and + * iso/diagrammatic scenes are glyphcss's differentiator; ortho is the more + * representative default. */ -export function createGlyphcssFirstPersonCamera(opts: GlyphcssFirstPersonCameraOptions = {}): GlyphcssFirstPersonCameraHandle { - const state = { - rotX: opts.rotX ?? Math.PI / 2, - rotY: opts.rotY ?? 0, - distance: 0, - zoom: 1, - stretch: 1.0, - target: (opts.origin ?? [0, 0, 0]) as Vec3, - focal: opts.focal ?? 1, - }; - const [cxN, cyN] = opts.center ?? [0.5, 0.5]; - return { - kind: "firstPerson", - get rotX(): number { return state.rotX; }, - set rotX(v: number) { state.rotX = v; }, - get rotY(): number { return state.rotY; }, - set rotY(v: number) { state.rotY = v; }, - get distance(): number { return state.distance; }, - set distance(v: number) { state.distance = v; state.focal = Math.max(0.05, v / 100); }, - get zoom(): number { return state.zoom; }, - set zoom(v: number) { state.zoom = v; }, - get stretch(): number { return state.stretch; }, - set stretch(v: number) { state.stretch = v; }, - get target(): Vec3 { return state.target; }, - set target(v: Vec3) { state.target = v; }, - project(v, cols, rows, cellAspect) { - const shifted: Vec3 = [v[0] - state.target[0], v[1] - state.target[1], v[2] - state.target[2]]; - const r = rotateVec3(shifted, state.rotY, state.rotX); - // Cull at or behind the eye plane. - const NEAR = 0.001; - if (r[2] >= -NEAR) return [NaN, NaN, r[2]]; - const inv = state.focal / -r[2]; - const radius = Math.min(cols, rows) * state.zoom * inv; - const col = cols * cxN + r[0] * radius * cellAspect * state.stretch; - const row = rows * cyN + r[1] * radius; - return [col, row, r[2]]; - }, - }; -} +export const createGlyphCamera = createGlyphOrthographicCamera; diff --git a/packages/glyphcss/src/api/createGlyphcssFirstPersonControls.test.ts b/packages/glyphcss/src/api/createGlyphFirstPersonControls.test.ts similarity index 73% rename from packages/glyphcss/src/api/createGlyphcssFirstPersonControls.test.ts rename to packages/glyphcss/src/api/createGlyphFirstPersonControls.test.ts index 4f0b6578..0e9cc909 100644 --- a/packages/glyphcss/src/api/createGlyphcssFirstPersonControls.test.ts +++ b/packages/glyphcss/src/api/createGlyphFirstPersonControls.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { createGlyphcssScene } from "./createGlyphcssScene"; -import { createGlyphcssFirstPersonControls } from "./createGlyphcssFirstPersonControls"; -import type { GlyphcssSceneHandle } from "./createGlyphcssScene"; +import { createGlyphScene } from "./createGlyphScene"; +import { createGlyphFirstPersonControls } from "./createGlyphFirstPersonControls"; +import { createGlyphOrthographicCamera } from "./createGlyphCamera"; +import type { GlyphSceneHandle } from "./createGlyphScene"; -function makeScene(): GlyphcssSceneHandle { +function makeScene(): GlyphSceneHandle { const host = document.createElement("div"); document.body.appendChild(host); - return createGlyphcssScene(host, { cols: 20, rows: 10 }); + return createGlyphScene(host, { cols: 20, rows: 10 }); } function pd(host: Element, x: number, y: number, pointerId = 1): void { @@ -39,8 +40,8 @@ function ku(key: string): void { document.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true })); } -describe("createGlyphcssFirstPersonControls", () => { - let scene: GlyphcssSceneHandle; +describe("createGlyphFirstPersonControls", () => { + let scene: GlyphSceneHandle; beforeEach(() => { scene = makeScene(); @@ -59,7 +60,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("returns a handle with destroy()", () => { - const controls = createGlyphcssFirstPersonControls(scene); + const controls = createGlyphFirstPersonControls(scene); expect(typeof controls.destroy).toBe("function"); expect(typeof controls.pause).toBe("function"); expect(typeof controls.resume).toBe("function"); @@ -68,7 +69,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("mouse-drag right decreases rotY (look right)", () => { - const controls = createGlyphcssFirstPersonControls(scene); + const controls = createGlyphFirstPersonControls(scene); const initialRotY = scene.camera.rotY; pd(scene.host, 100, 100); @@ -81,7 +82,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("mouse-drag left increases rotY (look left)", () => { - const controls = createGlyphcssFirstPersonControls(scene); + const controls = createGlyphFirstPersonControls(scene); const initialRotY = scene.camera.rotY; pd(scene.host, 200, 100); @@ -93,7 +94,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("mouse-drag down increases rotX (look down)", () => { - const controls = createGlyphcssFirstPersonControls(scene); + const controls = createGlyphFirstPersonControls(scene); const initialRotX = scene.camera.rotX; pd(scene.host, 100, 100); @@ -105,7 +106,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("rotX is clamped to [-π/2, π/2]", () => { - const controls = createGlyphcssFirstPersonControls(scene); + const controls = createGlyphFirstPersonControls(scene); pd(scene.host, 0, 0); pm(scene.host, 0, 100000); // huge dy @@ -117,7 +118,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("lookSpeed option scales mouse-drag rotation magnitude", () => { - const controlsFast = createGlyphcssFirstPersonControls(scene, { lookSpeed: 0.01 }); + const controlsFast = createGlyphFirstPersonControls(scene, { lookSpeed: 0.01 }); const dx = 100; pd(scene.host, 0, 0); @@ -130,7 +131,7 @@ describe("createGlyphcssFirstPersonControls", () => { controlsFast.destroy(); scene.camera.rotY = 0; - const controlsSlow = createGlyphcssFirstPersonControls(scene, { lookSpeed: 0.001 }); + const controlsSlow = createGlyphFirstPersonControls(scene, { lookSpeed: 0.001 }); pd(scene.host, 0, 0); pm(scene.host, dx, 0); pu(scene.host); @@ -143,7 +144,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("invert option reverses drag direction", () => { - const controls = createGlyphcssFirstPersonControls(scene, { invert: true }); + const controls = createGlyphFirstPersonControls(scene, { invert: true }); const initialRotY = scene.camera.rotY; pd(scene.host, 100, 100); @@ -155,7 +156,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("'w' key moves camera target forward (negative z-ish in camera space)", async () => { - const controls = createGlyphcssFirstPersonControls(scene, { keyboard: true }); + const controls = createGlyphFirstPersonControls(scene, { keyboard: true }); // rotY=0: forward = sinY=0, cosY=1 → target moves toward -cosY direction const initialY = scene.camera.target[1]; @@ -176,7 +177,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("'s' key moves camera target backward", async () => { - const controls = createGlyphcssFirstPersonControls(scene, { keyboard: true }); + const controls = createGlyphFirstPersonControls(scene, { keyboard: true }); const initialY = scene.camera.target[1]; kd("s"); @@ -189,7 +190,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("'a' key strafes left (target.x decreases at rotY=0)", async () => { - const controls = createGlyphcssFirstPersonControls(scene, { keyboard: true }); + const controls = createGlyphFirstPersonControls(scene, { keyboard: true }); const initialX = scene.camera.target[0]; kd("a"); @@ -202,7 +203,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("'d' key strafes right (target.x increases at rotY=0)", async () => { - const controls = createGlyphcssFirstPersonControls(scene, { keyboard: true }); + const controls = createGlyphFirstPersonControls(scene, { keyboard: true }); const initialX = scene.camera.target[0]; kd("d"); @@ -215,7 +216,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("moveSpeed option scales keyboard movement magnitude", async () => { - const controlsFast = createGlyphcssFirstPersonControls(scene, { moveSpeed: 0.5 }); + const controlsFast = createGlyphFirstPersonControls(scene, { moveSpeed: 0.5 }); kd("w"); await new Promise((r) => requestAnimationFrame(r)); ku("w"); @@ -224,7 +225,7 @@ describe("createGlyphcssFirstPersonControls", () => { controlsFast.destroy(); scene.camera.target = [0, 0, 0]; - const controlsSlow = createGlyphcssFirstPersonControls(scene, { moveSpeed: 0.01 }); + const controlsSlow = createGlyphFirstPersonControls(scene, { moveSpeed: 0.01 }); kd("w"); await new Promise((r) => requestAnimationFrame(r)); ku("w"); @@ -235,7 +236,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("arrow keys also move the camera", async () => { - const controls = createGlyphcssFirstPersonControls(scene, { keyboard: true }); + const controls = createGlyphFirstPersonControls(scene, { keyboard: true }); const initialY = scene.camera.target[1]; kd("ArrowUp"); @@ -247,7 +248,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("destroy() stops responding to mouse-drag events", () => { - const controls = createGlyphcssFirstPersonControls(scene); + const controls = createGlyphFirstPersonControls(scene); controls.destroy(); const rotYBefore = scene.camera.rotY; @@ -259,7 +260,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("destroy() stops keyboard processing", async () => { - const controls = createGlyphcssFirstPersonControls(scene, { keyboard: true }); + const controls = createGlyphFirstPersonControls(scene, { keyboard: true }); controls.destroy(); const initialY = scene.camera.target[1]; @@ -271,7 +272,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("pause() stops drag handling; resume() restores it", () => { - const controls = createGlyphcssFirstPersonControls(scene); + const controls = createGlyphFirstPersonControls(scene); controls.pause(); const rotYBefore = scene.camera.rotY; @@ -290,7 +291,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("drag disabled via option produces no rotation", () => { - const controls = createGlyphcssFirstPersonControls(scene, { drag: false }); + const controls = createGlyphFirstPersonControls(scene, { drag: false }); const initialRotY = scene.camera.rotY; pd(scene.host, 100, 100); @@ -302,7 +303,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("keyboard disabled via option produces no movement on key press", async () => { - const controls = createGlyphcssFirstPersonControls(scene, { keyboard: false }); + const controls = createGlyphFirstPersonControls(scene, { keyboard: false }); const initialY = scene.camera.target[1]; kd("w"); @@ -314,7 +315,7 @@ describe("createGlyphcssFirstPersonControls", () => { }); it("pointermove without pointerdown is a no-op", () => { - const controls = createGlyphcssFirstPersonControls(scene); + const controls = createGlyphFirstPersonControls(scene); const initialRotY = scene.camera.rotY; pm(scene.host, 300, 100); @@ -322,4 +323,35 @@ describe("createGlyphcssFirstPersonControls", () => { expect(scene.camera.rotY).toBe(initialRotY); controls.destroy(); }); + + it("sets eyeMode=true on the camera at attach time", () => { + expect(scene.camera.eyeMode).toBe(false); + const controls = createGlyphFirstPersonControls(scene); + expect(scene.camera.eyeMode).toBe(true); + controls.destroy(); + }); + + it("restores eyeMode=false on the camera after destroy", () => { + const controls = createGlyphFirstPersonControls(scene); + expect(scene.camera.eyeMode).toBe(true); + controls.destroy(); + expect(scene.camera.eyeMode).toBe(false); + }); +}); + +describe("createGlyphFirstPersonControls — orthographic camera rejection", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("throws when the scene has an orthographic camera", () => { + const host = document.createElement("div"); + document.body.appendChild(host); + const orthoCamera = createGlyphOrthographicCamera(); + const scene = createGlyphScene(host, { camera: orthoCamera, cols: 20, rows: 10 }); + expect(() => createGlyphFirstPersonControls(scene)).toThrow( + /GlyphFirstPersonControls requires a perspective camera/, + ); + scene.destroy(); + }); }); diff --git a/packages/glyphcss/src/api/createGlyphcssFirstPersonControls.ts b/packages/glyphcss/src/api/createGlyphFirstPersonControls.ts similarity index 82% rename from packages/glyphcss/src/api/createGlyphcssFirstPersonControls.ts rename to packages/glyphcss/src/api/createGlyphFirstPersonControls.ts index 776faca8..94717c3d 100644 --- a/packages/glyphcss/src/api/createGlyphcssFirstPersonControls.ts +++ b/packages/glyphcss/src/api/createGlyphFirstPersonControls.ts @@ -1,14 +1,18 @@ /** - * createGlyphcssFirstPersonControls — first-person camera input for a GlyphcssScene. + * createGlyphFirstPersonControls — first-person camera input for a GlyphScene. + * + * Requires the scene to have a perspective camera. On attach, sets + * `camera.eyeMode = true` so the perspective projection switches to the + * eye-at-origin formulation (target = eye position, near-plane cull). On + * detach, restores `eyeMode = false`. * * Mouse-drag looks around (rotX/rotY). WASD or arrow keys move forward/backward/strafe. - * Mirrors glyphcss's createPolyFirstPersonControls semantics for the ASCII rasterizer. */ -import type { GlyphcssSceneHandle } from "./createGlyphcssScene"; +import type { GlyphSceneHandle } from "./createGlyphScene"; import type { Vec3 } from "@glyphcss/core"; -export interface GlyphcssFirstPersonControlsOptions { +export interface GlyphFirstPersonControlsOptions { drag?: boolean; keyboard?: boolean; moveSpeed?: number; @@ -16,17 +20,25 @@ export interface GlyphcssFirstPersonControlsOptions { invert?: boolean | number; } -export interface GlyphcssFirstPersonControlsHandle { - update(opts: GlyphcssFirstPersonControlsOptions): void; +export interface GlyphFirstPersonControlsHandle { + update(opts: GlyphFirstPersonControlsOptions): void; pause(): void; resume(): void; destroy(): void; } -export function createGlyphcssFirstPersonControls( - scene: GlyphcssSceneHandle, - options: GlyphcssFirstPersonControlsOptions = {}, -): GlyphcssFirstPersonControlsHandle { +export function createGlyphFirstPersonControls( + scene: GlyphSceneHandle, + options: GlyphFirstPersonControlsOptions = {}, +): GlyphFirstPersonControlsHandle { + if (scene.camera.kind !== "perspective") { + throw new Error( + "glyphcss: GlyphFirstPersonControls requires a perspective camera. " + + "Use (not / ).", + ); + } + scene.camera.eyeMode = true; + const host = scene.host; let drag = options.drag ?? true; let keyboard = options.keyboard ?? true; @@ -126,12 +138,13 @@ export function createGlyphcssFirstPersonControls( host.style.userSelect = ""; if (rafId !== null) { if (typeof cancelAnimationFrame !== "undefined") cancelAnimationFrame(rafId); rafId = null; } keys.clear(); + scene.camera.eyeMode = false; } attach(); return { - update(opts: GlyphcssFirstPersonControlsOptions): void { + update(opts: GlyphFirstPersonControlsOptions): void { drag = opts.drag ?? drag; keyboard = opts.keyboard ?? keyboard; moveSpeed = opts.moveSpeed ?? moveSpeed; diff --git a/packages/glyphcss/src/api/createGlyphcssMapControls.test.ts b/packages/glyphcss/src/api/createGlyphMapControls.test.ts similarity index 83% rename from packages/glyphcss/src/api/createGlyphcssMapControls.test.ts rename to packages/glyphcss/src/api/createGlyphMapControls.test.ts index 67497f03..ad252ed4 100644 --- a/packages/glyphcss/src/api/createGlyphcssMapControls.test.ts +++ b/packages/glyphcss/src/api/createGlyphMapControls.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { createGlyphcssScene } from "./createGlyphcssScene"; -import { createGlyphcssMapControls } from "./createGlyphcssMapControls"; -import type { GlyphcssSceneHandle } from "./createGlyphcssScene"; +import { createGlyphScene } from "./createGlyphScene"; +import { createGlyphMapControls } from "./createGlyphMapControls"; +import type { GlyphSceneHandle } from "./createGlyphScene"; -function makeScene(): GlyphcssSceneHandle { +function makeScene(): GlyphSceneHandle { const host = document.createElement("div"); document.body.appendChild(host); - return createGlyphcssScene(host, { cols: 20, rows: 10 }); + return createGlyphScene(host, { cols: 20, rows: 10 }); } function pd(host: Element, x: number, y: number, button = 0, pointerId = 1): void { @@ -31,8 +31,8 @@ function pu(host: Element, pointerId = 1): void { ); } -describe("createGlyphcssMapControls", () => { - let scene: GlyphcssSceneHandle; +describe("createGlyphMapControls", () => { + let scene: GlyphSceneHandle; beforeEach(() => { scene = makeScene(); @@ -43,7 +43,7 @@ describe("createGlyphcssMapControls", () => { }); it("returns a handle with destroy()", () => { - const controls = createGlyphcssMapControls(scene); + const controls = createGlyphMapControls(scene); expect(typeof controls.destroy).toBe("function"); expect(typeof controls.pause).toBe("function"); expect(typeof controls.resume).toBe("function"); @@ -52,7 +52,7 @@ describe("createGlyphcssMapControls", () => { }); it("left-drag pans the target (camera.target shifts)", () => { - const controls = createGlyphcssMapControls(scene); + const controls = createGlyphMapControls(scene); const [tx0, ty0, tz0] = scene.camera.target; pd(scene.host, 100, 100, 0); @@ -68,7 +68,7 @@ describe("createGlyphcssMapControls", () => { }); it("left-drag right → target.x decreases (map moves left)", () => { - const controls = createGlyphcssMapControls(scene); + const controls = createGlyphMapControls(scene); const initialX = scene.camera.target[0]; pd(scene.host, 100, 100, 0); @@ -80,7 +80,7 @@ describe("createGlyphcssMapControls", () => { }); it("left-drag down → target.y decreases", () => { - const controls = createGlyphcssMapControls(scene); + const controls = createGlyphMapControls(scene); const initialY = scene.camera.target[1]; pd(scene.host, 100, 100, 0); @@ -92,7 +92,7 @@ describe("createGlyphcssMapControls", () => { }); it("right-drag orbits instead of panning (rotY changes, target unchanged)", () => { - const controls = createGlyphcssMapControls(scene); + const controls = createGlyphMapControls(scene); const initialRotY = scene.camera.rotY; const initialTargetX = scene.camera.target[0]; @@ -106,7 +106,7 @@ describe("createGlyphcssMapControls", () => { }); it("shift+left-drag orbits instead of panning", () => { - const controls = createGlyphcssMapControls(scene); + const controls = createGlyphMapControls(scene); const initialRotY = scene.camera.rotY; const initialTargetX = scene.camera.target[0]; @@ -120,7 +120,7 @@ describe("createGlyphcssMapControls", () => { }); it("wheel deltaY < 0 increases scale (zoom in)", () => { - const controls = createGlyphcssMapControls(scene); + const controls = createGlyphMapControls(scene); const initialZoom = scene.camera.zoom; scene.host.dispatchEvent(new WheelEvent("wheel", { deltaY: -100, bubbles: true })); @@ -130,7 +130,7 @@ describe("createGlyphcssMapControls", () => { }); it("wheel deltaY > 0 decreases scale (zoom out)", () => { - const controls = createGlyphcssMapControls(scene); + const controls = createGlyphMapControls(scene); const initialZoom = scene.camera.zoom; scene.host.dispatchEvent(new WheelEvent("wheel", { deltaY: 100, bubbles: true })); @@ -140,7 +140,7 @@ describe("createGlyphcssMapControls", () => { }); it("scale clamped between 0.05 and 10", () => { - const controls = createGlyphcssMapControls(scene); + const controls = createGlyphMapControls(scene); for (let i = 0; i < 50; i++) { scene.host.dispatchEvent(new WheelEvent("wheel", { deltaY: 10000, bubbles: true })); @@ -155,7 +155,7 @@ describe("createGlyphcssMapControls", () => { }); it("destroy() stops responding to pan events", () => { - const controls = createGlyphcssMapControls(scene); + const controls = createGlyphMapControls(scene); controls.destroy(); const [tx0] = scene.camera.target; @@ -167,7 +167,7 @@ describe("createGlyphcssMapControls", () => { }); it("destroy() stops responding to wheel events", () => { - const controls = createGlyphcssMapControls(scene); + const controls = createGlyphMapControls(scene); controls.destroy(); const zoomBefore = scene.camera.zoom; @@ -179,7 +179,7 @@ describe("createGlyphcssMapControls", () => { it("invert option reverses orbit direction (right-drag) but NOT pan direction", () => { // The pan formula does not apply invertFactor — only the orbit branch does. // Verify that invert reverses right-drag orbit direction. - const controls = createGlyphcssMapControls(scene, { invert: true }); + const controls = createGlyphMapControls(scene, { invert: true }); const initialRotY = scene.camera.rotY; // Right-drag triggers orbit which respects invert @@ -197,14 +197,14 @@ describe("createGlyphcssMapControls", () => { // Use a higher scale so pan distance is smaller const sceneHighZoom = makeScene(); sceneHighZoom.camera.zoom = 2; - const controlsHigh = createGlyphcssMapControls(sceneHighZoom); + const controlsHigh = createGlyphMapControls(sceneHighZoom); const sceneLowZoom = makeScene(); sceneLowZoom.camera.zoom = 1; - const controlsLow = createGlyphcssMapControls(sceneLowZoom); + const controlsLow = createGlyphMapControls(sceneLowZoom); const dx = 100; - const drag = (s: GlyphcssSceneHandle) => { + const drag = (s: GlyphSceneHandle) => { pd(s.host, 0, 0, 0); pm(s.host, dx, 0); pu(s.host); @@ -226,7 +226,7 @@ describe("createGlyphcssMapControls", () => { }); it("pause() stops pan; resume() restores it", () => { - const controls = createGlyphcssMapControls(scene); + const controls = createGlyphMapControls(scene); controls.pause(); const [tx0] = scene.camera.target; @@ -245,7 +245,7 @@ describe("createGlyphcssMapControls", () => { }); it("drag disabled via option produces no target change", () => { - const controls = createGlyphcssMapControls(scene, { drag: false }); + const controls = createGlyphMapControls(scene, { drag: false }); const [tx0] = scene.camera.target; pd(scene.host, 100, 100, 0); @@ -257,7 +257,7 @@ describe("createGlyphcssMapControls", () => { }); it("wheel disabled via option produces no scale change", () => { - const controls = createGlyphcssMapControls(scene, { wheel: false }); + const controls = createGlyphMapControls(scene, { wheel: false }); const initialZoom = scene.camera.zoom; scene.host.dispatchEvent(new WheelEvent("wheel", { deltaY: -100, bubbles: true })); @@ -267,7 +267,7 @@ describe("createGlyphcssMapControls", () => { }); it("pointermove without pointerdown is a no-op", () => { - const controls = createGlyphcssMapControls(scene); + const controls = createGlyphMapControls(scene); const [tx0] = scene.camera.target; pm(scene.host, 300, 100); diff --git a/packages/glyphcss/src/api/createGlyphcssMapControls.ts b/packages/glyphcss/src/api/createGlyphMapControls.ts similarity index 91% rename from packages/glyphcss/src/api/createGlyphcssMapControls.ts rename to packages/glyphcss/src/api/createGlyphMapControls.ts index b6051f64..b8de853e 100644 --- a/packages/glyphcss/src/api/createGlyphcssMapControls.ts +++ b/packages/glyphcss/src/api/createGlyphMapControls.ts @@ -1,32 +1,32 @@ /** - * createGlyphcssMapControls — map/pan-mode camera input for a GlyphcssScene. + * createGlyphMapControls — map/pan-mode camera input for a GlyphScene. * * Left-drag pans the target (slippy-map semantics). Right-drag or * Shift+left-drag orbits. Wheel zooms. Mirrors glyphcss's createPolyMapControls - * semantics, adapted for the ASCII rasterizer's GlyphcssCamera. + * semantics, adapted for the ASCII rasterizer's GlyphCamera. */ -import type { GlyphcssSceneHandle } from "./createGlyphcssScene"; +import type { GlyphSceneHandle } from "./createGlyphScene"; import type { Vec3 } from "@glyphcss/core"; -export interface GlyphcssMapControlsOptions { +export interface GlyphMapControlsOptions { drag?: boolean; wheel?: boolean; invert?: boolean | number; animate?: false | { speed?: number; axis?: "x" | "y"; pauseOnInteraction?: boolean }; } -export interface GlyphcssMapControlsHandle { - update(opts: GlyphcssMapControlsOptions): void; +export interface GlyphMapControlsHandle { + update(opts: GlyphMapControlsOptions): void; pause(): void; resume(): void; destroy(): void; } -export function createGlyphcssMapControls( - scene: GlyphcssSceneHandle, - options: GlyphcssMapControlsOptions = {}, -): GlyphcssMapControlsHandle { +export function createGlyphMapControls( + scene: GlyphSceneHandle, + options: GlyphMapControlsOptions = {}, +): GlyphMapControlsHandle { const host = scene.host; let drag = options.drag ?? true; let wheel = options.wheel ?? true; @@ -158,7 +158,7 @@ export function createGlyphcssMapControls( startAnim(); return { - update(opts: GlyphcssMapControlsOptions): void { + update(opts: GlyphMapControlsOptions): void { const wasAnimating = !!animOpts; drag = opts.drag ?? drag; wheel = opts.wheel ?? wheel; diff --git a/packages/glyphcss/src/api/createGlyphcssOrbitControls.test.ts b/packages/glyphcss/src/api/createGlyphOrbitControls.test.ts similarity index 80% rename from packages/glyphcss/src/api/createGlyphcssOrbitControls.test.ts rename to packages/glyphcss/src/api/createGlyphOrbitControls.test.ts index 16cb5400..db9c8b0b 100644 --- a/packages/glyphcss/src/api/createGlyphcssOrbitControls.test.ts +++ b/packages/glyphcss/src/api/createGlyphOrbitControls.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { createGlyphcssScene } from "./createGlyphcssScene"; -import { createGlyphcssOrbitControls } from "./createGlyphcssOrbitControls"; -import type { GlyphcssSceneHandle } from "./createGlyphcssScene"; +import { createGlyphScene } from "./createGlyphScene"; +import { createGlyphOrbitControls } from "./createGlyphOrbitControls"; +import type { GlyphSceneHandle } from "./createGlyphScene"; -function makeScene(): GlyphcssSceneHandle { +function makeScene(): GlyphSceneHandle { const host = document.createElement("div"); document.body.appendChild(host); - return createGlyphcssScene(host, { cols: 20, rows: 10 }); + return createGlyphScene(host, { cols: 20, rows: 10 }); } function pd(host: Element, x: number, y: number, pointerId = 1): void { @@ -31,8 +31,8 @@ function pu(host: Element, pointerId = 1): void { ); } -describe("createGlyphcssOrbitControls", () => { - let scene: GlyphcssSceneHandle; +describe("createGlyphOrbitControls", () => { + let scene: GlyphSceneHandle; beforeEach(() => { scene = makeScene(); @@ -43,7 +43,7 @@ describe("createGlyphcssOrbitControls", () => { }); it("returns a handle with destroy()", () => { - const controls = createGlyphcssOrbitControls(scene); + const controls = createGlyphOrbitControls(scene); expect(typeof controls.destroy).toBe("function"); expect(typeof controls.pause).toBe("function"); expect(typeof controls.resume).toBe("function"); @@ -52,7 +52,7 @@ describe("createGlyphcssOrbitControls", () => { }); it("dragging right decreases rotY (camera spins left)", () => { - const controls = createGlyphcssOrbitControls(scene); + const controls = createGlyphOrbitControls(scene); const initialRotY = scene.camera.rotY; pd(scene.host, 100, 100); @@ -65,7 +65,7 @@ describe("createGlyphcssOrbitControls", () => { }); it("dragging left increases rotY", () => { - const controls = createGlyphcssOrbitControls(scene); + const controls = createGlyphOrbitControls(scene); const initialRotY = scene.camera.rotY; pd(scene.host, 200, 100); @@ -76,20 +76,20 @@ describe("createGlyphcssOrbitControls", () => { controls.destroy(); }); - it("dragging down increases rotX (tilts up)", () => { - const controls = createGlyphcssOrbitControls(scene); + it("dragging down decreases rotX (camera orbits downward — drag-follows-pointer)", () => { + const controls = createGlyphOrbitControls(scene); const initialRotX = scene.camera.rotX; pd(scene.host, 100, 100); pm(scene.host, 100, 200); // dy = +100 pu(scene.host); - expect(scene.camera.rotX).toBeGreaterThan(initialRotX); + expect(scene.camera.rotX).toBeLessThan(initialRotX); controls.destroy(); }); it("rotX is clamped to [-π/2, π/2]", () => { - const controls = createGlyphcssOrbitControls(scene); + const controls = createGlyphOrbitControls(scene); // Drag down massively pd(scene.host, 0, 0); @@ -102,7 +102,7 @@ describe("createGlyphcssOrbitControls", () => { }); it("wheel deltaY < 0 increases scale (zoom in)", () => { - const controls = createGlyphcssOrbitControls(scene); + const controls = createGlyphOrbitControls(scene); const initialZoom = scene.camera.zoom; scene.host.dispatchEvent(new WheelEvent("wheel", { deltaY: -100, bubbles: true })); @@ -113,7 +113,7 @@ describe("createGlyphcssOrbitControls", () => { }); it("wheel deltaY > 0 decreases scale (zoom out)", () => { - const controls = createGlyphcssOrbitControls(scene); + const controls = createGlyphOrbitControls(scene); const initialZoom = scene.camera.zoom; scene.host.dispatchEvent(new WheelEvent("wheel", { deltaY: 100, bubbles: true })); @@ -123,7 +123,7 @@ describe("createGlyphcssOrbitControls", () => { }); it("scale is clamped between 0.05 and 10", () => { - const controls = createGlyphcssOrbitControls(scene); + const controls = createGlyphOrbitControls(scene); // Zoom out aggressively for (let i = 0; i < 50; i++) { @@ -140,7 +140,7 @@ describe("createGlyphcssOrbitControls", () => { }); it("destroy() stops responding to pointer events", () => { - const controls = createGlyphcssOrbitControls(scene); + const controls = createGlyphOrbitControls(scene); controls.destroy(); const rotYBefore = scene.camera.rotY; @@ -152,7 +152,7 @@ describe("createGlyphcssOrbitControls", () => { }); it("destroy() stops responding to wheel events", () => { - const controls = createGlyphcssOrbitControls(scene); + const controls = createGlyphOrbitControls(scene); controls.destroy(); const zoomBefore = scene.camera.zoom; @@ -162,7 +162,7 @@ describe("createGlyphcssOrbitControls", () => { }); it("invert option reverses drag direction", () => { - const controls = createGlyphcssOrbitControls(scene, { invert: true }); + const controls = createGlyphOrbitControls(scene, { invert: true }); const initialRotY = scene.camera.rotY; pd(scene.host, 100, 100); @@ -175,7 +175,7 @@ describe("createGlyphcssOrbitControls", () => { }); it("numeric invert factor scales drag magnitude", () => { - const controls2x = createGlyphcssOrbitControls(scene, { invert: 2 }); + const controls2x = createGlyphOrbitControls(scene, { invert: 2 }); const initialRotY = scene.camera.rotY; pd(scene.host, 100, 100); @@ -190,7 +190,7 @@ describe("createGlyphcssOrbitControls", () => { }); it("pause() stops drag handling; resume() restores it", () => { - const controls = createGlyphcssOrbitControls(scene); + const controls = createGlyphOrbitControls(scene); controls.pause(); const rotYBefore = scene.camera.rotY; @@ -209,7 +209,7 @@ describe("createGlyphcssOrbitControls", () => { }); it("drag disabled via option produces no rotation", () => { - const controls = createGlyphcssOrbitControls(scene, { drag: false }); + const controls = createGlyphOrbitControls(scene, { drag: false }); const initialRotY = scene.camera.rotY; pd(scene.host, 100, 100); @@ -221,7 +221,7 @@ describe("createGlyphcssOrbitControls", () => { }); it("wheel disabled via option produces no scale change", () => { - const controls = createGlyphcssOrbitControls(scene, { wheel: false }); + const controls = createGlyphOrbitControls(scene, { wheel: false }); const initialZoom = scene.camera.zoom; scene.host.dispatchEvent(new WheelEvent("wheel", { deltaY: -100, bubbles: true })); @@ -231,7 +231,7 @@ describe("createGlyphcssOrbitControls", () => { }); it("update() can re-enable drag mid-session", () => { - const controls = createGlyphcssOrbitControls(scene, { drag: false }); + const controls = createGlyphOrbitControls(scene, { drag: false }); controls.update({ drag: true }); const initialRotY = scene.camera.rotY; @@ -244,7 +244,7 @@ describe("createGlyphcssOrbitControls", () => { }); it("pointermove without prior pointerdown is a no-op", () => { - const controls = createGlyphcssOrbitControls(scene); + const controls = createGlyphOrbitControls(scene); const initialRotY = scene.camera.rotY; pm(scene.host, 300, 100); @@ -254,7 +254,7 @@ describe("createGlyphcssOrbitControls", () => { }); it("non-primary pointer events are ignored for drag start", () => { - const controls = createGlyphcssOrbitControls(scene); + const controls = createGlyphOrbitControls(scene); const initialRotY = scene.camera.rotY; scene.host.dispatchEvent( diff --git a/packages/glyphcss/src/api/createGlyphcssOrbitControls.ts b/packages/glyphcss/src/api/createGlyphOrbitControls.ts similarity index 87% rename from packages/glyphcss/src/api/createGlyphcssOrbitControls.ts rename to packages/glyphcss/src/api/createGlyphOrbitControls.ts index 77a55d00..dd6b9569 100644 --- a/packages/glyphcss/src/api/createGlyphcssOrbitControls.ts +++ b/packages/glyphcss/src/api/createGlyphOrbitControls.ts @@ -1,14 +1,14 @@ /** - * createGlyphcssOrbitControls — orbit-mode camera input for a GlyphcssScene. + * createGlyphOrbitControls — orbit-mode camera input for a GlyphScene. * * Left-drag rotates rotX / rotY around the target (orbit). Wheel zooms or * dollies. Mirrors glyphcss's createPolyOrbitControls semantics, adapted for - * the ASCII rasterizer's GlyphcssCamera instead of the CSS matrix3d camera. + * the ASCII rasterizer's GlyphCamera instead of the CSS matrix3d camera. */ -import type { GlyphcssSceneHandle } from "./createGlyphcssScene"; +import type { GlyphSceneHandle } from "./createGlyphScene"; -export interface GlyphcssOrbitControlsOptions { +export interface GlyphOrbitControlsOptions { /** Pointer-drag. Default: true. */ drag?: boolean; /** Wheel / pinch zoom. Default: true. */ @@ -19,17 +19,17 @@ export interface GlyphcssOrbitControlsOptions { animate?: false | { speed?: number; axis?: "x" | "y"; pauseOnInteraction?: boolean }; } -export interface GlyphcssOrbitControlsHandle { - update(opts: GlyphcssOrbitControlsOptions): void; +export interface GlyphOrbitControlsHandle { + update(opts: GlyphOrbitControlsOptions): void; pause(): void; resume(): void; destroy(): void; } -export function createGlyphcssOrbitControls( - scene: GlyphcssSceneHandle, - options: GlyphcssOrbitControlsOptions = {}, -): GlyphcssOrbitControlsHandle { +export function createGlyphOrbitControls( + scene: GlyphSceneHandle, + options: GlyphOrbitControlsOptions = {}, +): GlyphOrbitControlsHandle { const host = scene.host; let drag = options.drag ?? true; let wheel = options.wheel ?? true; @@ -71,7 +71,10 @@ export function createGlyphcssOrbitControls( const DEG_PER_PX = 1 / 4; const RAD_PER_PX = DEG_PER_PX * Math.PI / 180; camera.rotY = camera.rotY - dx * RAD_PER_PX * f; - camera.rotX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.rotX + dy * RAD_PER_PX * f)); + // Drag in the same direction as the pointer: dragging UP tilts the camera + // UP (positive rotX increase from the +Z-is-screen-up convention), so dy + // negates here. Matches the horizontal axis's `-dx` direction. + camera.rotX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.rotX - dy * RAD_PER_PX * f)); scene.rerender(); } @@ -147,7 +150,7 @@ export function createGlyphcssOrbitControls( startAnim(); return { - update(opts: GlyphcssOrbitControlsOptions): void { + update(opts: GlyphOrbitControlsOptions): void { const wasAnimating = !!animOpts; drag = opts.drag ?? drag; wheel = opts.wheel ?? wheel; diff --git a/packages/glyphcss/src/api/createGlyphcssScene.test.ts b/packages/glyphcss/src/api/createGlyphScene.test.ts similarity index 69% rename from packages/glyphcss/src/api/createGlyphcssScene.test.ts rename to packages/glyphcss/src/api/createGlyphScene.test.ts index b7312f67..859b0381 100644 --- a/packages/glyphcss/src/api/createGlyphcssScene.test.ts +++ b/packages/glyphcss/src/api/createGlyphScene.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { createGlyphcssScene } from "./createGlyphcssScene"; +import { createGlyphScene } from "./createGlyphScene"; import type { Polygon } from "@glyphcss/core"; function makeDiv(): HTMLElement { @@ -33,7 +33,7 @@ function makeCubePolygons(): Polygon[] { return out; } -describe("createGlyphcssScene", () => { +describe("createGlyphScene", () => { let host: HTMLElement; beforeEach(() => { @@ -42,23 +42,23 @@ describe("createGlyphcssScene", () => { }); it("creates a scene div with a pre element", () => { - const scene = createGlyphcssScene(host, { cols: 20, rows: 10 }); - const sceneEl = host.querySelector(".glyphcss-scene"); + const scene = createGlyphScene(host, { cols: 20, rows: 10 }); + const sceneEl = host.querySelector(".glyph-scene"); expect(sceneEl).toBeTruthy(); - const pre = host.querySelector("pre.glyphcss-output"); + const pre = host.querySelector("pre.glyph-output"); expect(pre).toBeTruthy(); scene.destroy(); }); it("exposes host and output references", () => { - const scene = createGlyphcssScene(host, { cols: 20, rows: 10 }); + const scene = createGlyphScene(host, { cols: 20, rows: 10 }); expect(scene.host).toBe(host); expect(scene.output.tagName.toLowerCase()).toBe("pre"); scene.destroy(); }); it("renders text content after adding a mesh", async () => { - const scene = createGlyphcssScene(host, { cols: 30, rows: 15, useColors: false }); + const scene = createGlyphScene(host, { cols: 30, rows: 15, useColors: false }); scene.add(makeCubePolygons()); // Await the microtask queue so scheduleRender fires await Promise.resolve(); @@ -67,8 +67,8 @@ describe("createGlyphcssScene", () => { scene.destroy(); }); - it("returns a GlyphcssMeshHandle with dispose", () => { - const scene = createGlyphcssScene(host, { cols: 20, rows: 10 }); + it("returns a GlyphMeshHandle with dispose", () => { + const scene = createGlyphScene(host, { cols: 20, rows: 10 }); const handle = scene.add(makeSinglePolygon()); expect(typeof handle.dispose).toBe("function"); expect(typeof handle.setTransform).toBe("function"); @@ -77,7 +77,7 @@ describe("createGlyphcssScene", () => { }); it("removes mesh on dispose and re-renders empty", async () => { - const scene = createGlyphcssScene(host, { cols: 20, rows: 10, useColors: false }); + const scene = createGlyphScene(host, { cols: 20, rows: 10, useColors: false }); const handle = scene.add(makeCubePolygons()); await Promise.resolve(); const withMesh = scene.output.textContent ?? ""; @@ -94,14 +94,14 @@ describe("createGlyphcssScene", () => { }); it("destroy removes the scene element from host", () => { - const scene = createGlyphcssScene(host, { cols: 10, rows: 5 }); - expect(host.querySelector(".glyphcss-scene")).toBeTruthy(); + const scene = createGlyphScene(host, { cols: 10, rows: 5 }); + expect(host.querySelector(".glyph-scene")).toBeTruthy(); scene.destroy(); - expect(host.querySelector(".glyphcss-scene")).toBeFalsy(); + expect(host.querySelector(".glyph-scene")).toBeFalsy(); }); it("setOptions changes mode and re-renders", async () => { - const scene = createGlyphcssScene(host, { cols: 30, rows: 15, mode: "solid", useColors: false }); + const scene = createGlyphScene(host, { cols: 30, rows: 15, mode: "solid", useColors: false }); scene.add(makeCubePolygons()); await Promise.resolve(); scene.setOptions({ mode: "wireframe" }); @@ -111,32 +111,34 @@ describe("createGlyphcssScene", () => { scene.destroy(); }); - it("addHotspot returns a handle with remove()", async () => { - const scene = createGlyphcssScene(host, { cols: 20, rows: 10 }); + it("addHotspot returns a handle with remove() and el", async () => { + const scene = createGlyphScene(host, { cols: 20, rows: 10 }); const hotspot = scene.addHotspot({ id: "test", at: [0, 0, 0] }); expect(typeof hotspot.remove).toBe("function"); + expect(hotspot.el).toBeInstanceOf(HTMLElement); + expect(hotspot.el.getAttribute("data-hotspot-id")).toBe("test"); hotspot.remove(); scene.destroy(); }); - it("GlyphcssMeshHandle.name is undefined when no id is supplied", () => { - const scene = createGlyphcssScene(host, { cols: 20, rows: 10 }); + it("GlyphMeshHandle.name is undefined when no id is supplied", () => { + const scene = createGlyphScene(host, { cols: 20, rows: 10 }); const handle = scene.add(makeSinglePolygon()); expect(handle.name).toBeUndefined(); handle.dispose(); scene.destroy(); }); - it("GlyphcssMeshHandle.name matches the id supplied via transform", () => { - const scene = createGlyphcssScene(host, { cols: 20, rows: 10 }); + it("GlyphMeshHandle.name matches the id supplied via transform", () => { + const scene = createGlyphScene(host, { cols: 20, rows: 10 }); const handle = scene.add(makeSinglePolygon(), { id: "hero-mesh" }); expect(handle.name).toBe("hero-mesh"); handle.dispose(); scene.destroy(); }); - it("GlyphcssMeshHandle.name updates when setTransform changes the id", () => { - const scene = createGlyphcssScene(host, { cols: 20, rows: 10 }); + it("GlyphMeshHandle.name updates when setTransform changes the id", () => { + const scene = createGlyphScene(host, { cols: 20, rows: 10 }); const handle = scene.add(makeSinglePolygon(), { id: "first" }); expect(handle.name).toBe("first"); handle.setTransform({ id: "second" }); diff --git a/packages/glyphcss/src/api/createGlyphcssScene.ts b/packages/glyphcss/src/api/createGlyphScene.ts similarity index 52% rename from packages/glyphcss/src/api/createGlyphcssScene.ts rename to packages/glyphcss/src/api/createGlyphScene.ts index 4fdeee3f..ce633691 100644 --- a/packages/glyphcss/src/api/createGlyphcssScene.ts +++ b/packages/glyphcss/src/api/createGlyphScene.ts @@ -1,14 +1,14 @@ /** - * createGlyphcssScene — imperative scene API. The vanilla counterpart to - * `` custom element. + * createGlyphScene — imperative scene API. The vanilla counterpart to + * `` custom element. * * Mirrors glyphcss's `createPolyScene` architecturally: - * - Takes a host element + scene options, returns a `GlyphcssSceneHandle`. + * - Takes a host element + scene options, returns a `GlyphSceneHandle`. * - `handle.add(polygons, transform?)` registers a mesh and returns a - * removable `GlyphcssMeshHandle`. + * removable `GlyphMeshHandle`. * - * DOM: injects `
` containing one `
` (text
- * output) and one `
` (positioned overlay + * DOM: injects `
` containing one `
` (text
+ * output) and one `
` (positioned overlay * for hotspot dots). * * Paint backend: on each render, walks all registered meshes, applies each @@ -27,16 +27,16 @@ import type { Hotspot, Polygon, } from "@glyphcss/core"; -import type { GlyphcssCamera } from "./createGlyphcssCamera"; -import { createGlyphcssPerspectiveCamera } from "./createGlyphcssCamera"; +import type { GlyphCamera } from "./createGlyphCamera"; +import { createGlyphPerspectiveCamera } from "./createGlyphCamera"; import { buildRasterizeContext } from "./rasterizeContext"; import { rasterize } from "../render/rasterize"; -import { injectGlyphcssBaseStyles } from "../styles/styles"; +import { injectGlyphBaseStyles } from "../styles/styles"; import { projectHotspots } from "./projectHotspots"; -import type { GlyphcssDirectionalLight, GlyphcssAmbientLight, GlyphcssMeshTransform } from "./types"; -export type { GlyphcssMeshTransform } from "./types"; +import type { GlyphDirectionalLight, GlyphAmbientLight, GlyphMeshTransform } from "./types"; +export type { GlyphMeshTransform } from "./types"; -export interface GlyphcssSceneOptions { +export interface GlyphSceneOptions { /** Render mode: "wireframe" | "solid". Default "solid". */ mode?: RenderMode; /** Named glyph palette. Defaults to "default". */ @@ -49,60 +49,97 @@ export interface GlyphcssSceneOptions { rows?: number; /** Character cell aspect ratio (height/width). Default 2.0. */ cellAspect?: number; - directionalLight?: GlyphcssDirectionalLight; - ambientLight?: GlyphcssAmbientLight; - camera?: GlyphcssCamera; + directionalLight?: GlyphDirectionalLight; + ambientLight?: GlyphAmbientLight; + camera?: GlyphCamera; + /** + * Smooth (Gouraud) shading. When `true`, per-pixel Lambert intensity is + * interpolated from per-vertex normals averaged across adjacent polygons + * within `creaseAngle`. Adjacent triangles on a curved surface render + * without visible seams along their shared edges. Default `false` — the + * faceted ASCII look is part of glyph's identity, so smooth shading is + * opt-in. Turn it on for organic / curved-surface meshes (bread, sphere, + * character models) where polygon seams hurt the silhouette. + */ + smoothShading?: boolean; + /** + * Crease angle in degrees. With smooth shading on, adjacent faces whose + * normals diverge by more than this angle stay flat-shaded at their shared + * edge (preserves hard corners on otherwise smooth meshes). `0` reproduces + * flat shading regardless of `smoothShading`; `180` smooths everything. + * Default `60`. + */ + creaseAngle?: number; + /** + * Auto-size the character grid to fill the host element. When `true`, the + * scene measures one monospace character's pixel size from the live `
`
+   * (using whatever font size the host inherits via CSS), computes `cols` and
+   * `rows` that fit the host's `clientWidth × clientHeight`, and re-fits on
+   * host resize via a `ResizeObserver`. Default `false` — fixed `cols`/`rows`
+   * (default 80×24) is the predictable choice for tests and SSR.
+   */
+  autoSize?: boolean;
 }
 
-export interface GlyphcssHotspotOptions {
+export interface GlyphHotspotOptions {
   id: string;
   at: Vec3;
   size?: [number, number];
 }
 
-export interface GlyphcssHotspotHandle {
+export interface GlyphHotspotHandle {
   remove(): void;
+  /** The absolutely-positioned overlay `
` in the hotspot layer. */ + readonly el: HTMLElement; } -export interface GlyphcssMeshHandle { +export interface GlyphMeshHandle { readonly id: number; /** String identifier supplied via the `id` prop / transform option. */ readonly name: string | undefined; /** The raw polygons registered with this mesh. */ readonly polygons: Polygon[]; - setTransform(transform: GlyphcssMeshTransform): void; + setTransform(transform: GlyphMeshTransform): void; dispose(): void; } -export interface GlyphcssSceneHandle { - /** The host element passed to `createGlyphcssScene`. */ +export interface GlyphSceneHandle { + /** The host element passed to `createGlyphScene`. */ readonly host: HTMLElement; /** The `
` element for reading rendered text output. */
   readonly output: HTMLPreElement;
   /** The camera attached to this scene (mutate then call `rerender()`). */
-  readonly camera: GlyphcssCamera;
+  readonly camera: GlyphCamera;
   /**
    * Register a polygon list as a mesh. Optionally supply a transform.
    * Returns a handle to update or dispose the mesh.
    */
-  add(polygons: Polygon[], transform?: GlyphcssMeshTransform): GlyphcssMeshHandle;
-  addHotspot(opts: GlyphcssHotspotOptions, onClick?: () => void): GlyphcssHotspotHandle;
+  add(polygons: Polygon[], transform?: GlyphMeshTransform): GlyphMeshHandle;
+  addHotspot(opts: GlyphHotspotOptions, onClick?: () => void): GlyphHotspotHandle;
   /** Force an immediate re-rasterize. Normally called automatically on add/remove/setOptions. */
   rerender(): void;
-  setOptions(opts: Partial): void;
-  getOptions(): GlyphcssSceneOptions;
+  setOptions(opts: Partial): void;
+  getOptions(): GlyphSceneOptions;
+  /**
+   * Re-measure the host's character cell (font-size, line-height) and adapt
+   * `cols`/`rows`/`cellAspect`. Only meaningful when `autoSize` was enabled.
+   * Call when something outside the scene options changes the cell size —
+   * e.g., the consumer overrode `pre.style.lineHeight` directly. The internal
+   * `ResizeObserver` already handles host-size changes automatically.
+   */
+  fit(): void;
   destroy(): void;
 }
 
 interface MeshEntry {
   id: number;
   polygons: Polygon[];
-  transform: GlyphcssMeshTransform;
+  transform: GlyphMeshTransform;
 }
 
 let nextMeshId = 1;
 
-function applyTransform(polygons: Polygon[], transform: GlyphcssMeshTransform): Polygon[] {
+function applyTransform(polygons: Polygon[], transform: GlyphMeshTransform): Polygon[] {
   const { position, scale, rotation } = transform;
   if (!position && !scale && !rotation) return polygons;
 
@@ -144,13 +181,13 @@ function applyTransform(polygons: Polygon[], transform: GlyphcssMeshTransform):
   }));
 }
 
-export function createGlyphcssScene(
+export function createGlyphScene(
   host: HTMLElement,
-  opts: GlyphcssSceneOptions = {},
-): GlyphcssSceneHandle {
-  injectGlyphcssBaseStyles(host.ownerDocument ?? undefined);
+  opts: GlyphSceneOptions = {},
+): GlyphSceneHandle {
+  injectGlyphBaseStyles(host.ownerDocument ?? undefined);
 
-  const options: Required = {
+  const options: Required = {
     mode: opts.mode ?? "solid",
     glyphPalette: opts.glyphPalette ?? "default",
     useColors: opts.useColors ?? true,
@@ -159,16 +196,19 @@ export function createGlyphcssScene(
     cellAspect: opts.cellAspect ?? 2.0,
     directionalLight: opts.directionalLight ?? { direction: [0.5, 0.7, 0.5], intensity: 1 },
     ambientLight: opts.ambientLight ?? { intensity: 0.4 },
-    camera: opts.camera ?? createGlyphcssPerspectiveCamera(),
+    camera: opts.camera ?? createGlyphPerspectiveCamera(),
+    smoothShading: opts.smoothShading ?? false,
+    creaseAngle: opts.creaseAngle ?? 60,
+    autoSize: opts.autoSize ?? false,
   };
 
   // Build DOM
   const sceneEl = host.ownerDocument!.createElement("div");
-  sceneEl.className = "glyphcss-scene";
+  sceneEl.className = "glyph-scene";
   const pre = host.ownerDocument!.createElement("pre") as HTMLPreElement;
-  pre.className = "glyphcss-output";
+  pre.className = "glyph-output";
   const hotspotLayer = host.ownerDocument!.createElement("div");
-  hotspotLayer.className = "glyphcss-hotspot-layer";
+  hotspotLayer.className = "glyph-hotspot-layer";
   sceneEl.appendChild(pre);
   sceneEl.appendChild(hotspotLayer);
   host.appendChild(sceneEl);
@@ -203,6 +243,8 @@ export function createGlyphcssScene(
       ambientLight: options.ambientLight,
       glyphPalette: options.glyphPalette,
       useColors: options.useColors,
+      smoothShading: options.smoothShading,
+      creaseAngle: options.creaseAngle,
     });
 
     const output = rasterize(ctx);
@@ -238,14 +280,19 @@ export function createGlyphcssScene(
         el.style.display = "none";
       } else {
         el.style.display = "";
-        el.style.left = `${cell.col * cellW}px`;
-        el.style.top = `${cell.row * cellH}px`;
+        // Anchor at the CELL CENTER (not top-left). The `.glyph-hotspot` CSS
+        // rule applies `transform: translate(-50%, -50%)` so the visible
+        // label/dot is centered on this point — and the rendered ASCII glyph
+        // at this cell is also drawn at the cell center, so the two visually
+        // coincide.
+        el.style.left = `${(cell.col + 0.5) * cellW}px`;
+        el.style.top = `${(cell.row + 0.5) * cellH}px`;
         el.style.zIndex = String(Math.round(cell.depth * 1000));
       }
     }
   }
 
-  function add(polygons: Polygon[], transform: GlyphcssMeshTransform = {}): GlyphcssMeshHandle {
+  function add(polygons: Polygon[], transform: GlyphMeshTransform = {}): GlyphMeshHandle {
     const id = nextMeshId++;
     meshes.set(id, { id, polygons, transform });
     scheduleRender();
@@ -254,7 +301,7 @@ export function createGlyphcssScene(
       get id() { return id; },
       get name() { return meshes.get(id)?.transform.id; },
       get polygons() { return polygons; },
-      setTransform(next: GlyphcssMeshTransform): void {
+      setTransform(next: GlyphMeshTransform): void {
         const entry = meshes.get(id);
         if (entry) { entry.transform = next; scheduleRender(); }
       },
@@ -265,9 +312,9 @@ export function createGlyphcssScene(
     };
   }
 
-  function addHotspot(hotspotOpts: GlyphcssHotspotOptions, onClick?: () => void): GlyphcssHotspotHandle {
+  function addHotspot(hotspotOpts: GlyphHotspotOptions, onClick?: () => void): GlyphHotspotHandle {
     const el = host.ownerDocument!.createElement("div");
-    el.className = "glyphcss-hotspot";
+    el.className = "glyph-hotspot";
     el.setAttribute("data-hotspot-id", hotspotOpts.id);
     const [w, h] = hotspotOpts.size ?? [1, 1];
     el.style.position = "absolute";
@@ -285,6 +332,7 @@ export function createGlyphcssScene(
     scheduleRender();
 
     return {
+      get el() { return el; },
       remove(): void {
         const idx = hotspots.indexOf(entry);
         if (idx >= 0) hotspots.splice(idx, 1);
@@ -299,7 +347,7 @@ export function createGlyphcssScene(
     doRender();
   }
 
-  function setOptions(partial: Partial): void {
+  function setOptions(partial: Partial): void {
     if (partial.mode !== undefined) options.mode = partial.mode;
     if (partial.glyphPalette !== undefined) options.glyphPalette = partial.glyphPalette;
     if (partial.useColors !== undefined) options.useColors = partial.useColors;
@@ -309,14 +357,72 @@ export function createGlyphcssScene(
     if (partial.directionalLight !== undefined) options.directionalLight = partial.directionalLight;
     if (partial.ambientLight !== undefined) options.ambientLight = partial.ambientLight;
     if (partial.camera !== undefined) options.camera = partial.camera;
+    if (partial.smoothShading !== undefined) options.smoothShading = partial.smoothShading;
+    if (partial.creaseAngle !== undefined) options.creaseAngle = partial.creaseAngle;
+    if (partial.autoSize !== undefined) {
+      options.autoSize = partial.autoSize;
+      if (options.autoSize && !resizeObserver && typeof ResizeObserver !== "undefined") {
+        resizeObserver = new ResizeObserver(() => fitToHost());
+        resizeObserver.observe(host);
+        fitToHost();
+      } else if (!options.autoSize && resizeObserver) {
+        resizeObserver.disconnect();
+        resizeObserver = null;
+      }
+    }
     scheduleRender();
   }
 
-  function getOptions(): GlyphcssSceneOptions {
+  function getOptions(): GlyphSceneOptions {
     return { ...options };
   }
 
+  /**
+   * Measure one monospace character cell from the live `
` element and
+   * compute `cols`/`rows` that fill the host's client box. We probe the actual
+   * `
` (not the host) so the measurement reflects the inherited font.
+   * Falls back to the existing cols/rows when the host has zero size (not yet
+   * attached) so the scene still renders.
+   */
+  function measureCell(): { w: number; h: number } {
+    // Inherit line-height + font-size from the `
` so the measurement
+    // reflects any caller-applied overrides (e.g. the gallery's lineHeight
+    // tunable). Hardcoding `line-height: 1` here would defeat the purpose.
+    const probe = host.ownerDocument!.createElement("span");
+    probe.textContent = "M";
+    probe.style.cssText =
+      "position:absolute;visibility:hidden;font-family:inherit;font-size:inherit;line-height:inherit;white-space:pre;padding:0;margin:0";
+    pre.appendChild(probe);
+    const r = probe.getBoundingClientRect();
+    probe.remove();
+    return { w: r.width || 8, h: r.height || 16 };
+  }
+
+  function fitToHost(): void {
+    const w = host.clientWidth;
+    const h = host.clientHeight;
+    if (!w || !h) return;
+    const cell = measureCell();
+    const cols = Math.max(20, Math.floor(w / cell.w));
+    const rows = Math.max(8, Math.floor(h / cell.h));
+    const cellAspect = cell.h / cell.w;
+    let changed = false;
+    if (options.cols !== cols) { options.cols = cols; changed = true; }
+    if (options.rows !== rows) { options.rows = rows; changed = true; }
+    if (Math.abs(options.cellAspect - cellAspect) > 0.01) { options.cellAspect = cellAspect; changed = true; }
+    if (changed) scheduleRender();
+  }
+
+  let resizeObserver: ResizeObserver | null = null;
+  if (options.autoSize && typeof ResizeObserver !== "undefined") {
+    resizeObserver = new ResizeObserver(() => fitToHost());
+    resizeObserver.observe(host);
+    // Initial fit (also handles the case where the host already has a size).
+    fitToHost();
+  }
+
   function destroy(): void {
+    if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; }
     meshes.clear();
     if (host.contains(sceneEl)) host.removeChild(sceneEl);
   }
@@ -332,6 +438,7 @@ export function createGlyphcssScene(
     rerender,
     setOptions,
     getOptions,
+    fit: fitToHost,
     destroy,
   };
 }
diff --git a/packages/glyphcss/src/api/events.ts b/packages/glyphcss/src/api/events.ts
index daf9c999..bb7ef405 100644
--- a/packages/glyphcss/src/api/events.ts
+++ b/packages/glyphcss/src/api/events.ts
@@ -10,7 +10,7 @@
  * Until then, consumers receive plain DOM events with `meshId` left undefined.
  */
 
-export type GlyphcssPointerEvent = PointerEvent & { meshId?: string };
-export type GlyphcssMouseEvent = MouseEvent & { meshId?: string };
-export type GlyphcssWheelEvent = WheelEvent & { meshId?: string };
-export type GlyphcssEventHandler = (event: E) => void;
+export type GlyphPointerEvent = PointerEvent & { meshId?: string };
+export type GlyphMouseEvent = MouseEvent & { meshId?: string };
+export type GlyphWheelEvent = WheelEvent & { meshId?: string };
+export type GlyphEventHandler = (event: E) => void;
diff --git a/packages/glyphcss/src/api/meshFinders.ts b/packages/glyphcss/src/api/meshFinders.ts
index 7f6a5472..f4ab4d96 100644
--- a/packages/glyphcss/src/api/meshFinders.ts
+++ b/packages/glyphcss/src/api/meshFinders.ts
@@ -2,25 +2,25 @@
  * Mesh lookup helpers — mirrors voxcss's `findPolyMeshHandle`,
  * `findMeshUnderPoint`, and `pointInMeshElement`.
  *
- * `findGlyphcssMeshHandle(host, id)` performs an O(n) walk of mesh elements
- * under the given host, matching on the `data-glyphcss-mesh-id` attribute.
+ * `findGlyphMeshHandle(host, id)` performs an O(n) walk of mesh elements
+ * under the given host, matching on the `data-glyph-mesh-id` attribute.
  *
  * `findMeshUnderPoint` and `pointInMeshElement` use bounding-box checks.
  * TODO(hit-layer): replace the bbox check with proper polygon raycasting once
  * the rasterizer hit-map is wired to the hit layer.
  */
 
-import type { GlyphcssSceneHandle } from "./createGlyphcssScene";
+import type { GlyphSceneHandle } from "./createGlyphScene";
 
 /**
  * Given a host element and a string mesh id, return the mesh's HTMLElement
- * (the `.glyphcss-mesh` wrapper div) if found, or `null`.
+ * (the `.glyph-mesh` wrapper div) if found, or `null`.
  */
-export function findGlyphcssMeshHandle(
+export function findGlyphMeshHandle(
   host: HTMLElement,
   id: string,
 ): HTMLElement | null {
-  const el = host.querySelector(`[data-glyphcss-mesh-id="${CSS.escape(id)}"]`);
+  const el = host.querySelector(`[data-glyph-mesh-id="${CSS.escape(id)}"]`);
   return el instanceof HTMLElement ? el : null;
 }
 
@@ -41,7 +41,7 @@ export function pointInMeshElement(
 }
 
 /**
- * Returns the `.glyphcss-mesh` element whose bounding box contains the
+ * Returns the `.glyph-mesh` element whose bounding box contains the
  * given client coordinates, or `null`.
  *
  * The `host` parameter scopes the search; pass the scene host element to
@@ -57,7 +57,7 @@ export function findMeshUnderPoint(
 ): HTMLElement | null {
   const root = host instanceof Document ? host : host;
   const meshEls = Array.from(
-    root.querySelectorAll(".glyphcss-mesh"),
+    root.querySelectorAll(".glyph-mesh"),
   ) as HTMLElement[];
   for (const meshEl of meshEls) {
     if (pointInMeshElement(meshEl, clientX, clientY)) return meshEl;
@@ -66,4 +66,4 @@ export function findMeshUnderPoint(
   return null;
 }
 
-export type { GlyphcssSceneHandle };
+export type { GlyphSceneHandle };
diff --git a/packages/glyphcss/src/api/projectHotspots.ts b/packages/glyphcss/src/api/projectHotspots.ts
index 82962913..06cded41 100644
--- a/packages/glyphcss/src/api/projectHotspots.ts
+++ b/packages/glyphcss/src/api/projectHotspots.ts
@@ -1,5 +1,5 @@
 import type { Hotspot, HotspotCell } from "@glyphcss/core";
-import type { GlyphcssCamera } from "./createGlyphcssCamera";
+import type { GlyphCamera } from "./createGlyphCamera";
 
 /**
  * Project a list of 3D hotspot anchors through the camera. Returns the
@@ -11,7 +11,7 @@ import type { GlyphcssCamera } from "./createGlyphcssCamera";
  */
 export function projectHotspots(
   hotspots: readonly Hotspot[],
-  camera: GlyphcssCamera,
+  camera: GlyphCamera,
   cols: number,
   rows: number,
   cellAspect: number,
diff --git a/packages/glyphcss/src/api/rasterizeContext.ts b/packages/glyphcss/src/api/rasterizeContext.ts
index b7a13b4c..21d36bd5 100644
--- a/packages/glyphcss/src/api/rasterizeContext.ts
+++ b/packages/glyphcss/src/api/rasterizeContext.ts
@@ -4,19 +4,19 @@ import type {
   WireframeEdge,
   Polygon,
 } from "@glyphcss/core";
-import type { GlyphcssCamera } from "./createGlyphcssCamera";
-import type { GlyphcssDirectionalLight, GlyphcssAmbientLight } from "./types";
+import type { GlyphCamera } from "./createGlyphCamera";
+import type { GlyphDirectionalLight, GlyphAmbientLight } from "./types";
 
 export interface RasterizeContextOptions {
-  camera: GlyphcssCamera;
+  camera: GlyphCamera;
   grid: GridSize;
   /** Polygon list. Required for `solid` / `voxel` modes, optional otherwise. */
   polygons?: Polygon[];
   /** Explicit wireframe edges. If omitted in wireframe mode, edges are derived from `polygons` (fan-triangulated). */
   wireframe?: WireframeEdge[];
   mode?: RenderMode;
-  directionalLight?: GlyphcssDirectionalLight;
-  ambientLight?: GlyphcssAmbientLight;
+  directionalLight?: GlyphDirectionalLight;
+  ambientLight?: GlyphAmbientLight;
   /** Named wireframe glyph palette. Defaults to `"default"`. */
   glyphPalette?: string;
   /**
@@ -24,23 +24,39 @@ export interface RasterizeContextOptions {
    * output is just one text node — fastest possible DOM update. Default `true`.
    */
   useColors?: boolean;
+  /**
+   * Smooth (Gouraud) shading. When `true`, per-pixel Lambert intensity is
+   * interpolated from per-vertex normals (averaged across adjacent polygons
+   * within `creaseAngle`). Default `false` — flat shading is glyph's default
+   * because the facets are part of the ASCII aesthetic.
+   */
+  smoothShading?: boolean;
+  /**
+   * Crease angle in degrees for smooth shading. Vertex normals are averaged
+   * across adjacent faces whose normals diverge by less than this angle;
+   * edges sharper than this stay flat-shaded. `0` collapses to pure flat
+   * shading; `180` smooths every shared vertex. Default `60`.
+   */
+  creaseAngle?: number;
 }
 
 export interface RasterizeContext {
-  camera: GlyphcssCamera;
+  camera: GlyphCamera;
   grid: GridSize;
   polygons: Polygon[];
   wireframe: WireframeEdge[];
   mode: RenderMode;
-  directionalLight: GlyphcssDirectionalLight;
-  ambientLight: GlyphcssAmbientLight;
+  directionalLight: GlyphDirectionalLight;
+  ambientLight: GlyphAmbientLight;
   /** Named wireframe glyph palette passed to the rasterizer. */
   glyphPalette: string;
   useColors: boolean;
+  smoothShading: boolean;
+  creaseAngle: number;
 }
 
-const DEFAULT_DIRECTIONAL: GlyphcssDirectionalLight = { direction: [0.5, 0.7, 0.5], intensity: 1 };
-const DEFAULT_AMBIENT: GlyphcssAmbientLight = { intensity: 0.4 };
+const DEFAULT_DIRECTIONAL: GlyphDirectionalLight = { direction: [0.5, 0.7, 0.5], intensity: 1 };
+const DEFAULT_AMBIENT: GlyphAmbientLight = { intensity: 0.4 };
 
 function polygonsToWireframeEdges(polygons: Polygon[]): WireframeEdge[] {
   // Derive deduplicated edges by fan-triangulating each polygon and collecting
@@ -81,5 +97,7 @@ export function buildRasterizeContext(opts: RasterizeContextOptions): RasterizeC
     ambientLight: opts.ambientLight ?? DEFAULT_AMBIENT,
     glyphPalette: opts.glyphPalette ?? "default",
     useColors: opts.useColors ?? true,
+    smoothShading: opts.smoothShading ?? false,
+    creaseAngle: opts.creaseAngle ?? 60,
   };
 }
diff --git a/packages/glyphcss/src/api/types.ts b/packages/glyphcss/src/api/types.ts
index 815d8c46..228dc6cd 100644
--- a/packages/glyphcss/src/api/types.ts
+++ b/packages/glyphcss/src/api/types.ts
@@ -1,7 +1,7 @@
 import type { Vec3, Polygon } from "@glyphcss/core";
 
 /** Directional light — single distant source for the ASCII rasterizer. */
-export interface GlyphcssDirectionalLight {
+export interface GlyphDirectionalLight {
   direction: Vec3;
   intensity?: number;
   /** Hex color (#rrggbb). Tints the lit-side per-cell output. Default white. */
@@ -9,20 +9,20 @@ export interface GlyphcssDirectionalLight {
 }
 
 /** Ambient light — uniform fill regardless of orientation. */
-export interface GlyphcssAmbientLight {
+export interface GlyphAmbientLight {
   intensity?: number;
   /** Hex color (#rrggbb). Tints the unlit-side fill. Default white. */
   color?: string;
 }
 
-export interface GlyphcssMeshState {
+export interface GlyphMeshState {
   id: number;
   polygons: Polygon[];
-  transform: GlyphcssMeshTransform;
+  transform: GlyphMeshTransform;
 }
 
-export interface GlyphcssMeshTransform {
-  /** String identifier for the mesh — surfaced as `GlyphcssMeshHandle.name`. */
+export interface GlyphMeshTransform {
+  /** String identifier for the mesh — surfaced as `GlyphMeshHandle.name`. */
   id?: string;
   position?: Vec3;
   scale?: number | Vec3;
diff --git a/packages/glyphcss/src/elements.test.ts b/packages/glyphcss/src/elements.test.ts
index 0594a633..628e9fe9 100644
--- a/packages/glyphcss/src/elements.test.ts
+++ b/packages/glyphcss/src/elements.test.ts
@@ -4,38 +4,42 @@ import { describe, it, expect } from "vitest";
 import "./elements";
 
 describe("elements auto-registration", () => {
-  it("registers glyphcss-scene", () => {
-    expect(customElements.get("glyphcss-scene")).toBeDefined();
+  it("registers glyph-scene", () => {
+    expect(customElements.get("glyph-scene")).toBeDefined();
   });
 
-  it("registers glyphcss-mesh", () => {
-    expect(customElements.get("glyphcss-mesh")).toBeDefined();
+  it("registers glyph-mesh", () => {
+    expect(customElements.get("glyph-mesh")).toBeDefined();
   });
 
-  it("registers glyphcss-hotspot", () => {
-    expect(customElements.get("glyphcss-hotspot")).toBeDefined();
+  it("registers glyph-hotspot", () => {
+    expect(customElements.get("glyph-hotspot")).toBeDefined();
   });
 
-  it("registers glyphcss-perspective-camera", () => {
-    expect(customElements.get("glyphcss-perspective-camera")).toBeDefined();
+  it("registers glyph-perspective-camera", () => {
+    expect(customElements.get("glyph-perspective-camera")).toBeDefined();
   });
 
-  it("registers glyphcss-orthographic-camera", () => {
-    expect(customElements.get("glyphcss-orthographic-camera")).toBeDefined();
+  it("registers glyph-orthographic-camera", () => {
+    expect(customElements.get("glyph-orthographic-camera")).toBeDefined();
   });
 
-  it("registers glyphcss-orbit-controls", () => {
-    expect(customElements.get("glyphcss-orbit-controls")).toBeDefined();
+  it("registers glyph-camera (orthographic alias)", () => {
+    expect(customElements.get("glyph-camera")).toBeDefined();
   });
 
-  it("registers glyphcss-map-controls", () => {
-    expect(customElements.get("glyphcss-map-controls")).toBeDefined();
+  it("registers glyph-orbit-controls", () => {
+    expect(customElements.get("glyph-orbit-controls")).toBeDefined();
+  });
+
+  it("registers glyph-map-controls", () => {
+    expect(customElements.get("glyph-map-controls")).toBeDefined();
   });
 
   it("is idempotent — re-importing does not throw", () => {
     // The module guard prevents double-define. Since vitest caches modules,
     // a second import is already a no-op. We verify the tags are still defined.
-    expect(customElements.get("glyphcss-scene")).toBeDefined();
-    expect(customElements.get("glyphcss-mesh")).toBeDefined();
+    expect(customElements.get("glyph-scene")).toBeDefined();
+    expect(customElements.get("glyph-mesh")).toBeDefined();
   });
 });
diff --git a/packages/glyphcss/src/elements.ts b/packages/glyphcss/src/elements.ts
index 0ce0af34..cd6ccad6 100644
--- a/packages/glyphcss/src/elements.ts
+++ b/packages/glyphcss/src/elements.ts
@@ -9,44 +9,52 @@
  * `customElements` is undefined and we silently no-op so importing this
  * module doesn't crash the bundle.
  */
-import { GlyphcssSceneElement } from "./elements/GlyphcssSceneElement";
-import { GlyphcssMeshElement } from "./elements/GlyphcssMeshElement";
-import { GlyphcssHotspotElement } from "./elements/GlyphcssHotspotElement";
-import { GlyphcssPerspectiveCameraElement } from "./elements/GlyphcssPerspectiveCameraElement";
-import { GlyphcssOrthographicCameraElement } from "./elements/GlyphcssOrthographicCameraElement";
-import { GlyphcssOrbitControlsElement } from "./elements/GlyphcssOrbitControlsElement";
-import { GlyphcssMapControlsElement } from "./elements/GlyphcssMapControlsElement";
+import { GlyphSceneElement } from "./elements/GlyphSceneElement";
+import { GlyphMeshElement } from "./elements/GlyphMeshElement";
+import { GlyphHotspotElement } from "./elements/GlyphHotspotElement";
+import { GlyphPerspectiveCameraElement } from "./elements/GlyphPerspectiveCameraElement";
+import { GlyphOrthographicCameraElement } from "./elements/GlyphOrthographicCameraElement";
+import { GlyphOrbitControlsElement } from "./elements/GlyphOrbitControlsElement";
+import { GlyphMapControlsElement } from "./elements/GlyphMapControlsElement";
 
 if (typeof customElements !== "undefined") {
-  if (!customElements.get("glyphcss-scene")) {
-    customElements.define("glyphcss-scene", GlyphcssSceneElement);
+  if (!customElements.get("glyph-scene")) {
+    customElements.define("glyph-scene", GlyphSceneElement);
   }
-  if (!customElements.get("glyphcss-mesh")) {
-    customElements.define("glyphcss-mesh", GlyphcssMeshElement);
+  if (!customElements.get("glyph-mesh")) {
+    customElements.define("glyph-mesh", GlyphMeshElement);
   }
-  if (!customElements.get("glyphcss-hotspot")) {
-    customElements.define("glyphcss-hotspot", GlyphcssHotspotElement);
+  if (!customElements.get("glyph-hotspot")) {
+    customElements.define("glyph-hotspot", GlyphHotspotElement);
   }
-  if (!customElements.get("glyphcss-perspective-camera")) {
-    customElements.define("glyphcss-perspective-camera", GlyphcssPerspectiveCameraElement);
+  if (!customElements.get("glyph-perspective-camera")) {
+    customElements.define("glyph-perspective-camera", GlyphPerspectiveCameraElement);
   }
-  if (!customElements.get("glyphcss-orthographic-camera")) {
-    customElements.define("glyphcss-orthographic-camera", GlyphcssOrthographicCameraElement);
+  if (!customElements.get("glyph-orthographic-camera")) {
+    customElements.define("glyph-orthographic-camera", GlyphOrthographicCameraElement);
   }
-  if (!customElements.get("glyphcss-orbit-controls")) {
-    customElements.define("glyphcss-orbit-controls", GlyphcssOrbitControlsElement);
+  // `glyph-camera` is the ergonomic default alias — an orthographic camera.
+  // Custom elements require one constructor per tag name, so this is a distinct
+  // subclass of GlyphOrthographicCameraElement even though it is behaviorally
+  // identical. The subclass has no additional logic.
+  if (!customElements.get("glyph-camera")) {
+    class GlyphCameraElement extends GlyphOrthographicCameraElement {}
+    customElements.define("glyph-camera", GlyphCameraElement);
   }
-  if (!customElements.get("glyphcss-map-controls")) {
-    customElements.define("glyphcss-map-controls", GlyphcssMapControlsElement);
+  if (!customElements.get("glyph-orbit-controls")) {
+    customElements.define("glyph-orbit-controls", GlyphOrbitControlsElement);
+  }
+  if (!customElements.get("glyph-map-controls")) {
+    customElements.define("glyph-map-controls", GlyphMapControlsElement);
   }
 }
 
 export {
-  GlyphcssSceneElement,
-  GlyphcssMeshElement,
-  GlyphcssHotspotElement,
-  GlyphcssPerspectiveCameraElement,
-  GlyphcssOrthographicCameraElement,
-  GlyphcssOrbitControlsElement,
-  GlyphcssMapControlsElement,
+  GlyphSceneElement,
+  GlyphMeshElement,
+  GlyphHotspotElement,
+  GlyphPerspectiveCameraElement,
+  GlyphOrthographicCameraElement,
+  GlyphOrbitControlsElement,
+  GlyphMapControlsElement,
 };
diff --git a/packages/glyphcss/src/elements/GlyphcssHotspotElement.test.ts b/packages/glyphcss/src/elements/GlyphHotspotElement.test.ts
similarity index 62%
rename from packages/glyphcss/src/elements/GlyphcssHotspotElement.test.ts
rename to packages/glyphcss/src/elements/GlyphHotspotElement.test.ts
index 9a20842b..725eee40 100644
--- a/packages/glyphcss/src/elements/GlyphcssHotspotElement.test.ts
+++ b/packages/glyphcss/src/elements/GlyphHotspotElement.test.ts
@@ -1,43 +1,50 @@
 import { describe, it, expect, beforeEach, afterEach } from "vitest";
-import { GlyphcssSceneElement } from "./GlyphcssSceneElement";
-import { GlyphcssHotspotElement } from "./GlyphcssHotspotElement";
+import { GlyphSceneElement } from "./GlyphSceneElement";
+import { GlyphHotspotElement } from "./GlyphHotspotElement";
+import { GlyphPerspectiveCameraElement } from "./GlyphPerspectiveCameraElement";
 
-if (!customElements.get("glyphcss-scene")) {
-  customElements.define("glyphcss-scene", GlyphcssSceneElement);
+if (!customElements.get("glyph-scene")) {
+  customElements.define("glyph-scene", GlyphSceneElement);
 }
-if (!customElements.get("glyphcss-hotspot")) {
-  customElements.define("glyphcss-hotspot", GlyphcssHotspotElement);
+if (!customElements.get("glyph-hotspot")) {
+  customElements.define("glyph-hotspot", GlyphHotspotElement);
+}
+if (!customElements.get("glyph-perspective-camera")) {
+  customElements.define("glyph-perspective-camera", GlyphPerspectiveCameraElement);
 }
 
-describe("GlyphcssHotspotElement", () => {
-  let sceneEl: GlyphcssSceneElement;
-  let hotspot: GlyphcssHotspotElement;
+describe("GlyphHotspotElement", () => {
+  let camEl: GlyphPerspectiveCameraElement;
+  let sceneEl: GlyphSceneElement;
+  let hotspot: GlyphHotspotElement;
 
   beforeEach(() => {
-    sceneEl = document.createElement("glyphcss-scene") as GlyphcssSceneElement;
+    camEl = document.createElement("glyph-perspective-camera") as GlyphPerspectiveCameraElement;
+    sceneEl = document.createElement("glyph-scene") as GlyphSceneElement;
     sceneEl.setAttribute("cols", "20");
     sceneEl.setAttribute("rows", "5");
-    document.body.appendChild(sceneEl);
+    camEl.appendChild(sceneEl);
+    document.body.appendChild(camEl);
 
-    hotspot = document.createElement("glyphcss-hotspot") as GlyphcssHotspotElement;
+    hotspot = document.createElement("glyph-hotspot") as GlyphHotspotElement;
   });
 
   afterEach(() => {
-    if (sceneEl.isConnected) sceneEl.remove();
+    if (camEl.isConnected) camEl.remove();
   });
 
-  it("is registered under the 'glyphcss-hotspot' tag", () => {
-    expect(customElements.get("glyphcss-hotspot")).toBe(GlyphcssHotspotElement);
+  it("is registered under the 'glyph-hotspot' tag", () => {
+    expect(customElements.get("glyph-hotspot")).toBe(GlyphHotspotElement);
   });
 
-  it("createElement produces a GlyphcssHotspotElement instance", () => {
-    expect(hotspot).toBeInstanceOf(GlyphcssHotspotElement);
+  it("createElement produces a GlyphHotspotElement instance", () => {
+    expect(hotspot).toBeInstanceOf(GlyphHotspotElement);
   });
 
   it("observes at, size, hotspot-id attributes", () => {
-    expect(GlyphcssHotspotElement.observedAttributes).toContain("at");
-    expect(GlyphcssHotspotElement.observedAttributes).toContain("size");
-    expect(GlyphcssHotspotElement.observedAttributes).toContain("hotspot-id");
+    expect(GlyphHotspotElement.observedAttributes).toContain("at");
+    expect(GlyphHotspotElement.observedAttributes).toContain("size");
+    expect(GlyphHotspotElement.observedAttributes).toContain("hotspot-id");
   });
 
   it("connects without throwing when placed outside a scene", () => {
@@ -56,8 +63,8 @@ describe("GlyphcssHotspotElement", () => {
     hotspot.setAttribute("hotspot-id", "hs1");
     sceneEl.appendChild(hotspot);
     await Promise.resolve();
-    // Observable effect: a .glyphcss-hotspot element appears in the hotspot layer.
-    const hsEl = sceneEl.querySelector(".glyphcss-hotspot[data-hotspot-id='hs1']");
+    // Observable effect: a .glyph-hotspot element appears in the hotspot layer.
+    const hsEl = sceneEl.querySelector(".glyph-hotspot[data-hotspot-id='hs1']");
     expect(hsEl).toBeTruthy();
   });
 
@@ -94,15 +101,15 @@ describe("GlyphcssHotspotElement", () => {
     await Promise.resolve();
 
     let clickDetail: unknown = null;
-    // The event bubbles from the GlyphcssHotspotElement itself.
+    // The event bubbles from the GlyphHotspotElement itself.
     hotspot.addEventListener("glyphcss:hotspot-click", (e) => {
       clickDetail = (e as CustomEvent).detail;
     });
 
     // The click handler is attached to the overlay div in the hotspot layer,
-    // which calls this.dispatchEvent on the GlyphcssHotspotElement.
+    // which calls this.dispatchEvent on the GlyphHotspotElement.
     // We can simulate the click by finding the overlay and clicking it.
-    const overlayEl = sceneEl.querySelector(".glyphcss-hotspot[data-hotspot-id='hs-click']") as HTMLElement;
+    const overlayEl = sceneEl.querySelector(".glyph-hotspot[data-hotspot-id='hs-click']") as HTMLElement;
     expect(overlayEl).toBeTruthy();
     overlayEl.click();
 
@@ -122,6 +129,6 @@ describe("GlyphcssHotspotElement", () => {
     hotspot.setAttribute("at", "bad,values,here");
     expect(() => { sceneEl.appendChild(hotspot); }).not.toThrow();
     // No hotspot overlay should appear.
-    expect(sceneEl.querySelectorAll(".glyphcss-hotspot").length).toBe(0);
+    expect(sceneEl.querySelectorAll(".glyph-hotspot").length).toBe(0);
   });
 });
diff --git a/packages/glyphcss/src/elements/GlyphcssHotspotElement.ts b/packages/glyphcss/src/elements/GlyphHotspotElement.ts
similarity index 50%
rename from packages/glyphcss/src/elements/GlyphcssHotspotElement.ts
rename to packages/glyphcss/src/elements/GlyphHotspotElement.ts
index 299e6383..032df028 100644
--- a/packages/glyphcss/src/elements/GlyphcssHotspotElement.ts
+++ b/packages/glyphcss/src/elements/GlyphHotspotElement.ts
@@ -1,11 +1,11 @@
 /**
- * `` — declarative hit anchor.
- * Walks up to the parent `` and registers itself as a hotspot.
+ * `` — declarative hit anchor.
+ * Walks up to the parent `` and registers itself as a hotspot.
  * Normal DOM events (click, hover, focus) fire on the projected overlay element.
  */
 import type { Vec3 } from "@glyphcss/core";
-import type { GlyphcssHotspotHandle } from "../api/createGlyphcssScene";
-import type { GlyphcssSceneElement } from "./GlyphcssSceneElement";
+import type { GlyphHotspotHandle } from "../api/createGlyphScene";
+import type { GlyphSceneElement } from "./GlyphSceneElement";
 
 const ELEMENT_BASE: typeof HTMLElement =
   typeof HTMLElement !== "undefined"
@@ -26,17 +26,17 @@ function parseSize(value: string | null): [number, number] | undefined {
   return [parts[0]!, parts[1]!];
 }
 
-function findScene(el: HTMLElement): GlyphcssSceneElement | null {
-  const found = el.closest("glyphcss-scene") as unknown as (GlyphcssSceneElement & { getScene?: () => unknown }) | null;
+function findScene(el: HTMLElement): GlyphSceneElement | null {
+  const found = el.closest("glyph-scene") as unknown as (GlyphSceneElement & { getScene?: () => unknown }) | null;
   return found ?? null;
 }
 
-export class GlyphcssHotspotElement extends ELEMENT_BASE {
+export class GlyphHotspotElement extends ELEMENT_BASE {
   static get observedAttributes(): string[] {
     return ["at", "size", "hotspot-id"];
   }
 
-  private _handle: GlyphcssHotspotHandle | null = null;
+  private _handle: GlyphHotspotHandle | null = null;
 
   connectedCallback(): void {
     this._register();
@@ -44,8 +44,7 @@ export class GlyphcssHotspotElement extends ELEMENT_BASE {
 
   disconnectedCallback(): void {
     if (this._handle) {
-      this._handle.remove();
-      this._handle = null;
+      this._unregister();
     }
   }
 
@@ -56,17 +55,38 @@ export class GlyphcssHotspotElement extends ELEMENT_BASE {
   ): void {
     if (oldValue === newValue) return;
     if (this._handle) {
-      this._handle.remove();
-      this._handle = null;
+      this._unregister();
     }
     this._register();
   }
 
+  private _unregister(): void {
+    if (!this._handle) return;
+    // Return children from the overlay div back to this element before
+    // removing the handle so they survive re-registration on attribute changes.
+    const overlayEl = this._handle.el;
+    while (overlayEl.firstChild) {
+      this.appendChild(overlayEl.firstChild);
+    }
+    this._handle.remove();
+    this._handle = null;
+  }
+
   private _register(): void {
     const at = parseVec3(this.getAttribute("at"));
     if (!at) return;
     const sceneEl = findScene(this);
-    const scene = sceneEl?.getScene();
+    if (!sceneEl) return;
+    // If the scene handle isn't ready yet wait for it.
+    if (!sceneEl.getScene()) {
+      const onReady = (): void => {
+        sceneEl.removeEventListener("glyphcss:scene-ready", onReady);
+        this._register();
+      };
+      sceneEl.addEventListener("glyphcss:scene-ready", onReady);
+      return;
+    }
+    const scene = sceneEl.getScene();
     if (!scene) return;
 
     const id = this.getAttribute("hotspot-id") ?? this.getAttribute("id") ?? String(Math.random());
@@ -76,5 +96,14 @@ export class GlyphcssHotspotElement extends ELEMENT_BASE {
       { id, at, size },
       () => this.dispatchEvent(new CustomEvent("glyphcss:hotspot-click", { detail: { id }, bubbles: true })),
     );
+
+    // Move child nodes into the overlay div so they are positioned over the
+    // rendered 
 at the projected anchor point. The  element
+    // itself stays in the document but becomes invisible; its children live in
+    // the hotspot-layer overlay where they track the 3D anchor.
+    const overlayEl = this._handle.el;
+    while (this.firstChild) {
+      overlayEl.appendChild(this.firstChild);
+    }
   }
 }
diff --git a/packages/glyphcss/src/elements/GlyphMapControlsElement.test.ts b/packages/glyphcss/src/elements/GlyphMapControlsElement.test.ts
new file mode 100644
index 00000000..ee4790c8
--- /dev/null
+++ b/packages/glyphcss/src/elements/GlyphMapControlsElement.test.ts
@@ -0,0 +1,98 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { GlyphSceneElement } from "./GlyphSceneElement";
+import { GlyphMapControlsElement } from "./GlyphMapControlsElement";
+import { GlyphPerspectiveCameraElement } from "./GlyphPerspectiveCameraElement";
+
+if (!customElements.get("glyph-scene")) {
+  customElements.define("glyph-scene", GlyphSceneElement);
+}
+if (!customElements.get("glyph-map-controls")) {
+  customElements.define("glyph-map-controls", GlyphMapControlsElement);
+}
+if (!customElements.get("glyph-perspective-camera")) {
+  customElements.define("glyph-perspective-camera", GlyphPerspectiveCameraElement);
+}
+
+describe("GlyphMapControlsElement", () => {
+  let camEl: GlyphPerspectiveCameraElement;
+  let sceneEl: GlyphSceneElement;
+  let controls: GlyphMapControlsElement;
+
+  beforeEach(() => {
+    camEl = document.createElement("glyph-perspective-camera") as GlyphPerspectiveCameraElement;
+    sceneEl = document.createElement("glyph-scene") as GlyphSceneElement;
+    sceneEl.setAttribute("cols", "20");
+    sceneEl.setAttribute("rows", "5");
+    camEl.appendChild(sceneEl);
+    document.body.appendChild(camEl);
+
+    controls = document.createElement("glyph-map-controls") as GlyphMapControlsElement;
+  });
+
+  afterEach(() => {
+    if (controls.isConnected) controls.remove();
+    if (camEl.isConnected) camEl.remove();
+  });
+
+  it("is registered under the 'glyph-map-controls' tag", () => {
+    expect(customElements.get("glyph-map-controls")).toBe(GlyphMapControlsElement);
+  });
+
+  it("createElement produces a GlyphMapControlsElement instance", () => {
+    expect(controls).toBeInstanceOf(GlyphMapControlsElement);
+  });
+
+  it("observes drag, wheel, invert attributes", () => {
+    expect(GlyphMapControlsElement.observedAttributes).toContain("drag");
+    expect(GlyphMapControlsElement.observedAttributes).toContain("wheel");
+    expect(GlyphMapControlsElement.observedAttributes).toContain("invert");
+  });
+
+  it("connects without throwing inside a scene", () => {
+    expect(() => { sceneEl.appendChild(controls); }).not.toThrow();
+  });
+
+  it("connects without throwing outside a scene", () => {
+    expect(() => { document.body.appendChild(controls); }).not.toThrow();
+    controls.remove();
+  });
+
+  it("attaches grab cursor style to scene host on connect", () => {
+    sceneEl.appendChild(controls);
+    expect(sceneEl.style.cursor).toBe("grab");
+  });
+
+  it("drag=false omits grab cursor", () => {
+    controls.setAttribute("drag", "false");
+    sceneEl.appendChild(controls);
+    expect(sceneEl.style.cursor).toBe("");
+  });
+
+  it("disconnect cleans up cursor on scene host", () => {
+    sceneEl.appendChild(controls);
+    expect(sceneEl.style.cursor).toBe("grab");
+    controls.remove();
+    expect(sceneEl.style.cursor).toBe("");
+  });
+
+  it("attribute change updates controls without throwing", () => {
+    sceneEl.appendChild(controls);
+    expect(() => { controls.setAttribute("wheel", "false"); }).not.toThrow();
+  });
+
+  it("waits for glyphcss:scene-ready when connected before scene is ready", () => {
+    const freshCam = document.createElement("glyph-perspective-camera") as GlyphPerspectiveCameraElement;
+    const freshScene = document.createElement("glyph-scene") as GlyphSceneElement;
+    freshScene.setAttribute("cols", "10");
+    freshScene.setAttribute("rows", "5");
+    freshCam.appendChild(freshScene);
+    freshScene.appendChild(controls);
+    expect(() => { document.body.appendChild(freshCam); }).not.toThrow();
+    freshCam.remove();
+  });
+
+  it("invert=true attribute is forwarded without error", () => {
+    controls.setAttribute("invert", "true");
+    expect(() => { sceneEl.appendChild(controls); }).not.toThrow();
+  });
+});
diff --git a/packages/glyphcss/src/elements/GlyphcssMapControlsElement.ts b/packages/glyphcss/src/elements/GlyphMapControlsElement.ts
similarity index 71%
rename from packages/glyphcss/src/elements/GlyphcssMapControlsElement.ts
rename to packages/glyphcss/src/elements/GlyphMapControlsElement.ts
index 0ef5805a..8d4a2d50 100644
--- a/packages/glyphcss/src/elements/GlyphcssMapControlsElement.ts
+++ b/packages/glyphcss/src/elements/GlyphMapControlsElement.ts
@@ -1,8 +1,8 @@
 /**
- * `` — declarative map/pan controls.
+ * `` — declarative map/pan controls.
  */
-import { createGlyphcssMapControls, type GlyphcssMapControlsHandle } from "../api/createGlyphcssMapControls";
-import type { GlyphcssSceneElement } from "./GlyphcssSceneElement";
+import { createGlyphMapControls, type GlyphMapControlsHandle } from "../api/createGlyphMapControls";
+import type { GlyphSceneElement } from "./GlyphSceneElement";
 
 const ELEMENT_BASE: typeof HTMLElement =
   typeof HTMLElement !== "undefined"
@@ -16,17 +16,17 @@ function parseBool(value: string | null): boolean | undefined {
   return undefined;
 }
 
-function findScene(el: HTMLElement): GlyphcssSceneElement | null {
-  const found = el.closest("glyphcss-scene") as unknown as (GlyphcssSceneElement & { getScene?: () => unknown }) | null;
+function findScene(el: HTMLElement): GlyphSceneElement | null {
+  const found = el.closest("glyph-scene") as unknown as (GlyphSceneElement & { getScene?: () => unknown }) | null;
   return found ?? null;
 }
 
-export class GlyphcssMapControlsElement extends ELEMENT_BASE {
+export class GlyphMapControlsElement extends ELEMENT_BASE {
   static get observedAttributes(): string[] {
     return ["drag", "wheel", "invert"];
   }
 
-  private _controls: GlyphcssMapControlsHandle | null = null;
+  private _controls: GlyphMapControlsHandle | null = null;
 
   connectedCallback(): void { this._attach(); }
 
@@ -63,6 +63,6 @@ export class GlyphcssMapControlsElement extends ELEMENT_BASE {
       sceneEl.addEventListener("glyphcss:scene-ready", onReady);
       return;
     }
-    this._controls = createGlyphcssMapControls(handle, this._readOptions());
+    this._controls = createGlyphMapControls(handle, this._readOptions());
   }
 }
diff --git a/packages/glyphcss/src/elements/GlyphcssMeshElement.test.ts b/packages/glyphcss/src/elements/GlyphMeshElement.test.ts
similarity index 67%
rename from packages/glyphcss/src/elements/GlyphcssMeshElement.test.ts
rename to packages/glyphcss/src/elements/GlyphMeshElement.test.ts
index b3724a57..72ff0a84 100644
--- a/packages/glyphcss/src/elements/GlyphcssMeshElement.test.ts
+++ b/packages/glyphcss/src/elements/GlyphMeshElement.test.ts
@@ -1,45 +1,52 @@
 import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
-import { GlyphcssSceneElement } from "./GlyphcssSceneElement";
-import { GlyphcssMeshElement } from "./GlyphcssMeshElement";
+import { GlyphSceneElement } from "./GlyphSceneElement";
+import { GlyphMeshElement } from "./GlyphMeshElement";
+import { GlyphPerspectiveCameraElement } from "./GlyphPerspectiveCameraElement";
 
 // Register elements if not already.
-if (!customElements.get("glyphcss-scene")) {
-  customElements.define("glyphcss-scene", GlyphcssSceneElement);
+if (!customElements.get("glyph-scene")) {
+  customElements.define("glyph-scene", GlyphSceneElement);
 }
-if (!customElements.get("glyphcss-mesh")) {
-  customElements.define("glyphcss-mesh", GlyphcssMeshElement);
+if (!customElements.get("glyph-mesh")) {
+  customElements.define("glyph-mesh", GlyphMeshElement);
+}
+if (!customElements.get("glyph-perspective-camera")) {
+  customElements.define("glyph-perspective-camera", GlyphPerspectiveCameraElement);
 }
 
-describe("GlyphcssMeshElement", () => {
-  let scene: GlyphcssSceneElement;
-  let mesh: GlyphcssMeshElement;
+describe("GlyphMeshElement", () => {
+  let camEl: GlyphPerspectiveCameraElement;
+  let scene: GlyphSceneElement;
+  let mesh: GlyphMeshElement;
 
   beforeEach(() => {
-    scene = document.createElement("glyphcss-scene") as GlyphcssSceneElement;
+    camEl = document.createElement("glyph-perspective-camera") as GlyphPerspectiveCameraElement;
+    scene = document.createElement("glyph-scene") as GlyphSceneElement;
     scene.setAttribute("cols", "20");
     scene.setAttribute("rows", "5");
-    document.body.appendChild(scene);
+    camEl.appendChild(scene);
+    document.body.appendChild(camEl);
 
-    mesh = document.createElement("glyphcss-mesh") as GlyphcssMeshElement;
+    mesh = document.createElement("glyph-mesh") as GlyphMeshElement;
   });
 
   afterEach(() => {
-    if (scene.isConnected) scene.remove();
+    if (camEl.isConnected) camEl.remove();
   });
 
-  it("is registered under the 'glyphcss-mesh' tag", () => {
-    expect(customElements.get("glyphcss-mesh")).toBe(GlyphcssMeshElement);
+  it("is registered under the 'glyph-mesh' tag", () => {
+    expect(customElements.get("glyph-mesh")).toBe(GlyphMeshElement);
   });
 
-  it("createElement produces a GlyphcssMeshElement instance", () => {
-    expect(mesh).toBeInstanceOf(GlyphcssMeshElement);
+  it("createElement produces a GlyphMeshElement instance", () => {
+    expect(mesh).toBeInstanceOf(GlyphMeshElement);
   });
 
   it("observes src, position, scale, rotation attributes", () => {
-    expect(GlyphcssMeshElement.observedAttributes).toContain("src");
-    expect(GlyphcssMeshElement.observedAttributes).toContain("position");
-    expect(GlyphcssMeshElement.observedAttributes).toContain("scale");
-    expect(GlyphcssMeshElement.observedAttributes).toContain("rotation");
+    expect(GlyphMeshElement.observedAttributes).toContain("src");
+    expect(GlyphMeshElement.observedAttributes).toContain("position");
+    expect(GlyphMeshElement.observedAttributes).toContain("scale");
+    expect(GlyphMeshElement.observedAttributes).toContain("rotation");
   });
 
   it("getMeshHandle() returns null before connect", () => {
diff --git a/packages/glyphcss/src/elements/GlyphMeshElement.ts b/packages/glyphcss/src/elements/GlyphMeshElement.ts
new file mode 100644
index 00000000..f406a7c0
--- /dev/null
+++ b/packages/glyphcss/src/elements/GlyphMeshElement.ts
@@ -0,0 +1,188 @@
+/**
+ * `` / `` custom element.
+ *
+ * When `src` is set, fetches the mesh via `loadMesh`. When `geometry` is set
+ * and `src` is NOT set, resolves the named built-in polygon factory via
+ * `resolveGeometry`. If both are supplied, `src` wins silently.
+ *
+ * On disconnect: disposes the registered mesh handle.
+ */
+import { loadMesh, resolveGeometry, computeSceneBbox } from "@glyphcss/core";
+import type { Vec3, GlyphGeometryName, Polygon } from "@glyphcss/core";
+import type { GlyphMeshHandle, GlyphSceneHandle } from "../api/createGlyphScene";
+import type { GlyphMeshTransform } from "../api/types";
+import type { GlyphSceneElement } from "./GlyphSceneElement";
+
+const ELEMENT_BASE: typeof HTMLElement =
+  typeof HTMLElement !== "undefined"
+    ? HTMLElement
+    : (class {} as unknown as typeof HTMLElement);
+
+const OBSERVED_ATTRS = ["src", "geometry", "size", "color", "position", "scale", "rotation", "normalize"] as const;
+
+/** Center and scale polygons to fit a 2-unit bounding box at origin. */
+function fitToUnitBbox(polygons: Polygon[]): Polygon[] {
+  const bbox = computeSceneBbox(polygons);
+  const cx = (bbox.min[0] + bbox.max[0]) / 2;
+  const cy = (bbox.min[1] + bbox.max[1]) / 2;
+  const cz = (bbox.min[2] + bbox.max[2]) / 2;
+  const size = Math.max(
+    bbox.max[0] - bbox.min[0],
+    bbox.max[1] - bbox.min[1],
+    bbox.max[2] - bbox.min[2],
+  ) || 1;
+  const k = 2 / size;
+  return polygons.map((p) => ({
+    ...p,
+    vertices: p.vertices.map((v): Vec3 => [
+      (v[0] - cx) * k,
+      (v[1] - cy) * k,
+      (v[2] - cz) * k,
+    ]),
+  }));
+}
+
+function parseVec3(value: string | null): Vec3 | undefined {
+  if (!value) return undefined;
+  const parts = value.split(",").map((p) => parseFloat(p.trim()));
+  if (parts.length !== 3 || parts.some((p) => !Number.isFinite(p))) return undefined;
+  return [parts[0]!, parts[1]!, parts[2]!];
+}
+
+function parseScale(value: string | null): number | Vec3 | undefined {
+  if (!value) return undefined;
+  if (!value.includes(",")) {
+    const n = parseFloat(value);
+    return Number.isFinite(n) ? n : undefined;
+  }
+  return parseVec3(value);
+}
+
+function findScene(el: HTMLElement): GlyphSceneElement | null {
+  const found = el.closest("glyph-scene") as unknown as (GlyphSceneElement & { getScene?: () => unknown }) | null;
+  return found ?? null;
+}
+
+
+export class GlyphMeshElement extends ELEMENT_BASE {
+  static get observedAttributes(): string[] {
+    return [...OBSERVED_ATTRS];
+  }
+
+  private _handle: GlyphMeshHandle | null = null;
+  private _loadToken = 0;
+
+  getMeshHandle(): GlyphMeshHandle | null {
+    return this._handle;
+  }
+
+  connectedCallback(): void {
+    this._maybeLoad();
+  }
+
+  disconnectedCallback(): void {
+    this._tearDown();
+  }
+
+  attributeChangedCallback(
+    name: string,
+    oldValue: string | null,
+    newValue: string | null,
+  ): void {
+    if (oldValue === newValue) return;
+    if (name === "src" || name === "geometry" || name === "size" || name === "color") {
+      this._tearDown();
+      this._maybeLoad();
+      return;
+    }
+    if (!this._handle) return;
+    this._handle.setTransform(this._readTransform());
+  }
+
+  private _readTransform(): GlyphMeshTransform {
+    return {
+      position: parseVec3(this.getAttribute("position")),
+      scale: parseScale(this.getAttribute("scale")),
+      rotation: parseVec3(this.getAttribute("rotation")),
+    };
+  }
+
+  private _tearDown(): void {
+    this._loadToken += 1;
+    if (this._handle) {
+      try { this._handle.dispose(); } catch { /* ignore */ }
+      this._handle = null;
+    }
+  }
+
+  private async _maybeLoad(): Promise {
+    const src = this.getAttribute("src");
+    const geometryAttr = this.getAttribute("geometry");
+    const sceneEl = findScene(this);
+    if (!sceneEl) return;
+
+    // If the scene handle isn't ready yet (e.g. the camera custom element
+    // upgrades after this element does), wait for it.
+    if (!sceneEl.getScene()) {
+      const onReady = (): void => {
+        sceneEl.removeEventListener("glyphcss:scene-ready", onReady);
+        void this._maybeLoad();
+      };
+      sceneEl.addEventListener("glyphcss:scene-ready", onReady);
+      return;
+    }
+
+    if (src) {
+      // src wins over geometry
+      const token = ++this._loadToken;
+
+      let parsed: Awaited>;
+      try {
+        parsed = await loadMesh(src);
+      } catch (err) {
+        this.dispatchEvent(new CustomEvent("glyphcss:error", { detail: err, bubbles: true }));
+        return;
+      }
+
+      if (token !== this._loadToken) {
+        try { parsed.dispose(); } catch { /* ignore */ }
+        return;
+      }
+
+      const scene: GlyphSceneHandle | null = sceneEl.getScene();
+      if (!scene) {
+        try { parsed.dispose(); } catch { /* ignore */ }
+        return;
+      }
+
+      const shouldNormalize = this.hasAttribute("normalize");
+      const polygons = shouldNormalize ? fitToUnitBbox(parsed.polygons) : parsed.polygons;
+      this._handle = scene.add(polygons, this._readTransform());
+      this.dispatchEvent(new CustomEvent("glyphcss:loaded", { detail: { polygons }, bubbles: true }));
+      return;
+    }
+
+    if (geometryAttr) {
+      const scene: GlyphSceneHandle | null = sceneEl.getScene();
+      if (!scene) return;
+
+      const sizeAttr = this.getAttribute("size");
+      const size = sizeAttr !== null ? parseFloat(sizeAttr) : 1;
+      const colorAttr = this.getAttribute("color") ?? undefined;
+
+      let polygons;
+      try {
+        polygons = resolveGeometry(geometryAttr as GlyphGeometryName, {
+          size: Number.isFinite(size) ? size : 1,
+          color: colorAttr,
+        });
+      } catch (err) {
+        this.dispatchEvent(new CustomEvent("glyphcss:error", { detail: err, bubbles: true }));
+        return;
+      }
+
+      this._handle = scene.add(polygons, this._readTransform());
+      this.dispatchEvent(new CustomEvent("glyphcss:loaded", { detail: { polygons }, bubbles: true }));
+    }
+  }
+}
diff --git a/packages/glyphcss/src/elements/GlyphOrbitControlsElement.test.ts b/packages/glyphcss/src/elements/GlyphOrbitControlsElement.test.ts
new file mode 100644
index 00000000..22416a5d
--- /dev/null
+++ b/packages/glyphcss/src/elements/GlyphOrbitControlsElement.test.ts
@@ -0,0 +1,99 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { GlyphSceneElement } from "./GlyphSceneElement";
+import { GlyphOrbitControlsElement } from "./GlyphOrbitControlsElement";
+import { GlyphPerspectiveCameraElement } from "./GlyphPerspectiveCameraElement";
+
+if (!customElements.get("glyph-scene")) {
+  customElements.define("glyph-scene", GlyphSceneElement);
+}
+if (!customElements.get("glyph-orbit-controls")) {
+  customElements.define("glyph-orbit-controls", GlyphOrbitControlsElement);
+}
+if (!customElements.get("glyph-perspective-camera")) {
+  customElements.define("glyph-perspective-camera", GlyphPerspectiveCameraElement);
+}
+
+describe("GlyphOrbitControlsElement", () => {
+  let camEl: GlyphPerspectiveCameraElement;
+  let sceneEl: GlyphSceneElement;
+  let controls: GlyphOrbitControlsElement;
+
+  beforeEach(() => {
+    camEl = document.createElement("glyph-perspective-camera") as GlyphPerspectiveCameraElement;
+    sceneEl = document.createElement("glyph-scene") as GlyphSceneElement;
+    sceneEl.setAttribute("cols", "20");
+    sceneEl.setAttribute("rows", "5");
+    camEl.appendChild(sceneEl);
+    document.body.appendChild(camEl);
+
+    controls = document.createElement("glyph-orbit-controls") as GlyphOrbitControlsElement;
+  });
+
+  afterEach(() => {
+    if (controls.isConnected) controls.remove();
+    if (camEl.isConnected) camEl.remove();
+  });
+
+  it("is registered under the 'glyph-orbit-controls' tag", () => {
+    expect(customElements.get("glyph-orbit-controls")).toBe(GlyphOrbitControlsElement);
+  });
+
+  it("createElement produces a GlyphOrbitControlsElement instance", () => {
+    expect(controls).toBeInstanceOf(GlyphOrbitControlsElement);
+  });
+
+  it("observes drag, wheel, invert, animate-speed, animate-axis attributes", () => {
+    expect(GlyphOrbitControlsElement.observedAttributes).toContain("drag");
+    expect(GlyphOrbitControlsElement.observedAttributes).toContain("wheel");
+    expect(GlyphOrbitControlsElement.observedAttributes).toContain("invert");
+    expect(GlyphOrbitControlsElement.observedAttributes).toContain("animate-speed");
+    expect(GlyphOrbitControlsElement.observedAttributes).toContain("animate-axis");
+  });
+
+  it("connects without throwing inside a scene", () => {
+    expect(() => { sceneEl.appendChild(controls); }).not.toThrow();
+  });
+
+  it("connects without throwing outside a scene (no scene parent)", () => {
+    expect(() => { document.body.appendChild(controls); }).not.toThrow();
+    controls.remove();
+  });
+
+  it("attaches grab cursor style to scene host on connect", () => {
+    sceneEl.appendChild(controls);
+    // createGlyphOrbitControls sets cursor:'grab' on the host when drag is enabled.
+    expect(sceneEl.style.cursor).toBe("grab");
+  });
+
+  it("drag=false removes grab cursor", () => {
+    controls.setAttribute("drag", "false");
+    sceneEl.appendChild(controls);
+    expect(sceneEl.style.cursor).toBe("");
+  });
+
+  it("disconnect cleans up cursor style on scene host", () => {
+    sceneEl.appendChild(controls);
+    expect(sceneEl.style.cursor).toBe("grab");
+    controls.remove();
+    expect(sceneEl.style.cursor).toBe("");
+  });
+
+  it("attribute change updates controls without throwing", () => {
+    sceneEl.appendChild(controls);
+    expect(() => { controls.setAttribute("invert", "true"); }).not.toThrow();
+  });
+
+  it("waits for glyphcss:scene-ready when attached before scene is ready", () => {
+    // Create a fresh camera+scene tree (not yet connected) and insert controls first.
+    const freshCam = document.createElement("glyph-perspective-camera") as GlyphPerspectiveCameraElement;
+    const freshScene = document.createElement("glyph-scene") as GlyphSceneElement;
+    freshScene.setAttribute("cols", "10");
+    freshScene.setAttribute("rows", "5");
+    freshCam.appendChild(freshScene);
+    // Append controls into scene before camera+scene is connected — scene not ready yet.
+    freshScene.appendChild(controls);
+    // Now connect camera — triggers camera-ready then scene-ready.
+    expect(() => { document.body.appendChild(freshCam); }).not.toThrow();
+    freshCam.remove();
+  });
+});
diff --git a/packages/glyphcss/src/elements/GlyphcssOrbitControlsElement.ts b/packages/glyphcss/src/elements/GlyphOrbitControlsElement.ts
similarity index 76%
rename from packages/glyphcss/src/elements/GlyphcssOrbitControlsElement.ts
rename to packages/glyphcss/src/elements/GlyphOrbitControlsElement.ts
index 4a84e5fe..43c93084 100644
--- a/packages/glyphcss/src/elements/GlyphcssOrbitControlsElement.ts
+++ b/packages/glyphcss/src/elements/GlyphOrbitControlsElement.ts
@@ -1,8 +1,8 @@
 /**
- * `` — declarative orbit controls.
+ * `` — declarative orbit controls.
  */
-import { createGlyphcssOrbitControls, type GlyphcssOrbitControlsHandle } from "../api/createGlyphcssOrbitControls";
-import type { GlyphcssSceneElement } from "./GlyphcssSceneElement";
+import { createGlyphOrbitControls, type GlyphOrbitControlsHandle } from "../api/createGlyphOrbitControls";
+import type { GlyphSceneElement } from "./GlyphSceneElement";
 
 const ELEMENT_BASE: typeof HTMLElement =
   typeof HTMLElement !== "undefined"
@@ -22,17 +22,17 @@ function parseBool(value: string | null): boolean | undefined {
   return undefined;
 }
 
-function findScene(el: HTMLElement): GlyphcssSceneElement | null {
-  const found = el.closest("glyphcss-scene") as unknown as (GlyphcssSceneElement & { getScene?: () => unknown }) | null;
+function findScene(el: HTMLElement): GlyphSceneElement | null {
+  const found = el.closest("glyph-scene") as unknown as (GlyphSceneElement & { getScene?: () => unknown }) | null;
   return found ?? null;
 }
 
-export class GlyphcssOrbitControlsElement extends ELEMENT_BASE {
+export class GlyphOrbitControlsElement extends ELEMENT_BASE {
   static get observedAttributes(): string[] {
     return ["drag", "wheel", "invert", "animate-speed", "animate-axis"];
   }
 
-  private _controls: GlyphcssOrbitControlsHandle | null = null;
+  private _controls: GlyphOrbitControlsHandle | null = null;
 
   connectedCallback(): void { this._attach(); }
 
@@ -72,6 +72,6 @@ export class GlyphcssOrbitControlsElement extends ELEMENT_BASE {
       sceneEl.addEventListener("glyphcss:scene-ready", onReady);
       return;
     }
-    this._controls = createGlyphcssOrbitControls(handle, this._readOptions());
+    this._controls = createGlyphOrbitControls(handle, this._readOptions());
   }
 }
diff --git a/packages/glyphcss/src/elements/GlyphOrthographicCameraElement.test.ts b/packages/glyphcss/src/elements/GlyphOrthographicCameraElement.test.ts
new file mode 100644
index 00000000..55a98c13
--- /dev/null
+++ b/packages/glyphcss/src/elements/GlyphOrthographicCameraElement.test.ts
@@ -0,0 +1,106 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { GlyphSceneElement } from "./GlyphSceneElement";
+import { GlyphOrthographicCameraElement } from "./GlyphOrthographicCameraElement";
+
+if (!customElements.get("glyph-scene")) {
+  customElements.define("glyph-scene", GlyphSceneElement);
+}
+if (!customElements.get("glyph-orthographic-camera")) {
+  customElements.define("glyph-orthographic-camera", GlyphOrthographicCameraElement);
+}
+
+describe("GlyphOrthographicCameraElement", () => {
+  let camEl: GlyphOrthographicCameraElement;
+  let sceneEl: GlyphSceneElement;
+
+  beforeEach(() => {
+    camEl = document.createElement("glyph-orthographic-camera") as GlyphOrthographicCameraElement;
+    sceneEl = document.createElement("glyph-scene") as GlyphSceneElement;
+    sceneEl.setAttribute("cols", "20");
+    sceneEl.setAttribute("rows", "5");
+    camEl.appendChild(sceneEl);
+  });
+
+  afterEach(() => {
+    if (camEl.isConnected) camEl.remove();
+  });
+
+  it("is registered under the 'glyph-orthographic-camera' tag", () => {
+    expect(customElements.get("glyph-orthographic-camera")).toBe(GlyphOrthographicCameraElement);
+  });
+
+  it("createElement produces a GlyphOrthographicCameraElement instance", () => {
+    expect(camEl).toBeInstanceOf(GlyphOrthographicCameraElement);
+  });
+
+  it("observes rot-x, rot-y, zoom attributes", () => {
+    expect(GlyphOrthographicCameraElement.observedAttributes).toContain("rot-x");
+    expect(GlyphOrthographicCameraElement.observedAttributes).toContain("rot-y");
+    expect(GlyphOrthographicCameraElement.observedAttributes).toContain("zoom");
+  });
+
+  it("getCamera() returns null before connect", () => {
+    expect(camEl.getCamera()).toBeNull();
+  });
+
+  it("connects without throwing", () => {
+    expect(() => { document.body.appendChild(camEl); }).not.toThrow();
+  });
+
+  it("getCamera() is non-null after connect", () => {
+    document.body.appendChild(camEl);
+    expect(camEl.getCamera()).not.toBeNull();
+  });
+
+  it("dispatches glyph:camera-ready on connect", () => {
+    let fired = false;
+    camEl.addEventListener("glyph:camera-ready", () => { fired = true; });
+    document.body.appendChild(camEl);
+    expect(fired).toBe(true);
+  });
+
+  it("scene is created with orthographic camera", () => {
+    document.body.appendChild(camEl);
+    expect(sceneEl.getScene()!.camera.kind).toBe("orthographic");
+  });
+
+  it("applies rot-x attribute to camera", () => {
+    camEl.setAttribute("rot-x", "0.4");
+    document.body.appendChild(camEl);
+    expect(camEl.getCamera()!.rotX).toBeCloseTo(0.4, 5);
+  });
+
+  it("applies rot-y attribute to camera", () => {
+    camEl.setAttribute("rot-y", "0.9");
+    document.body.appendChild(camEl);
+    expect(camEl.getCamera()!.rotY).toBeCloseTo(0.9, 5);
+  });
+
+  it("applies zoom attribute to camera", () => {
+    camEl.setAttribute("zoom", "0.7");
+    document.body.appendChild(camEl);
+    expect(camEl.getCamera()!.zoom).toBeCloseTo(0.7, 5);
+  });
+
+  it("changing rot-y attribute updates camera", () => {
+    document.body.appendChild(camEl);
+    camEl.setAttribute("rot-y", "1.5");
+    expect(camEl.getCamera()!.rotY).toBeCloseTo(1.5, 5);
+  });
+
+  it("attribute change before connect is a no-op (no throw)", () => {
+    expect(() => { camEl.setAttribute("zoom", "2.0"); }).not.toThrow();
+  });
+
+  it("invalid zoom value is ignored gracefully", () => {
+    camEl.setAttribute("zoom", "bad");
+    expect(() => { document.body.appendChild(camEl); }).not.toThrow();
+  });
+
+  it("disconnects cleanly", () => {
+    document.body.appendChild(camEl);
+    expect(camEl.getCamera()).not.toBeNull();
+    camEl.remove();
+    expect(camEl.getCamera()).toBeNull();
+  });
+});
diff --git a/packages/glyphcss/src/elements/GlyphOrthographicCameraElement.ts b/packages/glyphcss/src/elements/GlyphOrthographicCameraElement.ts
new file mode 100644
index 00000000..247579b2
--- /dev/null
+++ b/packages/glyphcss/src/elements/GlyphOrthographicCameraElement.ts
@@ -0,0 +1,61 @@
+/**
+ * `` — outer host for an orthographic camera.
+ * Creates the camera handle on connectedCallback and dispatches
+ * `glyph:camera-ready` so descendant `` elements can adopt it.
+ * Child `` walks up the DOM to find this element.
+ */
+import { createGlyphOrthographicCamera } from "../api/createGlyphCamera";
+import type { GlyphCamera } from "../api/createGlyphCamera";
+
+const ELEMENT_BASE: typeof HTMLElement =
+  typeof HTMLElement !== "undefined"
+    ? HTMLElement
+    : (class {} as unknown as typeof HTMLElement);
+
+function parseNumber(value: string | null): number | undefined {
+  if (value == null) return undefined;
+  const n = parseFloat(value);
+  return Number.isFinite(n) ? n : undefined;
+}
+
+export class GlyphOrthographicCameraElement extends ELEMENT_BASE {
+  static get observedAttributes(): string[] {
+    return ["rot-x", "rot-y", "zoom"];
+  }
+
+  private _camera: GlyphCamera | null = null;
+
+  getCamera(): GlyphCamera | null {
+    return this._camera;
+  }
+
+  connectedCallback(): void {
+    this._camera = createGlyphOrthographicCamera({
+      rotX: parseNumber(this.getAttribute("rot-x")),
+      rotY: parseNumber(this.getAttribute("rot-y")),
+      zoom: parseNumber(this.getAttribute("zoom")),
+    });
+    this.dispatchEvent(new CustomEvent("glyph:camera-ready", { bubbles: false }));
+  }
+
+  disconnectedCallback(): void {
+    this._camera = null;
+  }
+
+  attributeChangedCallback(_name: string, old: string | null, next: string | null): void {
+    if (old === next) return;
+    const camera = this._camera;
+    if (!camera) return;
+    const rotX = parseNumber(this.getAttribute("rot-x"));
+    const rotY = parseNumber(this.getAttribute("rot-y"));
+    const zoom = parseNumber(this.getAttribute("zoom"));
+    let dirty = false;
+    if (rotX !== undefined && camera.rotX !== rotX) { camera.rotX = rotX; dirty = true; }
+    if (rotY !== undefined && camera.rotY !== rotY) { camera.rotY = rotY; dirty = true; }
+    if (zoom !== undefined && camera.zoom !== zoom) { camera.zoom = zoom; dirty = true; }
+    if (dirty) {
+      const sceneEl = this.querySelector("glyph-scene") as (HTMLElement & { rerender?: () => void }) | null;
+      sceneEl?.rerender?.();
+    }
+  }
+}
diff --git a/packages/glyphcss/src/elements/GlyphPerspectiveCameraElement.test.ts b/packages/glyphcss/src/elements/GlyphPerspectiveCameraElement.test.ts
new file mode 100644
index 00000000..a21c5465
--- /dev/null
+++ b/packages/glyphcss/src/elements/GlyphPerspectiveCameraElement.test.ts
@@ -0,0 +1,114 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { GlyphSceneElement } from "./GlyphSceneElement";
+import { GlyphPerspectiveCameraElement } from "./GlyphPerspectiveCameraElement";
+
+if (!customElements.get("glyph-scene")) {
+  customElements.define("glyph-scene", GlyphSceneElement);
+}
+if (!customElements.get("glyph-perspective-camera")) {
+  customElements.define("glyph-perspective-camera", GlyphPerspectiveCameraElement);
+}
+
+describe("GlyphPerspectiveCameraElement", () => {
+  let camEl: GlyphPerspectiveCameraElement;
+  let sceneEl: GlyphSceneElement;
+
+  beforeEach(() => {
+    camEl = document.createElement("glyph-perspective-camera") as GlyphPerspectiveCameraElement;
+    sceneEl = document.createElement("glyph-scene") as GlyphSceneElement;
+    sceneEl.setAttribute("cols", "20");
+    sceneEl.setAttribute("rows", "5");
+    camEl.appendChild(sceneEl);
+  });
+
+  afterEach(() => {
+    if (camEl.isConnected) camEl.remove();
+  });
+
+  it("is registered under the 'glyph-perspective-camera' tag", () => {
+    expect(customElements.get("glyph-perspective-camera")).toBe(GlyphPerspectiveCameraElement);
+  });
+
+  it("createElement produces a GlyphPerspectiveCameraElement instance", () => {
+    expect(camEl).toBeInstanceOf(GlyphPerspectiveCameraElement);
+  });
+
+  it("observes rot-x, rot-y, distance, zoom, stretch attributes", () => {
+    expect(GlyphPerspectiveCameraElement.observedAttributes).toContain("rot-x");
+    expect(GlyphPerspectiveCameraElement.observedAttributes).toContain("rot-y");
+    expect(GlyphPerspectiveCameraElement.observedAttributes).toContain("distance");
+    expect(GlyphPerspectiveCameraElement.observedAttributes).toContain("zoom");
+    expect(GlyphPerspectiveCameraElement.observedAttributes).toContain("stretch");
+  });
+
+  it("getCamera() returns null before connect", () => {
+    expect(camEl.getCamera()).toBeNull();
+  });
+
+  it("connects without throwing", () => {
+    expect(() => { document.body.appendChild(camEl); }).not.toThrow();
+  });
+
+  it("getCamera() is non-null after connect", () => {
+    document.body.appendChild(camEl);
+    expect(camEl.getCamera()).not.toBeNull();
+  });
+
+  it("dispatches glyph:camera-ready on connect", () => {
+    let fired = false;
+    camEl.addEventListener("glyph:camera-ready", () => { fired = true; });
+    document.body.appendChild(camEl);
+    expect(fired).toBe(true);
+  });
+
+  it("scene is created with correct camera kind", () => {
+    document.body.appendChild(camEl);
+    expect(sceneEl.getScene()!.camera.kind).toBe("perspective");
+  });
+
+  it("applies rot-x attribute to camera on connect", () => {
+    camEl.setAttribute("rot-x", "0.5");
+    document.body.appendChild(camEl);
+    expect(camEl.getCamera()!.rotX).toBeCloseTo(0.5, 5);
+  });
+
+  it("applies rot-y attribute to camera on connect", () => {
+    camEl.setAttribute("rot-y", "1.2");
+    document.body.appendChild(camEl);
+    expect(camEl.getCamera()!.rotY).toBeCloseTo(1.2, 5);
+  });
+
+  it("applies distance attribute to camera", () => {
+    camEl.setAttribute("distance", "5");
+    document.body.appendChild(camEl);
+    expect(camEl.getCamera()!.distance).toBeCloseTo(5, 5);
+  });
+
+  it("applies zoom attribute to camera", () => {
+    camEl.setAttribute("zoom", "0.6");
+    document.body.appendChild(camEl);
+    expect(camEl.getCamera()!.zoom).toBeCloseTo(0.6, 5);
+  });
+
+  it("changing rot-x attribute updates camera", () => {
+    document.body.appendChild(camEl);
+    camEl.setAttribute("rot-x", "0.3");
+    expect(camEl.getCamera()!.rotX).toBeCloseTo(0.3, 5);
+  });
+
+  it("attribute change before connect is a no-op (no throw)", () => {
+    expect(() => { camEl.setAttribute("rot-x", "1.0"); }).not.toThrow();
+  });
+
+  it("invalid numeric attribute (NaN) is ignored gracefully", () => {
+    camEl.setAttribute("rot-x", "not-a-number");
+    expect(() => { document.body.appendChild(camEl); }).not.toThrow();
+  });
+
+  it("disconnects cleanly", () => {
+    document.body.appendChild(camEl);
+    expect(camEl.getCamera()).not.toBeNull();
+    camEl.remove();
+    expect(camEl.getCamera()).toBeNull();
+  });
+});
diff --git a/packages/glyphcss/src/elements/GlyphPerspectiveCameraElement.ts b/packages/glyphcss/src/elements/GlyphPerspectiveCameraElement.ts
new file mode 100644
index 00000000..0c2fd0a4
--- /dev/null
+++ b/packages/glyphcss/src/elements/GlyphPerspectiveCameraElement.ts
@@ -0,0 +1,67 @@
+/**
+ * `` — outer host for a perspective camera.
+ * Creates the camera handle on connectedCallback and dispatches
+ * `glyph:camera-ready` so descendant `` elements can adopt it.
+ * Child `` walks up the DOM to find this element.
+ */
+import { createGlyphPerspectiveCamera } from "../api/createGlyphCamera";
+import type { GlyphCamera } from "../api/createGlyphCamera";
+
+const ELEMENT_BASE: typeof HTMLElement =
+  typeof HTMLElement !== "undefined"
+    ? HTMLElement
+    : (class {} as unknown as typeof HTMLElement);
+
+function parseNumber(value: string | null): number | undefined {
+  if (value == null) return undefined;
+  const n = parseFloat(value);
+  return Number.isFinite(n) ? n : undefined;
+}
+
+export class GlyphPerspectiveCameraElement extends ELEMENT_BASE {
+  static get observedAttributes(): string[] {
+    return ["rot-x", "rot-y", "distance", "zoom", "stretch"];
+  }
+
+  private _camera: GlyphCamera | null = null;
+
+  getCamera(): GlyphCamera | null {
+    return this._camera;
+  }
+
+  connectedCallback(): void {
+    this._camera = createGlyphPerspectiveCamera({
+      rotX: parseNumber(this.getAttribute("rot-x")),
+      rotY: parseNumber(this.getAttribute("rot-y")),
+      distance: parseNumber(this.getAttribute("distance")),
+      zoom: parseNumber(this.getAttribute("zoom")),
+      stretch: parseNumber(this.getAttribute("stretch")),
+    });
+    this.dispatchEvent(new CustomEvent("glyph:camera-ready", { bubbles: false }));
+  }
+
+  disconnectedCallback(): void {
+    this._camera = null;
+  }
+
+  attributeChangedCallback(_name: string, old: string | null, next: string | null): void {
+    if (old === next) return;
+    const camera = this._camera;
+    if (!camera) return;
+    const rotX = parseNumber(this.getAttribute("rot-x"));
+    const rotY = parseNumber(this.getAttribute("rot-y"));
+    const distance = parseNumber(this.getAttribute("distance"));
+    const zoom = parseNumber(this.getAttribute("zoom"));
+    const stretch = parseNumber(this.getAttribute("stretch"));
+    let dirty = false;
+    if (rotX !== undefined && camera.rotX !== rotX) { camera.rotX = rotX; dirty = true; }
+    if (rotY !== undefined && camera.rotY !== rotY) { camera.rotY = rotY; dirty = true; }
+    if (distance !== undefined && camera.distance !== distance) { camera.distance = distance; dirty = true; }
+    if (zoom !== undefined && camera.zoom !== zoom) { camera.zoom = zoom; dirty = true; }
+    if (stretch !== undefined && camera.stretch !== stretch) { camera.stretch = stretch; dirty = true; }
+    if (dirty) {
+      const sceneEl = this.querySelector("glyph-scene") as (HTMLElement & { rerender?: () => void }) | null;
+      sceneEl?.rerender?.();
+    }
+  }
+}
diff --git a/packages/glyphcss/src/elements/GlyphSceneElement.test.ts b/packages/glyphcss/src/elements/GlyphSceneElement.test.ts
new file mode 100644
index 00000000..fef30ef2
--- /dev/null
+++ b/packages/glyphcss/src/elements/GlyphSceneElement.test.ts
@@ -0,0 +1,146 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { GlyphSceneElement } from "./GlyphSceneElement";
+import { GlyphPerspectiveCameraElement } from "./GlyphPerspectiveCameraElement";
+
+// Register elements if not already registered.
+if (!customElements.get("glyph-scene")) {
+  customElements.define("glyph-scene", GlyphSceneElement);
+}
+if (!customElements.get("glyph-perspective-camera")) {
+  customElements.define("glyph-perspective-camera", GlyphPerspectiveCameraElement);
+}
+
+describe("GlyphSceneElement", () => {
+  let camEl: GlyphPerspectiveCameraElement;
+  let host: GlyphSceneElement;
+
+  beforeEach(() => {
+    camEl = document.createElement("glyph-perspective-camera") as GlyphPerspectiveCameraElement;
+    host = document.createElement("glyph-scene") as GlyphSceneElement;
+    camEl.appendChild(host);
+  });
+
+  afterEach(() => {
+    if (camEl.isConnected) camEl.remove();
+  });
+
+  it("is registered under the 'glyph-scene' tag", () => {
+    expect(customElements.get("glyph-scene")).toBe(GlyphSceneElement);
+  });
+
+  it("createElement produces a GlyphSceneElement instance", () => {
+    expect(host).toBeInstanceOf(GlyphSceneElement);
+  });
+
+  it("observes the expected attributes", () => {
+    expect(GlyphSceneElement.observedAttributes).toContain("mode");
+    expect(GlyphSceneElement.observedAttributes).toContain("cols");
+    expect(GlyphSceneElement.observedAttributes).toContain("rows");
+    expect(GlyphSceneElement.observedAttributes).toContain("use-colors");
+    expect(GlyphSceneElement.observedAttributes).toContain("glyph-palette");
+    expect(GlyphSceneElement.observedAttributes).toContain("cell-aspect");
+    expect(GlyphSceneElement.observedAttributes).toContain("directional-intensity");
+    expect(GlyphSceneElement.observedAttributes).toContain("ambient-intensity");
+  });
+
+  it("getScene() returns null before connect", () => {
+    expect(host.getScene()).toBeNull();
+  });
+
+  it("appending camera to document emits .glyph-scene wrapper and 
 output", () => {
+    document.body.appendChild(camEl);
+    expect(host.querySelector(".glyph-scene")).toBeTruthy();
+    expect(host.querySelector("pre.glyph-output")).toBeTruthy();
+  });
+
+  it("getScene() is non-null after connect", () => {
+    document.body.appendChild(camEl);
+    expect(host.getScene()).not.toBeNull();
+  });
+
+  it("dispatches glyphcss:scene-ready on connect", () => {
+    let fired = false;
+    host.addEventListener("glyphcss:scene-ready", () => { fired = true; });
+    document.body.appendChild(camEl);
+    expect(fired).toBe(true);
+  });
+
+  it("passes cols/rows attributes down to the scene", async () => {
+    host.setAttribute("cols", "40");
+    host.setAttribute("rows", "10");
+    document.body.appendChild(camEl);
+    // Let the microtask render flush.
+    await Promise.resolve();
+    const pre = host.querySelector("pre.glyph-output") as HTMLPreElement;
+    // The pre should have some content rendered into a 40x10 grid.
+    expect(pre).toBeTruthy();
+  });
+
+  it("mode attribute change triggers re-render without throwing", async () => {
+    host.setAttribute("cols", "20");
+    host.setAttribute("rows", "5");
+    document.body.appendChild(camEl);
+    await Promise.resolve();
+    host.setAttribute("mode", "wireframe");
+    await Promise.resolve();
+    const pre = host.querySelector("pre.glyph-output") as HTMLPreElement;
+    expect(pre).toBeTruthy();
+  });
+
+  it("attributeChangedCallback is a no-op before connect", () => {
+    // Setting an attribute before connect must not throw.
+    expect(() => { host.setAttribute("mode", "solid"); }).not.toThrow();
+    // Scene still null after attribute change before connect.
+    expect(host.getScene()).toBeNull();
+  });
+
+  it("disconnect destroys the scene (removes .glyph-scene from DOM)", () => {
+    document.body.appendChild(camEl);
+    expect(host.querySelector(".glyph-scene")).toBeTruthy();
+    camEl.remove();
+    expect(host.querySelector(".glyph-scene")).toBeFalsy();
+    expect(host.getScene()).toBeNull();
+  });
+
+  it("reconnect after disconnect creates a fresh scene", () => {
+    document.body.appendChild(camEl);
+    const first = host.getScene();
+    camEl.remove();
+    // Re-create the camera element to avoid the "already connected" guard
+    const camEl2 = document.createElement("glyph-perspective-camera") as GlyphPerspectiveCameraElement;
+    const host2 = document.createElement("glyph-scene") as GlyphSceneElement;
+    camEl2.appendChild(host2);
+    document.body.appendChild(camEl2);
+    const second = host2.getScene();
+    expect(second).not.toBeNull();
+    // Should be a fresh handle object (not the same reference).
+    expect(second).not.toBe(first);
+    camEl2.remove();
+  });
+
+  it("use-colors=false attribute is forwarded (no crash on render)", async () => {
+    host.setAttribute("use-colors", "false");
+    host.setAttribute("cols", "20");
+    host.setAttribute("rows", "5");
+    document.body.appendChild(camEl);
+    await Promise.resolve();
+    const pre = host.querySelector("pre.glyph-output") as HTMLPreElement;
+    expect(pre).toBeTruthy();
+  });
+
+  it("directional-intensity and ambient-intensity attributes are forwarded without error", () => {
+    host.setAttribute("directional-intensity", "0.8");
+    host.setAttribute("ambient-intensity", "0.3");
+    expect(() => { document.body.appendChild(camEl); }).not.toThrow();
+  });
+
+  it("throws when connected without a camera ancestor", () => {
+    const orphanScene = document.createElement("glyph-scene") as GlyphSceneElement;
+    expect(() => {
+      document.body.appendChild(orphanScene);
+    }).toThrow(
+      "glyphcss:  must be placed inside a  or .",
+    );
+    orphanScene.remove();
+  });
+});
diff --git a/packages/glyphcss/src/elements/GlyphcssSceneElement.ts b/packages/glyphcss/src/elements/GlyphSceneElement.ts
similarity index 52%
rename from packages/glyphcss/src/elements/GlyphcssSceneElement.ts
rename to packages/glyphcss/src/elements/GlyphSceneElement.ts
index 505d58cb..e8c8f75e 100644
--- a/packages/glyphcss/src/elements/GlyphcssSceneElement.ts
+++ b/packages/glyphcss/src/elements/GlyphSceneElement.ts
@@ -1,17 +1,21 @@
 /**
- * `` custom element. Vanilla counterpart to React's future GlyphcssScene.
+ * `` custom element.
  *
- * On `connectedCallback`, instantiates `createGlyphcssScene(this, options)`.
- * Children (``) walk up the tree to find this element and call
+ * Must be placed inside a `` or
+ * `` element. On `connectedCallback`, walks up
+ * `parentElement` until it finds a camera ancestor, then instantiates
+ * `createGlyphScene(this, { camera, ...options })`.
+ *
+ * Children (``) walk up the tree to find this element and call
  * `getScene()` to register themselves.
  *
  * Attribute parsing mirrors `` conventions.
  */
 import {
-  createGlyphcssScene,
-  type GlyphcssSceneHandle,
-  type GlyphcssSceneOptions,
-} from "../api/createGlyphcssScene";
+  createGlyphScene,
+  type GlyphSceneHandle,
+  type GlyphSceneOptions,
+} from "../api/createGlyphScene";
 import type { RenderMode } from "@glyphcss/core";
 
 const ELEMENT_BASE: typeof HTMLElement =
@@ -29,6 +33,7 @@ const OBSERVED_ATTRS = [
   "directional-direction",
   "directional-intensity",
   "ambient-intensity",
+  "auto-size",
 ] as const;
 
 function parseNumber(value: string | null): number | undefined {
@@ -49,19 +54,19 @@ function parseBool(value: string | null): boolean | undefined {
   return undefined;
 }
 
-export class GlyphcssSceneElement extends ELEMENT_BASE {
+export class GlyphSceneElement extends ELEMENT_BASE {
   static get observedAttributes(): string[] {
     return [...OBSERVED_ATTRS];
   }
 
-  private _scene: GlyphcssSceneHandle | null = null;
+  private _scene: GlyphSceneHandle | null = null;
 
-  getScene(): GlyphcssSceneHandle | null {
+  getScene(): GlyphSceneHandle | null {
     return this._scene;
   }
 
-  private _readOptions(): GlyphcssSceneOptions {
-    const opts: GlyphcssSceneOptions = {};
+  private _readOptions(): GlyphSceneOptions {
+    const opts: GlyphSceneOptions = {};
     const mode = parseMode(this.getAttribute("mode"));
     if (mode !== undefined) opts.mode = mode;
     const glyphPalette = this.getAttribute("glyph-palette");
@@ -78,13 +83,59 @@ export class GlyphcssSceneElement extends ELEMENT_BASE {
     if (dirIntensity !== undefined) opts.directionalLight = { direction: [0.5, 0.7, 0.5], intensity: dirIntensity };
     const ambIntensity = parseNumber(this.getAttribute("ambient-intensity"));
     if (ambIntensity !== undefined) opts.ambientLight = { intensity: ambIntensity };
+    if (this.hasAttribute("auto-size")) opts.autoSize = true;
     return opts;
   }
 
+  private _findCameraAncestor(): (HTMLElement & { getCamera?: () => unknown }) | null {
+    let el: HTMLElement | null = this.parentElement;
+    while (el) {
+      const tag = el.tagName.toLowerCase();
+      if (tag === "glyph-perspective-camera" || tag === "glyph-orthographic-camera") {
+        return el as HTMLElement & { getCamera?: () => unknown };
+      }
+      el = el.parentElement;
+    }
+    return null;
+  }
+
+  private _initScene(cameraAncestor: HTMLElement & { getCamera?: () => unknown }): void {
+    const camera = typeof cameraAncestor.getCamera === "function"
+      ? (cameraAncestor.getCamera() as GlyphSceneOptions["camera"])
+      : undefined;
+    const opts = this._readOptions();
+    if (camera) opts.camera = camera;
+    this._scene = createGlyphScene(this, opts);
+    this.dispatchEvent(new CustomEvent("glyphcss:scene-ready", { bubbles: false }));
+  }
+
   connectedCallback(): void {
     if (this._scene) return;
-    this._scene = createGlyphcssScene(this, this._readOptions());
-    this.dispatchEvent(new CustomEvent("glyphcss:scene-ready", { bubbles: false }));
+    const cameraAncestor = this._findCameraAncestor();
+    if (!cameraAncestor) {
+      throw new Error(
+        "glyphcss:  must be placed inside a  or .",
+      );
+    }
+    const cam = typeof cameraAncestor.getCamera === "function"
+      ? (cameraAncestor.getCamera() as unknown)
+      : null;
+    if (cam !== null) {
+      // Camera already created — initialize immediately.
+      this._initScene(cameraAncestor);
+    } else {
+      // Camera element connected after scene (ordering edge case in some environments).
+      // Wait for the camera-ready event.
+      const onReady = () => {
+        cameraAncestor.removeEventListener("glyph:camera-ready", onReady);
+        if (!this._scene) this._initScene(cameraAncestor);
+      };
+      cameraAncestor.addEventListener("glyph:camera-ready", onReady);
+    }
+  }
+
+  rerender(): void {
+    this._scene?.rerender();
   }
 
   disconnectedCallback(): void {
diff --git a/packages/glyphcss/src/elements/GlyphcssMapControlsElement.test.ts b/packages/glyphcss/src/elements/GlyphcssMapControlsElement.test.ts
deleted file mode 100644
index d4f96688..00000000
--- a/packages/glyphcss/src/elements/GlyphcssMapControlsElement.test.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { describe, it, expect, beforeEach, afterEach } from "vitest";
-import { GlyphcssSceneElement } from "./GlyphcssSceneElement";
-import { GlyphcssMapControlsElement } from "./GlyphcssMapControlsElement";
-
-if (!customElements.get("glyphcss-scene")) {
-  customElements.define("glyphcss-scene", GlyphcssSceneElement);
-}
-if (!customElements.get("glyphcss-map-controls")) {
-  customElements.define("glyphcss-map-controls", GlyphcssMapControlsElement);
-}
-
-describe("GlyphcssMapControlsElement", () => {
-  let sceneEl: GlyphcssSceneElement;
-  let controls: GlyphcssMapControlsElement;
-
-  beforeEach(() => {
-    sceneEl = document.createElement("glyphcss-scene") as GlyphcssSceneElement;
-    sceneEl.setAttribute("cols", "20");
-    sceneEl.setAttribute("rows", "5");
-    document.body.appendChild(sceneEl);
-
-    controls = document.createElement("glyphcss-map-controls") as GlyphcssMapControlsElement;
-  });
-
-  afterEach(() => {
-    if (controls.isConnected) controls.remove();
-    if (sceneEl.isConnected) sceneEl.remove();
-  });
-
-  it("is registered under the 'glyphcss-map-controls' tag", () => {
-    expect(customElements.get("glyphcss-map-controls")).toBe(GlyphcssMapControlsElement);
-  });
-
-  it("createElement produces a GlyphcssMapControlsElement instance", () => {
-    expect(controls).toBeInstanceOf(GlyphcssMapControlsElement);
-  });
-
-  it("observes drag, wheel, invert attributes", () => {
-    expect(GlyphcssMapControlsElement.observedAttributes).toContain("drag");
-    expect(GlyphcssMapControlsElement.observedAttributes).toContain("wheel");
-    expect(GlyphcssMapControlsElement.observedAttributes).toContain("invert");
-  });
-
-  it("connects without throwing inside a scene", () => {
-    expect(() => { sceneEl.appendChild(controls); }).not.toThrow();
-  });
-
-  it("connects without throwing outside a scene", () => {
-    expect(() => { document.body.appendChild(controls); }).not.toThrow();
-    controls.remove();
-  });
-
-  it("attaches grab cursor style to scene host on connect", () => {
-    sceneEl.appendChild(controls);
-    expect(sceneEl.style.cursor).toBe("grab");
-  });
-
-  it("drag=false omits grab cursor", () => {
-    controls.setAttribute("drag", "false");
-    sceneEl.appendChild(controls);
-    expect(sceneEl.style.cursor).toBe("");
-  });
-
-  it("disconnect cleans up cursor on scene host", () => {
-    sceneEl.appendChild(controls);
-    expect(sceneEl.style.cursor).toBe("grab");
-    controls.remove();
-    expect(sceneEl.style.cursor).toBe("");
-  });
-
-  it("attribute change updates controls without throwing", () => {
-    sceneEl.appendChild(controls);
-    expect(() => { controls.setAttribute("wheel", "false"); }).not.toThrow();
-  });
-
-  it("waits for glyphcss:scene-ready when connected before scene is ready", () => {
-    sceneEl.remove();
-    const freshScene = document.createElement("glyphcss-scene") as GlyphcssSceneElement;
-    freshScene.setAttribute("cols", "10");
-    freshScene.setAttribute("rows", "5");
-    freshScene.appendChild(controls);
-    expect(() => { document.body.appendChild(freshScene); }).not.toThrow();
-    freshScene.remove();
-  });
-
-  it("invert=true attribute is forwarded without error", () => {
-    controls.setAttribute("invert", "true");
-    expect(() => { sceneEl.appendChild(controls); }).not.toThrow();
-  });
-});
diff --git a/packages/glyphcss/src/elements/GlyphcssMeshElement.ts b/packages/glyphcss/src/elements/GlyphcssMeshElement.ts
deleted file mode 100644
index 478e14fc..00000000
--- a/packages/glyphcss/src/elements/GlyphcssMeshElement.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-/**
- * `` custom element. Fetches a mesh via the glyphcss-core
- * `loadMesh` parser and registers with the parent ``.
- *
- * On disconnect: disposes the registered mesh handle.
- */
-import { loadMesh } from "@glyphcss/core";
-import type { Vec3 } from "@glyphcss/core";
-import type { GlyphcssMeshHandle, GlyphcssSceneHandle } from "../api/createGlyphcssScene";
-import type { GlyphcssMeshTransform } from "../api/types";
-import type { GlyphcssSceneElement } from "./GlyphcssSceneElement";
-
-const ELEMENT_BASE: typeof HTMLElement =
-  typeof HTMLElement !== "undefined"
-    ? HTMLElement
-    : (class {} as unknown as typeof HTMLElement);
-
-const OBSERVED_ATTRS = ["src", "position", "scale", "rotation"] as const;
-
-function parseVec3(value: string | null): Vec3 | undefined {
-  if (!value) return undefined;
-  const parts = value.split(",").map((p) => parseFloat(p.trim()));
-  if (parts.length !== 3 || parts.some((p) => !Number.isFinite(p))) return undefined;
-  return [parts[0]!, parts[1]!, parts[2]!];
-}
-
-function parseScale(value: string | null): number | Vec3 | undefined {
-  if (!value) return undefined;
-  if (!value.includes(",")) {
-    const n = parseFloat(value);
-    return Number.isFinite(n) ? n : undefined;
-  }
-  return parseVec3(value);
-}
-
-function findScene(el: HTMLElement): GlyphcssSceneElement | null {
-  const found = el.closest("glyphcss-scene") as unknown as (GlyphcssSceneElement & { getScene?: () => unknown }) | null;
-  return found ?? null;
-}
-
-
-export class GlyphcssMeshElement extends ELEMENT_BASE {
-  static get observedAttributes(): string[] {
-    return [...OBSERVED_ATTRS];
-  }
-
-  private _handle: GlyphcssMeshHandle | null = null;
-  private _loadToken = 0;
-
-  getMeshHandle(): GlyphcssMeshHandle | null {
-    return this._handle;
-  }
-
-  connectedCallback(): void {
-    this._maybeLoad();
-  }
-
-  disconnectedCallback(): void {
-    this._tearDown();
-  }
-
-  attributeChangedCallback(
-    name: string,
-    oldValue: string | null,
-    newValue: string | null,
-  ): void {
-    if (oldValue === newValue) return;
-    if (name === "src") {
-      this._tearDown();
-      this._maybeLoad();
-      return;
-    }
-    if (!this._handle) return;
-    this._handle.setTransform(this._readTransform());
-  }
-
-  private _readTransform(): GlyphcssMeshTransform {
-    return {
-      position: parseVec3(this.getAttribute("position")),
-      scale: parseScale(this.getAttribute("scale")),
-      rotation: parseVec3(this.getAttribute("rotation")),
-    };
-  }
-
-  private _tearDown(): void {
-    this._loadToken += 1;
-    if (this._handle) {
-      try { this._handle.dispose(); } catch { /* ignore */ }
-      this._handle = null;
-    }
-  }
-
-  private async _maybeLoad(): Promise {
-    const src = this.getAttribute("src");
-    if (!src) return;
-    const sceneEl = findScene(this);
-    if (!sceneEl) return;
-
-    const token = ++this._loadToken;
-
-    let parsed: Awaited>;
-    try {
-      parsed = await loadMesh(src);
-    } catch (err) {
-      this.dispatchEvent(new CustomEvent("glyphcss:error", { detail: err, bubbles: true }));
-      return;
-    }
-
-    if (token !== this._loadToken) {
-      try { parsed.dispose(); } catch { /* ignore */ }
-      return;
-    }
-
-    const scene: GlyphcssSceneHandle | null = sceneEl.getScene();
-    if (!scene) {
-      try { parsed.dispose(); } catch { /* ignore */ }
-      return;
-    }
-
-    this._handle = scene.add(parsed.polygons, this._readTransform());
-
-    this.dispatchEvent(new CustomEvent("glyphcss:loaded", { detail: { polygons: parsed.polygons }, bubbles: true }));
-  }
-}
diff --git a/packages/glyphcss/src/elements/GlyphcssOrbitControlsElement.test.ts b/packages/glyphcss/src/elements/GlyphcssOrbitControlsElement.test.ts
deleted file mode 100644
index 134acfe9..00000000
--- a/packages/glyphcss/src/elements/GlyphcssOrbitControlsElement.test.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { describe, it, expect, beforeEach, afterEach } from "vitest";
-import { GlyphcssSceneElement } from "./GlyphcssSceneElement";
-import { GlyphcssOrbitControlsElement } from "./GlyphcssOrbitControlsElement";
-
-if (!customElements.get("glyphcss-scene")) {
-  customElements.define("glyphcss-scene", GlyphcssSceneElement);
-}
-if (!customElements.get("glyphcss-orbit-controls")) {
-  customElements.define("glyphcss-orbit-controls", GlyphcssOrbitControlsElement);
-}
-
-describe("GlyphcssOrbitControlsElement", () => {
-  let sceneEl: GlyphcssSceneElement;
-  let controls: GlyphcssOrbitControlsElement;
-
-  beforeEach(() => {
-    sceneEl = document.createElement("glyphcss-scene") as GlyphcssSceneElement;
-    sceneEl.setAttribute("cols", "20");
-    sceneEl.setAttribute("rows", "5");
-    document.body.appendChild(sceneEl);
-
-    controls = document.createElement("glyphcss-orbit-controls") as GlyphcssOrbitControlsElement;
-  });
-
-  afterEach(() => {
-    if (controls.isConnected) controls.remove();
-    if (sceneEl.isConnected) sceneEl.remove();
-  });
-
-  it("is registered under the 'glyphcss-orbit-controls' tag", () => {
-    expect(customElements.get("glyphcss-orbit-controls")).toBe(GlyphcssOrbitControlsElement);
-  });
-
-  it("createElement produces a GlyphcssOrbitControlsElement instance", () => {
-    expect(controls).toBeInstanceOf(GlyphcssOrbitControlsElement);
-  });
-
-  it("observes drag, wheel, invert, animate-speed, animate-axis attributes", () => {
-    expect(GlyphcssOrbitControlsElement.observedAttributes).toContain("drag");
-    expect(GlyphcssOrbitControlsElement.observedAttributes).toContain("wheel");
-    expect(GlyphcssOrbitControlsElement.observedAttributes).toContain("invert");
-    expect(GlyphcssOrbitControlsElement.observedAttributes).toContain("animate-speed");
-    expect(GlyphcssOrbitControlsElement.observedAttributes).toContain("animate-axis");
-  });
-
-  it("connects without throwing inside a scene", () => {
-    expect(() => { sceneEl.appendChild(controls); }).not.toThrow();
-  });
-
-  it("connects without throwing outside a scene (no scene parent)", () => {
-    expect(() => { document.body.appendChild(controls); }).not.toThrow();
-    controls.remove();
-  });
-
-  it("attaches grab cursor style to scene host on connect", () => {
-    sceneEl.appendChild(controls);
-    // createGlyphcssOrbitControls sets cursor:'grab' on the host when drag is enabled.
-    expect(sceneEl.style.cursor).toBe("grab");
-  });
-
-  it("drag=false removes grab cursor", () => {
-    controls.setAttribute("drag", "false");
-    sceneEl.appendChild(controls);
-    expect(sceneEl.style.cursor).toBe("");
-  });
-
-  it("disconnect cleans up cursor style on scene host", () => {
-    sceneEl.appendChild(controls);
-    expect(sceneEl.style.cursor).toBe("grab");
-    controls.remove();
-    expect(sceneEl.style.cursor).toBe("");
-  });
-
-  it("attribute change updates controls without throwing", () => {
-    sceneEl.appendChild(controls);
-    expect(() => { controls.setAttribute("invert", "true"); }).not.toThrow();
-  });
-
-  it("waits for glyphcss:scene-ready when attached before scene is ready", () => {
-    // Detach scene, create a fresh one (not yet connected) and insert controls first.
-    sceneEl.remove();
-    const freshScene = document.createElement("glyphcss-scene") as GlyphcssSceneElement;
-    freshScene.setAttribute("cols", "10");
-    freshScene.setAttribute("rows", "5");
-    // Append controls into scene before scene is connected — scene not ready yet.
-    freshScene.appendChild(controls);
-    // Now connect scene — dispatches glyphcss:scene-ready which controls listens to.
-    expect(() => { document.body.appendChild(freshScene); }).not.toThrow();
-    freshScene.remove();
-  });
-});
diff --git a/packages/glyphcss/src/elements/GlyphcssOrthographicCameraElement.test.ts b/packages/glyphcss/src/elements/GlyphcssOrthographicCameraElement.test.ts
deleted file mode 100644
index 29118de6..00000000
--- a/packages/glyphcss/src/elements/GlyphcssOrthographicCameraElement.test.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { describe, it, expect, beforeEach, afterEach } from "vitest";
-import { GlyphcssSceneElement } from "./GlyphcssSceneElement";
-import { GlyphcssOrthographicCameraElement } from "./GlyphcssOrthographicCameraElement";
-
-if (!customElements.get("glyphcss-scene")) {
-  customElements.define("glyphcss-scene", GlyphcssSceneElement);
-}
-if (!customElements.get("glyphcss-orthographic-camera")) {
-  customElements.define("glyphcss-orthographic-camera", GlyphcssOrthographicCameraElement);
-}
-
-describe("GlyphcssOrthographicCameraElement", () => {
-  let sceneEl: GlyphcssSceneElement;
-  let cam: GlyphcssOrthographicCameraElement;
-
-  beforeEach(() => {
-    sceneEl = document.createElement("glyphcss-scene") as GlyphcssSceneElement;
-    sceneEl.setAttribute("cols", "20");
-    sceneEl.setAttribute("rows", "5");
-    document.body.appendChild(sceneEl);
-
-    cam = document.createElement("glyphcss-orthographic-camera") as GlyphcssOrthographicCameraElement;
-  });
-
-  afterEach(() => {
-    if (cam.isConnected) cam.remove();
-    if (sceneEl.isConnected) sceneEl.remove();
-  });
-
-  it("is registered under the 'glyphcss-orthographic-camera' tag", () => {
-    expect(customElements.get("glyphcss-orthographic-camera")).toBe(GlyphcssOrthographicCameraElement);
-  });
-
-  it("createElement produces a GlyphcssOrthographicCameraElement instance", () => {
-    expect(cam).toBeInstanceOf(GlyphcssOrthographicCameraElement);
-  });
-
-  it("observes rot-x, rot-y, zoom attributes", () => {
-    expect(GlyphcssOrthographicCameraElement.observedAttributes).toContain("rot-x");
-    expect(GlyphcssOrthographicCameraElement.observedAttributes).toContain("rot-y");
-    expect(GlyphcssOrthographicCameraElement.observedAttributes).toContain("zoom");
-  });
-
-  it("connects without throwing inside a scene", () => {
-    expect(() => { sceneEl.appendChild(cam); }).not.toThrow();
-  });
-
-  it("connects without throwing outside a scene", () => {
-    expect(() => { document.body.appendChild(cam); }).not.toThrow();
-    cam.remove();
-  });
-
-  it("replaces the scene camera with an orthographic camera on connect", () => {
-    sceneEl.appendChild(cam);
-    expect(sceneEl.getScene()!.camera.kind).toBe("orthographic");
-  });
-
-  it("applies rot-x attribute to scene camera", () => {
-    cam.setAttribute("rot-x", "0.4");
-    sceneEl.appendChild(cam);
-    expect(sceneEl.getScene()!.camera.rotX).toBeCloseTo(0.4, 5);
-  });
-
-  it("applies rot-y attribute to scene camera", () => {
-    cam.setAttribute("rot-y", "0.9");
-    sceneEl.appendChild(cam);
-    expect(sceneEl.getScene()!.camera.rotY).toBeCloseTo(0.9, 5);
-  });
-
-  it("applies zoom as the camera scale", () => {
-    // createGlyphcssOrthographicCamera maps zoom→scale.
-    cam.setAttribute("zoom", "0.7");
-    sceneEl.appendChild(cam);
-    expect(sceneEl.getScene()!.camera.zoom).toBeCloseTo(0.7, 5);
-  });
-
-  it("changing rot-y attribute updates camera", () => {
-    sceneEl.appendChild(cam);
-    cam.setAttribute("rot-y", "1.5");
-    expect(sceneEl.getScene()!.camera.rotY).toBeCloseTo(1.5, 5);
-  });
-
-  it("attribute change without scene parent is a no-op (no throw)", () => {
-    expect(() => { cam.setAttribute("zoom", "2.0"); }).not.toThrow();
-  });
-
-  it("invalid zoom value is ignored gracefully", () => {
-    cam.setAttribute("zoom", "bad");
-    expect(() => { sceneEl.appendChild(cam); }).not.toThrow();
-  });
-});
diff --git a/packages/glyphcss/src/elements/GlyphcssOrthographicCameraElement.ts b/packages/glyphcss/src/elements/GlyphcssOrthographicCameraElement.ts
deleted file mode 100644
index 6f8c26f5..00000000
--- a/packages/glyphcss/src/elements/GlyphcssOrthographicCameraElement.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * `` — declarative orthographic camera.
- */
-import { createGlyphcssOrthographicCamera } from "../api/createGlyphcssCamera";
-import type { GlyphcssSceneElement } from "./GlyphcssSceneElement";
-
-const ELEMENT_BASE: typeof HTMLElement =
-  typeof HTMLElement !== "undefined"
-    ? HTMLElement
-    : (class {} as unknown as typeof HTMLElement);
-
-function parseNumber(value: string | null): number | undefined {
-  if (value == null) return undefined;
-  const n = parseFloat(value);
-  return Number.isFinite(n) ? n : undefined;
-}
-
-function findScene(el: HTMLElement): GlyphcssSceneElement | null {
-  const found = el.closest("glyphcss-scene") as unknown as (GlyphcssSceneElement & { getScene?: () => unknown }) | null;
-  return found ?? null;
-}
-
-export class GlyphcssOrthographicCameraElement extends ELEMENT_BASE {
-  static get observedAttributes(): string[] {
-    return ["rot-x", "rot-y", "zoom"];
-  }
-
-  connectedCallback(): void { this._apply(); }
-
-  attributeChangedCallback(_name: string, old: string | null, next: string | null): void {
-    if (old === next) return;
-    this._apply();
-  }
-
-  private _apply(): void {
-    const sceneEl = findScene(this);
-    const scene = sceneEl?.getScene();
-    if (!scene) return;
-    const cam = createGlyphcssOrthographicCamera({
-      rotX: parseNumber(this.getAttribute("rot-x")),
-      rotY: parseNumber(this.getAttribute("rot-y")),
-      zoom: parseNumber(this.getAttribute("zoom")),
-    });
-    scene.setOptions({ camera: cam });
-  }
-}
diff --git a/packages/glyphcss/src/elements/GlyphcssPerspectiveCameraElement.test.ts b/packages/glyphcss/src/elements/GlyphcssPerspectiveCameraElement.test.ts
deleted file mode 100644
index 2a7313b9..00000000
--- a/packages/glyphcss/src/elements/GlyphcssPerspectiveCameraElement.test.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { describe, it, expect, beforeEach, afterEach } from "vitest";
-import { GlyphcssSceneElement } from "./GlyphcssSceneElement";
-import { GlyphcssPerspectiveCameraElement } from "./GlyphcssPerspectiveCameraElement";
-
-if (!customElements.get("glyphcss-scene")) {
-  customElements.define("glyphcss-scene", GlyphcssSceneElement);
-}
-if (!customElements.get("glyphcss-perspective-camera")) {
-  customElements.define("glyphcss-perspective-camera", GlyphcssPerspectiveCameraElement);
-}
-
-describe("GlyphcssPerspectiveCameraElement", () => {
-  let sceneEl: GlyphcssSceneElement;
-  let cam: GlyphcssPerspectiveCameraElement;
-
-  beforeEach(() => {
-    sceneEl = document.createElement("glyphcss-scene") as GlyphcssSceneElement;
-    sceneEl.setAttribute("cols", "20");
-    sceneEl.setAttribute("rows", "5");
-    document.body.appendChild(sceneEl);
-
-    cam = document.createElement("glyphcss-perspective-camera") as GlyphcssPerspectiveCameraElement;
-  });
-
-  afterEach(() => {
-    if (cam.isConnected) cam.remove();
-    if (sceneEl.isConnected) sceneEl.remove();
-  });
-
-  it("is registered under the 'glyphcss-perspective-camera' tag", () => {
-    expect(customElements.get("glyphcss-perspective-camera")).toBe(GlyphcssPerspectiveCameraElement);
-  });
-
-  it("createElement produces a GlyphcssPerspectiveCameraElement instance", () => {
-    expect(cam).toBeInstanceOf(GlyphcssPerspectiveCameraElement);
-  });
-
-  it("observes rot-x, rot-y, distance, zoom, stretch attributes", () => {
-    expect(GlyphcssPerspectiveCameraElement.observedAttributes).toContain("rot-x");
-    expect(GlyphcssPerspectiveCameraElement.observedAttributes).toContain("rot-y");
-    expect(GlyphcssPerspectiveCameraElement.observedAttributes).toContain("distance");
-    expect(GlyphcssPerspectiveCameraElement.observedAttributes).toContain("zoom");
-    expect(GlyphcssPerspectiveCameraElement.observedAttributes).toContain("stretch");
-  });
-
-  it("connects without throwing inside a scene", () => {
-    expect(() => { sceneEl.appendChild(cam); }).not.toThrow();
-  });
-
-  it("connects without throwing outside a scene (no-op silently)", () => {
-    expect(() => { document.body.appendChild(cam); }).not.toThrow();
-    cam.remove();
-  });
-
-  it("applies rot-x attribute to scene camera on connect", () => {
-    cam.setAttribute("rot-x", "0.5");
-    sceneEl.appendChild(cam);
-    const scene = sceneEl.getScene();
-    expect(scene).not.toBeNull();
-    // The camera kind should be perspective (the element replaced the default one).
-    expect(scene!.camera.kind).toBe("perspective");
-    expect(scene!.camera.rotX).toBeCloseTo(0.5, 5);
-  });
-
-  it("applies rot-y attribute to scene camera on connect", () => {
-    cam.setAttribute("rot-y", "1.2");
-    sceneEl.appendChild(cam);
-    const scene = sceneEl.getScene();
-    expect(scene!.camera.rotY).toBeCloseTo(1.2, 5);
-  });
-
-  it("applies distance attribute to scene camera", () => {
-    cam.setAttribute("distance", "5");
-    sceneEl.appendChild(cam);
-    expect(sceneEl.getScene()!.camera.distance).toBeCloseTo(5, 5);
-  });
-
-  it("applies zoom attribute to scene camera", () => {
-    cam.setAttribute("zoom", "0.6");
-    sceneEl.appendChild(cam);
-    expect(sceneEl.getScene()!.camera.zoom).toBeCloseTo(0.6, 5);
-  });
-
-  it("changing rot-x attribute updates camera", () => {
-    sceneEl.appendChild(cam);
-    cam.setAttribute("rot-x", "0.3");
-    expect(sceneEl.getScene()!.camera.rotX).toBeCloseTo(0.3, 5);
-  });
-
-  it("attribute change is no-op when not inside a scene", () => {
-    // cam is not in the DOM — should not throw.
-    expect(() => { cam.setAttribute("rot-x", "1.0"); }).not.toThrow();
-  });
-
-  it("invalid numeric attribute (NaN) is ignored gracefully", () => {
-    cam.setAttribute("rot-x", "not-a-number");
-    expect(() => { sceneEl.appendChild(cam); }).not.toThrow();
-  });
-});
diff --git a/packages/glyphcss/src/elements/GlyphcssPerspectiveCameraElement.ts b/packages/glyphcss/src/elements/GlyphcssPerspectiveCameraElement.ts
deleted file mode 100644
index 3857a09b..00000000
--- a/packages/glyphcss/src/elements/GlyphcssPerspectiveCameraElement.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * `` — declarative perspective camera.
- * Walks up to the parent `` and replaces its camera.
- */
-import { createGlyphcssPerspectiveCamera } from "../api/createGlyphcssCamera";
-import type { GlyphcssSceneElement } from "./GlyphcssSceneElement";
-
-const ELEMENT_BASE: typeof HTMLElement =
-  typeof HTMLElement !== "undefined"
-    ? HTMLElement
-    : (class {} as unknown as typeof HTMLElement);
-
-function parseNumber(value: string | null): number | undefined {
-  if (value == null) return undefined;
-  const n = parseFloat(value);
-  return Number.isFinite(n) ? n : undefined;
-}
-
-function findScene(el: HTMLElement): GlyphcssSceneElement | null {
-  const found = el.closest("glyphcss-scene") as unknown as (GlyphcssSceneElement & { getScene?: () => unknown }) | null;
-  return found ?? null;
-}
-
-export class GlyphcssPerspectiveCameraElement extends ELEMENT_BASE {
-  static get observedAttributes(): string[] {
-    return ["rot-x", "rot-y", "distance", "zoom", "stretch"];
-  }
-
-  connectedCallback(): void { this._apply(); }
-
-  attributeChangedCallback(_name: string, old: string | null, next: string | null): void {
-    if (old === next) return;
-    this._apply();
-  }
-
-  private _apply(): void {
-    const sceneEl = findScene(this);
-    const scene = sceneEl?.getScene();
-    if (!scene) return;
-    const cam = createGlyphcssPerspectiveCamera({
-      rotX: parseNumber(this.getAttribute("rot-x")),
-      rotY: parseNumber(this.getAttribute("rot-y")),
-      distance: parseNumber(this.getAttribute("distance")),
-      zoom: parseNumber(this.getAttribute("zoom")),
-      stretch: parseNumber(this.getAttribute("stretch")),
-    });
-    scene.setOptions({ camera: cam });
-  }
-}
diff --git a/packages/glyphcss/src/elements/GlyphcssSceneElement.test.ts b/packages/glyphcss/src/elements/GlyphcssSceneElement.test.ts
deleted file mode 100644
index 44d760d7..00000000
--- a/packages/glyphcss/src/elements/GlyphcssSceneElement.test.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import { describe, it, expect, beforeEach, afterEach } from "vitest";
-import { GlyphcssSceneElement } from "./GlyphcssSceneElement";
-
-// Register the element if not already registered.
-if (!customElements.get("glyphcss-scene")) {
-  customElements.define("glyphcss-scene", GlyphcssSceneElement);
-}
-
-describe("GlyphcssSceneElement", () => {
-  let host: GlyphcssSceneElement;
-
-  beforeEach(() => {
-    host = document.createElement("glyphcss-scene") as GlyphcssSceneElement;
-  });
-
-  afterEach(() => {
-    if (host.isConnected) host.remove();
-  });
-
-  it("is registered under the 'glyphcss-scene' tag", () => {
-    expect(customElements.get("glyphcss-scene")).toBe(GlyphcssSceneElement);
-  });
-
-  it("createElement produces a GlyphcssSceneElement instance", () => {
-    expect(host).toBeInstanceOf(GlyphcssSceneElement);
-  });
-
-  it("observes the expected attributes", () => {
-    expect(GlyphcssSceneElement.observedAttributes).toContain("mode");
-    expect(GlyphcssSceneElement.observedAttributes).toContain("cols");
-    expect(GlyphcssSceneElement.observedAttributes).toContain("rows");
-    expect(GlyphcssSceneElement.observedAttributes).toContain("use-colors");
-    expect(GlyphcssSceneElement.observedAttributes).toContain("glyph-palette");
-    expect(GlyphcssSceneElement.observedAttributes).toContain("cell-aspect");
-    expect(GlyphcssSceneElement.observedAttributes).toContain("directional-intensity");
-    expect(GlyphcssSceneElement.observedAttributes).toContain("ambient-intensity");
-  });
-
-  it("getScene() returns null before connect", () => {
-    expect(host.getScene()).toBeNull();
-  });
-
-  it("appending to document emits .glyphcss-scene wrapper and 
 output", () => {
-    document.body.appendChild(host);
-    expect(host.querySelector(".glyphcss-scene")).toBeTruthy();
-    expect(host.querySelector("pre.glyphcss-output")).toBeTruthy();
-  });
-
-  it("getScene() is non-null after connect", () => {
-    document.body.appendChild(host);
-    expect(host.getScene()).not.toBeNull();
-  });
-
-  it("dispatches glyphcss:scene-ready on connect", () => {
-    let fired = false;
-    host.addEventListener("glyphcss:scene-ready", () => { fired = true; });
-    document.body.appendChild(host);
-    expect(fired).toBe(true);
-  });
-
-  it("passes cols/rows attributes down to the scene", async () => {
-    host.setAttribute("cols", "40");
-    host.setAttribute("rows", "10");
-    document.body.appendChild(host);
-    // Let the microtask render flush.
-    await Promise.resolve();
-    const pre = host.querySelector("pre.glyphcss-output") as HTMLPreElement;
-    // The pre should have some content rendered into a 40x10 grid.
-    expect(pre).toBeTruthy();
-  });
-
-  it("mode attribute change triggers re-render without throwing", async () => {
-    host.setAttribute("cols", "20");
-    host.setAttribute("rows", "5");
-    document.body.appendChild(host);
-    await Promise.resolve();
-    host.setAttribute("mode", "wireframe");
-    await Promise.resolve();
-    const pre = host.querySelector("pre.glyphcss-output") as HTMLPreElement;
-    expect(pre).toBeTruthy();
-  });
-
-  it("attributeChangedCallback is a no-op before connect", () => {
-    // Setting an attribute before connect must not throw.
-    expect(() => { host.setAttribute("mode", "solid"); }).not.toThrow();
-    // Scene still null after attribute change before connect.
-    expect(host.getScene()).toBeNull();
-  });
-
-  it("disconnect destroys the scene (removes .glyphcss-scene from DOM)", () => {
-    document.body.appendChild(host);
-    expect(host.querySelector(".glyphcss-scene")).toBeTruthy();
-    host.remove();
-    expect(host.querySelector(".glyphcss-scene")).toBeFalsy();
-    expect(host.getScene()).toBeNull();
-  });
-
-  it("reconnect after disconnect creates a fresh scene", () => {
-    document.body.appendChild(host);
-    const first = host.getScene();
-    host.remove();
-    document.body.appendChild(host);
-    const second = host.getScene();
-    expect(second).not.toBeNull();
-    // Should be a fresh handle object (not the same reference).
-    expect(second).not.toBe(first);
-  });
-
-  it("use-colors=false attribute is forwarded (no crash on render)", async () => {
-    host.setAttribute("use-colors", "false");
-    host.setAttribute("cols", "20");
-    host.setAttribute("rows", "5");
-    document.body.appendChild(host);
-    await Promise.resolve();
-    const pre = host.querySelector("pre.glyphcss-output") as HTMLPreElement;
-    expect(pre).toBeTruthy();
-  });
-
-  it("directional-intensity and ambient-intensity attributes are forwarded without error", () => {
-    host.setAttribute("directional-intensity", "0.8");
-    host.setAttribute("ambient-intensity", "0.3");
-    expect(() => { document.body.appendChild(host); }).not.toThrow();
-  });
-});
diff --git a/packages/glyphcss/src/index.ts b/packages/glyphcss/src/index.ts
index 07d81787..240f3dd9 100644
--- a/packages/glyphcss/src/index.ts
+++ b/packages/glyphcss/src/index.ts
@@ -2,11 +2,11 @@
  * glyphcss — ASCII paint backend with glyphcss's scene-composition API.
  *
  * Public surface:
- *   - `createGlyphcssScene(host, options)` — imperative scene API
- *   - Camera factories — `createGlyphcssPerspectiveCamera`, `createGlyphcssOrthographicCamera`,
- *     `createGlyphcssFirstPersonCamera`
- *   - Controls — `createGlyphcssOrbitControls`, `createGlyphcssMapControls`,
- *     `createGlyphcssFirstPersonControls`
+ *   - `createGlyphScene(host, options)` — imperative scene API
+ *   - Camera factories — `createGlyphCamera` (ortho alias), `createGlyphPerspectiveCamera`,
+ *     `createGlyphOrthographicCamera`
+ *   - Controls — `createGlyphOrbitControls`, `createGlyphMapControls`,
+ *     `createGlyphFirstPersonControls`
  *   - Rasterizer — `rasterize`, `bakeFrames`
  *   - Custom element classes (importing this entry does NOT auto-register them;
  *     use `glyphcss/elements` for that side effect).
@@ -14,63 +14,61 @@
  */
 
 // ── Imperative scene API ──────────────────────────────────────────
-export { createGlyphcssScene } from "./api/createGlyphcssScene";
+export { createGlyphScene } from "./api/createGlyphScene";
 export type {
-  GlyphcssSceneHandle,
-  GlyphcssMeshHandle,
-  GlyphcssMeshTransform,
-  GlyphcssSceneOptions,
-  GlyphcssHotspotOptions,
-  GlyphcssHotspotHandle,
-} from "./api/createGlyphcssScene";
+  GlyphSceneHandle,
+  GlyphMeshHandle,
+  GlyphMeshTransform,
+  GlyphSceneOptions,
+  GlyphHotspotOptions,
+  GlyphHotspotHandle,
+} from "./api/createGlyphScene";
 
-// Re-export glyphcss-specific types
-export type { GlyphcssDirectionalLight, GlyphcssAmbientLight } from "./api/types";
+// Re-export glyph-specific types
+export type { GlyphDirectionalLight, GlyphAmbientLight } from "./api/types";
 
 // ── Camera factories ──────────────────────────────────────────────
 export {
-  createGlyphcssPerspectiveCamera,
-  createGlyphcssOrthographicCamera,
-  createGlyphcssFirstPersonCamera,
-} from "./api/createGlyphcssCamera";
+  createGlyphCamera,
+  createGlyphPerspectiveCamera,
+  createGlyphOrthographicCamera,
+} from "./api/createGlyphCamera";
 export type {
-  GlyphcssCamera,
-  GlyphcssPerspectiveCameraOptions,
-  GlyphcssOrthographicCameraOptions,
-  GlyphcssFirstPersonCameraOptions,
-  GlyphcssPerspectiveCameraHandle,
-  GlyphcssOrthographicCameraHandle,
-  GlyphcssFirstPersonCameraHandle,
-} from "./api/createGlyphcssCamera";
+  GlyphCamera,
+  GlyphPerspectiveCameraOptions,
+  GlyphOrthographicCameraOptions,
+  GlyphPerspectiveCameraHandle,
+  GlyphOrthographicCameraHandle,
+} from "./api/createGlyphCamera";
 
 // ── Controls ──────────────────────────────────────────────────────
-export { createGlyphcssOrbitControls } from "./api/createGlyphcssOrbitControls";
+export { createGlyphOrbitControls } from "./api/createGlyphOrbitControls";
 export type {
-  GlyphcssOrbitControlsOptions,
-  GlyphcssOrbitControlsHandle,
-} from "./api/createGlyphcssOrbitControls";
+  GlyphOrbitControlsOptions,
+  GlyphOrbitControlsHandle,
+} from "./api/createGlyphOrbitControls";
 
-export { createGlyphcssMapControls } from "./api/createGlyphcssMapControls";
+export { createGlyphMapControls } from "./api/createGlyphMapControls";
 export type {
-  GlyphcssMapControlsOptions,
-  GlyphcssMapControlsHandle,
-} from "./api/createGlyphcssMapControls";
+  GlyphMapControlsOptions,
+  GlyphMapControlsHandle,
+} from "./api/createGlyphMapControls";
 
-export { createGlyphcssFirstPersonControls } from "./api/createGlyphcssFirstPersonControls";
+export { createGlyphFirstPersonControls } from "./api/createGlyphFirstPersonControls";
 export type {
-  GlyphcssFirstPersonControlsOptions,
-  GlyphcssFirstPersonControlsHandle,
-} from "./api/createGlyphcssFirstPersonControls";
+  GlyphFirstPersonControlsOptions,
+  GlyphFirstPersonControlsHandle,
+} from "./api/createGlyphFirstPersonControls";
 
 // ── Mesh finders ──────────────────────────────────────────────────
-export { findGlyphcssMeshHandle, findMeshUnderPoint, pointInMeshElement } from "./api/meshFinders";
+export { findGlyphMeshHandle, findMeshUnderPoint, pointInMeshElement } from "./api/meshFinders";
 
 // ── Event types ───────────────────────────────────────────────────
 export type {
-  GlyphcssPointerEvent,
-  GlyphcssMouseEvent,
-  GlyphcssWheelEvent,
-  GlyphcssEventHandler,
+  GlyphPointerEvent,
+  GlyphMouseEvent,
+  GlyphWheelEvent,
+  GlyphEventHandler,
 } from "./api/events";
 
 // ── Hotspot projection (hit layer) ────────────────────────────────
@@ -95,16 +93,16 @@ export type {
 } from "./api/rasterizeContext";
 
 // ── Style injection ───────────────────────────────────────────────
-export { injectGlyphcssBaseStyles } from "./styles/styles";
+export { injectGlyphBaseStyles } from "./styles/styles";
 
 // ── Custom element classes (without auto-registering) ─────────────
-export { GlyphcssSceneElement } from "./elements/GlyphcssSceneElement";
-export { GlyphcssMeshElement } from "./elements/GlyphcssMeshElement";
-export { GlyphcssHotspotElement } from "./elements/GlyphcssHotspotElement";
-export { GlyphcssPerspectiveCameraElement } from "./elements/GlyphcssPerspectiveCameraElement";
-export { GlyphcssOrthographicCameraElement } from "./elements/GlyphcssOrthographicCameraElement";
-export { GlyphcssOrbitControlsElement } from "./elements/GlyphcssOrbitControlsElement";
-export { GlyphcssMapControlsElement } from "./elements/GlyphcssMapControlsElement";
+export { GlyphSceneElement } from "./elements/GlyphSceneElement";
+export { GlyphMeshElement } from "./elements/GlyphMeshElement";
+export { GlyphHotspotElement } from "./elements/GlyphHotspotElement";
+export { GlyphPerspectiveCameraElement } from "./elements/GlyphPerspectiveCameraElement";
+export { GlyphOrthographicCameraElement } from "./elements/GlyphOrthographicCameraElement";
+export { GlyphOrbitControlsElement } from "./elements/GlyphOrbitControlsElement";
+export { GlyphMapControlsElement } from "./elements/GlyphMapControlsElement";
 
 // ── Re-exports from @glyphcss/core ───────────────────────────────
 export * from "@glyphcss/core";
diff --git a/packages/glyphcss/src/render/rasterize.test.ts b/packages/glyphcss/src/render/rasterize.test.ts
index 0aa0ff4e..cb38a0e9 100644
--- a/packages/glyphcss/src/render/rasterize.test.ts
+++ b/packages/glyphcss/src/render/rasterize.test.ts
@@ -1,7 +1,7 @@
 import { describe, it, expect } from "vitest";
 import { rasterize } from "./rasterize";
 import { buildRasterizeContext } from "../api/rasterizeContext";
-import { createGlyphcssPerspectiveCamera } from "../api/createGlyphcssCamera";
+import { createGlyphPerspectiveCamera } from "../api/createGlyphCamera";
 import type { Polygon } from "@glyphcss/core";
 
 /** Simple unit cube — 12 triangular polygons (2 per face × 6 faces). */
@@ -35,7 +35,7 @@ function makeCubePolygons(): Polygon[] {
 
 describe("rasterize", () => {
   it("renders a solid cube to non-empty text", () => {
-    const camera = createGlyphcssPerspectiveCamera({ rotX: 0.4, rotY: 0.5, scale: 0.35 });
+    const camera = createGlyphPerspectiveCamera({ rotX: 0.4, rotY: 0.5, zoom: 0.35, distance: 100 });
     const ctx = buildRasterizeContext({
       camera,
       grid: { cols: 40, rows: 20, cellAspect: 2.0 },
@@ -52,7 +52,7 @@ describe("rasterize", () => {
   });
 
   it("renders wireframe mode to non-empty text", () => {
-    const camera = createGlyphcssPerspectiveCamera({ scale: 0.3 });
+    const camera = createGlyphPerspectiveCamera({ zoom: 0.3, distance: 100 });
     const ctx = buildRasterizeContext({
       camera,
       grid: { cols: 30, rows: 15, cellAspect: 2.0 },
@@ -65,7 +65,7 @@ describe("rasterize", () => {
   });
 
   it("renders with colors producing html spans", () => {
-    const camera = createGlyphcssPerspectiveCamera({ scale: 0.35 });
+    const camera = createGlyphPerspectiveCamera({ zoom: 0.35, distance: 100 });
     const ctx = buildRasterizeContext({
       camera,
       grid: { cols: 40, rows: 20, cellAspect: 2.0 },
@@ -80,7 +80,7 @@ describe("rasterize", () => {
 
   it("produces exactly (rows - 1) newlines for a non-empty render", () => {
     const rows = 10;
-    const camera = createGlyphcssPerspectiveCamera({ scale: 0.3 });
+    const camera = createGlyphPerspectiveCamera({ zoom: 0.3, distance: 100 });
     const ctx = buildRasterizeContext({
       camera,
       grid: { cols: 20, rows, cellAspect: 2.0 },
diff --git a/packages/glyphcss/src/render/rasterize.ts b/packages/glyphcss/src/render/rasterize.ts
index 07657cb2..1557b44a 100644
--- a/packages/glyphcss/src/render/rasterize.ts
+++ b/packages/glyphcss/src/render/rasterize.ts
@@ -1,6 +1,6 @@
 import type { RasterizeContext } from "../api/rasterizeContext";
-import type { Vec3 } from "@glyphcss/core";
-import { SOLID_RAMP, getWireframeGlyphs } from "./ramps";
+import type { Polygon, Vec3 } from "@glyphcss/core";
+import { getWireframeGlyphs } from "./ramps";
 
 /**
  * Render the scene to a string.
@@ -51,20 +51,19 @@ function rasterizeSolid(
   rows: number,
   cellAspect: number,
 ): string {
-  const { camera, polygons, directionalLight, ambientLight } = scene;
+  const { camera, polygons, directionalLight, ambientLight, smoothShading, creaseAngle } = scene;
   // Pick the solid ramp from the active palette so the glyph palette dropdown
   // affects solid mode too — not just wireframe.
   const ramp = getWireframeGlyphs(scene.glyphPalette).solid;
+  const rampMax = ramp.length - 1;
 
   // Glyph buffer: one char per cell (space = empty).
   const glyphBuf: string[] = new Array(cols * rows).fill(" ");
   const useColors = scene.useColors;
   const colorBuf: (string | null)[] | null = useColors ? new Array(cols * rows).fill(null) : null;
-  // Depth buffer: -Infinity = nothing drawn yet. In our camera convention,
-  // higher `r[2]` = closer to viewer (perspective: persp > 1 for z > 0; ortho:
-  // higher z is "in front"; FPV: ahead-of-eye is z<0, with closest being the
-  // largest = least-negative z). So newer triangles win when their depth is
-  // GREATER than the existing buffer entry.
+  // Depth buffer: -Infinity = nothing drawn yet. Higher `r[2]` = closer to
+  // viewer in our camera convention, so newer triangles win when their
+  // depth is GREATER than the existing buffer entry.
   const depthBuf = new Float64Array(cols * rows).fill(-Infinity);
 
   // Normalize the light direction once.
@@ -76,15 +75,23 @@ function rasterizeSolid(
   const keyRgb = hexToRgb(directionalLight.color ?? "#ffffff");
   const ambRgb = hexToRgb(ambientLight.color ?? "#ffffff");
 
-  for (const poly of polygons) {
+  // Per-vertex normals for Gouraud shading. `null` when flat-shading.
+  // Index: [polyIdx][vertIdx] → normalized Vec3.
+  const vertexNormals = smoothShading && creaseAngle > 0
+    ? computeVertexNormals(polygons, creaseAngle)
+    : null;
+
+  for (let polyIdx = 0; polyIdx < polygons.length; polyIdx++) {
+    const poly = polygons[polyIdx]!;
     const verts = poly.vertices;
     if (verts.length < 3) continue;
     // Fan-triangulate: (v[0], v[i], v[i+1]) for i in [1, N-2].
     // For N=3 this produces exactly one triangle.
     for (let fanIdx = 1; fanIdx < verts.length - 1; fanIdx++) {
-      const v0 = verts[0]! as Vec3;
-      const v1 = verts[fanIdx]! as Vec3;
-      const v2 = verts[fanIdx + 1]! as Vec3;
+      const vi0 = 0, vi1 = fanIdx, vi2 = fanIdx + 1;
+      const v0 = verts[vi0]! as Vec3;
+      const v1 = verts[vi1]! as Vec3;
+      const v2 = verts[vi2]! as Vec3;
 
       const pa = camera.project(v0, cols, rows, cellAspect);
       const pb = camera.project(v1, cols, rows, cellAspect);
@@ -92,42 +99,68 @@ function rasterizeSolid(
       // NaN-cull: any vertex behind the near plane → skip triangle.
       if (pa[0] !== pa[0] || pb[0] !== pb[0] || pc[0] !== pc[0]) continue;
 
-      // Compute face normal in world space (before projection) for Lambert.
+      // Face normal in world space (for flat shading or as a fallback when
+      // vertex normals aren't computed).
       const ux = v1[0] - v0[0], uy = v1[1] - v0[1], uz = v1[2] - v0[2];
       const vvx = v2[0] - v0[0], vvy = v2[1] - v0[1], vvz = v2[2] - v0[2];
-      const nx = uy * vvz - uz * vvy;
-      const ny = uz * vvx - ux * vvz;
-      const nz = ux * vvy - uy * vvx;
-      const nLen = Math.hypot(nx, ny, nz) || 1;
-      const dot = (nx * lx + ny * ly + nz * lz) / nLen;
-      const keyFactor = Math.max(0, dot) * keyIntensity;
-      const intensity = Math.min(1, Math.max(0, ambIntensity + keyFactor));
-      const glyphIdx = Math.min(ramp.length - 1, (intensity * (ramp.length - 1)) | 0);
-      const glyph = ramp[glyphIdx]!;
+      const fnx = uy * vvz - uz * vvy;
+      const fny = uz * vvx - ux * vvz;
+      const fnz = ux * vvy - uy * vvx;
+      const fnLen = Math.hypot(fnx, fny, fnz) || 1;
+      const fnxN = fnx / fnLen, fnyN = fny / fnLen, fnzN = fnz / fnLen;
+
+      // Pick per-vertex normals. Smooth-shaded → look up from precomputed
+      // table. Flat-shaded → all three vertices use the face normal.
+      let nAx: number, nAy: number, nAz: number;
+      let nBx: number, nBy: number, nBz: number;
+      let nCx: number, nCy: number, nCz: number;
+      if (vertexNormals) {
+        const polyNormals = vertexNormals[polyIdx]!;
+        const nA = polyNormals[vi0]!, nB = polyNormals[vi1]!, nC = polyNormals[vi2]!;
+        nAx = nA[0]; nAy = nA[1]; nAz = nA[2];
+        nBx = nB[0]; nBy = nB[1]; nBz = nB[2];
+        nCx = nC[0]; nCy = nC[1]; nCz = nC[2];
+      } else {
+        nAx = nBx = nCx = fnxN;
+        nAy = nBy = nCy = fnyN;
+        nAz = nBz = nCz = fnzN;
+      }
 
-      // Per-channel light-mix only when colors are enabled. Otherwise pass null
-      // so scanFillTriangle skips the color write and the emitter skips spans.
+      // Per-vertex Lambert intensity (ambient + clamped key).
+      const dotA = nAx * lx + nAy * ly + nAz * lz;
+      const dotB = nBx * lx + nBy * ly + nBz * lz;
+      const dotC = nCx * lx + nCy * ly + nCz * lz;
+      const iA = Math.min(1, ambIntensity + Math.max(0, dotA) * keyIntensity);
+      const iB = Math.min(1, ambIntensity + Math.max(0, dotB) * keyIntensity);
+      const iC = Math.min(1, ambIntensity + Math.max(0, dotC) * keyIntensity);
+
+      // Triangle color: tint poly.color by the AVERAGE of the three vertex
+      // intensities. Keeping a single color per triangle preserves run-
+      // coalescing in `solidBufToString` — a per-cell color would force one
+      //  per cell and hurt innerHTML parse time. The intensity gradient
+      // already lives in the glyph selection per cell.
       let litColor: string | null = null;
       if (useColors) {
+        const avgI = (iA + iB + iC) / 3;
+        const avgKey = Math.max(0, avgI - ambIntensity);
         const triRgb = poly.color ? hexToRgb(poly.color) : [255, 255, 255];
-        const tintR = ambIntensity * ambRgb[0] / 255 + keyFactor * keyRgb[0] / 255;
-        const tintG = ambIntensity * ambRgb[1] / 255 + keyFactor * keyRgb[1] / 255;
-        const tintB = ambIntensity * ambRgb[2] / 255 + keyFactor * keyRgb[2] / 255;
+        const tintR = ambIntensity * ambRgb[0] / 255 + avgKey * keyRgb[0] / 255;
+        const tintG = ambIntensity * ambRgb[1] / 255 + avgKey * keyRgb[1] / 255;
+        const tintB = ambIntensity * ambRgb[2] / 255 + avgKey * keyRgb[2] / 255;
         const litR = Math.min(255, triRgb[0] * tintR);
         const litG = Math.min(255, triRgb[1] * tintG);
         const litB = Math.min(255, triRgb[2] * tintB);
         litColor = `#${toHex2(litR)}${toHex2(litG)}${toHex2(litB)}`;
       }
 
-      // Average depth for the triangle (camera-space Z, lower = closer).
-      const depth = (pa[2] + pb[2] + pc[2]) / 3;
-
-      // Scan-fill the projected triangle in grid coords.
+      // Scan-fill the projected triangle. Depth and intensity are both
+      // interpolated per cell via barycentric coordinates so adjacent
+      // triangles on a curved surface never disagree at their shared edge.
       scanFillTriangle(
-        pa[0], pa[1],
-        pb[0], pb[1],
-        pc[0], pc[1],
-        depth, glyph, litColor,
+        pa[0], pa[1], pa[2], iA,
+        pb[0], pb[1], pb[2], iB,
+        pc[0], pc[1], pc[2], iC,
+        ramp, rampMax, litColor,
         glyphBuf, colorBuf, depthBuf,
         cols, rows,
       );
@@ -138,16 +171,43 @@ function rasterizeSolid(
 }
 
 /**
- * Scan-fill a triangle in grid space. Uses a top-left fill convention with
- * per-scanline edge interpolation. Overwrites cells only when `depth` is
- * lower than the existing depth buffer entry.
+ * Half-space triangle rasterizer with per-pixel barycentric depth.
+ *
+ * For each cell in the triangle's bounding box, evaluate three edge functions.
+ * A cell is inside iff all three weights have the same sign as the signed
+ * 2× triangle area. The weights also give barycentric coordinates → we
+ * interpolate per-vertex depth so adjacent triangles on a curved surface
+ * never disagree at a shared edge (the previous per-triangle average depth
+ * flipped winners at angle-dependent epsilons and showed up as dark bands
+ * across solid surfaces).
+ *
+ * Shared edges between adjacent triangles get drawn twice (no top-left bias).
+ * That's fine: both triangles write the same per-pixel depth at the shared
+ * edge, so whichever is rasterized second either confirms or correctly loses
+ * the depth test. We can't use the GPU's fixed-point top-left bias trick here
+ * because our edge functions are in floating point — a constant −1 subtracted
+ * from a fractional weight near 0 turns valid interior pixels (w ≈ 0.4) into
+ * "outside" (w ≈ −0.6) and punches holes through every triangle.
+ */
+/**
+ * 4×4 Bayer ordered-dither thresholds, normalized to (0, 1). Indexed by
+ * `(row & 3) * 4 + (col & 3)`. The `+0.5` recentring keeps every cell strictly
+ * inside the open interval so neither boundary glyph is favored when intensity
+ * lands exactly on a ramp step.
  */
+const BAYER_4X4 = new Float64Array([
+  ( 0 + 0.5) / 16, ( 8 + 0.5) / 16, ( 2 + 0.5) / 16, (10 + 0.5) / 16,
+  (12 + 0.5) / 16, ( 4 + 0.5) / 16, (14 + 0.5) / 16, ( 6 + 0.5) / 16,
+  ( 3 + 0.5) / 16, (11 + 0.5) / 16, ( 1 + 0.5) / 16, ( 9 + 0.5) / 16,
+  (15 + 0.5) / 16, ( 7 + 0.5) / 16, (13 + 0.5) / 16, ( 5 + 0.5) / 16,
+]);
+
 function scanFillTriangle(
-  ax: number, ay: number,
-  bx: number, by: number,
-  cx: number, cy: number,
-  depth: number,
-  glyph: string,
+  ax: number, ay: number, az: number, ia: number,
+  bx: number, by: number, bz: number, ib: number,
+  cx: number, cy: number, cz: number, ic: number,
+  ramp: string[],
+  rampMax: number,
   color: string | null,
   glyphBuf: string[],
   colorBuf: (string | null)[] | null,
@@ -155,45 +215,140 @@ function scanFillTriangle(
   cols: number,
   rows: number,
 ): void {
-  // Sort vertices by row (top → bottom).
-  let x0 = ax, y0 = ay, x1 = bx, y1 = by, x2 = cx, y2 = cy;
-  if (y1 < y0) { let t = x0; x0 = x1; x1 = t; t = y0; y0 = y1; y1 = t; }
-  if (y2 < y0) { let t = x0; x0 = x2; x2 = t; t = y0; y0 = y2; y2 = t; }
-  if (y2 < y1) { let t = x1; x1 = x2; x2 = t; t = y1; y1 = y2; y2 = t; }
+  // Signed 2× area. Sign tells us screen-space winding.
+  const area2 = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
+  if (area2 === 0) return;
+  // Backface cull. Glyphcss's camera projects world-CCW polygons (the input
+  // convention is "CCW from outside") to screen-CW under our row convention
+  // (positive r[1] → larger row → visually below center), so front-facing
+  // triangles produce `area2 < 0`. Drop back faces. The asciss-derived
+  // rotateVec3 also swaps the X/Y input axes, which contributes to the
+  // orientation flip.
+  if (area2 > 0) return;
+  const invArea2 = 1 / area2;
+  const ccw = area2 > 0;
 
-  const rowTop = Math.max(0, Math.ceil(y0));
-  const rowBot = Math.min(rows - 1, Math.floor(y2));
-  if (rowTop > rowBot) return;
+  // Bounding box clamped to grid.
+  let minX = ax < bx ? ax : bx; if (cx < minX) minX = cx;
+  let maxX = ax > bx ? ax : bx; if (cx > maxX) maxX = cx;
+  let minY = ay < by ? ay : by; if (cy < minY) minY = cy;
+  let maxY = ay > by ? ay : by; if (cy > maxY) maxY = cy;
+  const colLeft = Math.max(0, Math.ceil(minX));
+  const colRight = Math.min(cols - 1, Math.floor(maxX));
+  const rowTop = Math.max(0, Math.ceil(minY));
+  const rowBot = Math.min(rows - 1, Math.floor(maxY));
+  if (colLeft > colRight || rowTop > rowBot) return;
 
   for (let row = rowTop; row <= rowBot; row++) {
-    // Compute left and right X for this scanline by interpolating along the two
-    // relevant edges. The long edge spans the full triangle height; the short
-    // edges span top→mid and mid→bottom.
-    const t = (row - y0) / (y2 - y0 || 1);
-    const xLong = x0 + (x2 - x0) * t;
-
-    let xShort: number;
-    if (row < y1) {
-      const t2 = (row - y0) / (y1 - y0 || 1);
-      xShort = x0 + (x1 - x0) * t2;
-    } else {
-      const t2 = (row - y1) / (y2 - y1 || 1);
-      xShort = x1 + (x2 - x1) * t2;
-    }
+    const py = row;
+    for (let col = colLeft; col <= colRight; col++) {
+      const px = col;
+      // Signed 2× areas of sub-triangles (P,B,C), (P,C,A), (P,A,B). Sum = area2.
+      // wA = weight of vertex A, wB = weight of B, wC = weight of C.
+      const wA = (bx - px) * (cy - py) - (by - py) * (cx - px);
+      const wB = (cx - px) * (ay - py) - (cy - py) * (ax - px);
+      const wC = (ax - px) * (by - py) - (ay - py) * (bx - px);
+      // Inside test: all three weights share sign of area2 (≥ 0 inclusive).
+      if (ccw ? (wA < 0 || wB < 0 || wC < 0) : (wA > 0 || wB > 0 || wC > 0)) continue;
 
-    const colL = Math.max(0, Math.ceil(Math.min(xLong, xShort)));
-    const colR = Math.min(cols - 1, Math.floor(Math.max(xLong, xShort)));
-    for (let col = colL; col <= colR; col++) {
+      // Per-pixel depth via barycentric interpolation.
+      const pixelDepth = (wA * az + wB * bz + wC * cz) * invArea2;
       const idx = row * cols + col;
-      if (depth > depthBuf[idx]!) {
-        depthBuf[idx] = depth;
-        glyphBuf[idx] = glyph;
+      if (pixelDepth > depthBuf[idx]!) {
+        depthBuf[idx] = pixelDepth;
+        // Per-pixel intensity → per-pixel glyph. Two things happen here:
+        //   1. Smooth shading: adjacent triangles' shared edge has the same
+        //      interpolated intensity on both sides, so the glyph transition
+        //      crosses the edge smoothly instead of stepping.
+        //   2. Bayer ordered dithering: pick between two adjacent ramp glyphs
+        //      based on a 4×4 threshold matrix. When the sub-ramp fraction
+        //      exceeds the cell's threshold, step up to the brighter glyph —
+        //      producing a stippled gradient that reads as continuous from a
+        //      distance and breaks up the visible contour bands between ramp
+        //      steps.
+        const intensity = (wA * ia + wB * ib + wC * ic) * invArea2;
+        const clamped = intensity < 0 ? 0 : intensity > 1 ? 1 : intensity;
+        const rampPos = clamped * rampMax;
+        const lower = rampPos | 0;
+        const frac = rampPos - lower;
+        const threshold = BAYER_4X4[(row & 3) * 4 + (col & 3)]!;
+        const glyphIdx = frac > threshold && lower < rampMax ? lower + 1 : lower;
+        glyphBuf[idx] = ramp[glyphIdx > rampMax ? rampMax : glyphIdx]!;
         if (colorBuf) colorBuf[idx] = color;
       }
     }
   }
 }
 
+/**
+ * Compute per-polygon, per-vertex smoothed normals for Gouraud shading.
+ *
+ * Vertices are bucketed by their exact world-space position (string key).
+ * Within each bucket, a polygon's vertex normal is the average of every
+ * adjacent polygon's face normal whose angle to *this* polygon's face normal
+ * is ≤ creaseAngle. This preserves sharp creases (cube corners, hard edges)
+ * while smoothing across genuine curved surfaces (bread crust, sphere).
+ *
+ * Returned shape: `out[polyIdx][vertIdx]` → normalized Vec3.
+ * O(N + E) where N = polygons, E = total polygon-vertex pairs sharing a position.
+ */
+function computeVertexNormals(polygons: Polygon[], creaseAngleDeg: number): Vec3[][] {
+  const n = polygons.length;
+  // 1. Compute one face normal per polygon (from its first three vertices).
+  //    Non-planar polygons get an approximation; acceptable for shading.
+  const faceNormals: Vec3[] = new Array(n);
+  for (let i = 0; i < n; i++) {
+    const v = polygons[i]!.vertices;
+    if (v.length < 3) { faceNormals[i] = [0, 0, 0]; continue; }
+    const a = v[0]!, b = v[1]!, c = v[2]!;
+    const ux = b[0] - a[0], uy = b[1] - a[1], uz = b[2] - a[2];
+    const vx = c[0] - a[0], vy = c[1] - a[1], vz = c[2] - a[2];
+    const nx = uy * vz - uz * vy;
+    const ny = uz * vx - ux * vz;
+    const nz = ux * vy - uy * vx;
+    const len = Math.hypot(nx, ny, nz) || 1;
+    faceNormals[i] = [nx / len, ny / len, nz / len];
+  }
+
+  // 2. Bucket polygons by shared vertex position.
+  const positionMap = new Map();
+  for (let i = 0; i < n; i++) {
+    const verts = polygons[i]!.vertices;
+    for (let v = 0; v < verts.length; v++) {
+      const p = verts[v]!;
+      const key = `${p[0]},${p[1]},${p[2]}`;
+      let arr = positionMap.get(key);
+      if (!arr) { arr = []; positionMap.set(key, arr); }
+      // Dedup self-add: a polygon with a repeated vertex shouldn't double-count.
+      if (arr.length === 0 || arr[arr.length - 1] !== i) arr.push(i);
+    }
+  }
+
+  // 3. For each polygon-vertex, average neighbors within the crease cone.
+  const cosThresh = Math.cos((creaseAngleDeg * Math.PI) / 180);
+  const out: Vec3[][] = new Array(n);
+  for (let i = 0; i < n; i++) {
+    const verts = polygons[i]!.vertices;
+    const myN = faceNormals[i]!;
+    const polyOut: Vec3[] = new Array(verts.length);
+    for (let v = 0; v < verts.length; v++) {
+      const p = verts[v]!;
+      const sharers = positionMap.get(`${p[0]},${p[1]},${p[2]}`)!;
+      let nx = 0, ny = 0, nz = 0;
+      for (let s = 0; s < sharers.length; s++) {
+        const otherI = sharers[s]!;
+        const oN = faceNormals[otherI]!;
+        const dot = myN[0] * oN[0] + myN[1] * oN[1] + myN[2] * oN[2];
+        if (dot >= cosThresh) { nx += oN[0]; ny += oN[1]; nz += oN[2]; }
+      }
+      const len = Math.hypot(nx, ny, nz) || 1;
+      polyOut[v] = [nx / len, ny / len, nz / len];
+    }
+    out[i] = polyOut;
+  }
+  return out;
+}
+
 function solidBufToString(glyphBuf: string[], colorBuf: (string | null)[] | null, cols: number, rows: number): string {
   // Coalesce runs of same-color consecutive cells into one  per run.
   // For ~5k colored cells with average run length 5, this drops total 
diff --git a/packages/glyphcss/src/styles/styles.ts b/packages/glyphcss/src/styles/styles.ts
index b0a890b7..0ad3ec67 100644
--- a/packages/glyphcss/src/styles/styles.ts
+++ b/packages/glyphcss/src/styles/styles.ts
@@ -3,21 +3,29 @@
  * Provides minimal positioning and monospace rendering for the ASCII output.
  * Full terminal aesthetic CSS lands in Phase 5.
  */
-const GLYPHCSS_STYLE_ID = "glyphcss-styles";
+const GLYPH_STYLE_ID = "glyph-styles";
 
-export function injectGlyphcssBaseStyles(doc?: Document): void {
+export function injectGlyphBaseStyles(doc?: Document): void {
   const target = doc ?? (typeof document !== "undefined" ? document : undefined);
-  if (!target || target.getElementById(GLYPHCSS_STYLE_ID)) return;
+  if (!target || target.getElementById(GLYPH_STYLE_ID)) return;
   const style = target.createElement("style");
-  style.id = GLYPHCSS_STYLE_ID;
+  style.id = GLYPH_STYLE_ID;
   style.textContent = CORE_BASE_STYLES;
   target.head.appendChild(style);
 }
 
 const CORE_BASE_STYLES = `
+/* ── React / Vue host wrapper ────────────────────────────────────────── */
+
+.glyph-host {
+  /* Fill the camera wrapper so autoSize can observe a non-zero height. */
+  width: 100%;
+  height: 100%;
+}
+
 /* ── Glyphcss scene container ───────────────────────────────────────── */
 
-.glyphcss-scene {
+.glyph-scene {
   position: relative;
   display: block;
   overflow: hidden;
@@ -26,8 +34,12 @@ const CORE_BASE_STYLES = `
 
 /* ── ASCII output 
 ──────────────────────────────────────────────── */
 
-.glyphcss-scene .glyphcss-output {
-  display: block;
+.glyph-scene .glyph-output {
+  /* inline-block so the box shrinks to the text's natural width. With display:
+     block the pre inherits parent width, leaving empty space on the right, and
+     cellW = preRect.width / cols overshoots the actual character cell — placing
+     hotspots to the right of the rasterized glyph they're supposed to anchor. */
+  display: inline-block;
   margin: 0;
   padding: 0;
   font-family: monospace;
@@ -41,15 +53,24 @@ const CORE_BASE_STYLES = `
 
 /* ── Hotspot overlay ─────────────────────────────────────────────────── */
 
-.glyphcss-scene .glyphcss-hotspot-layer {
+.glyph-scene .glyph-hotspot-layer {
   position: absolute;
   inset: 0;
   pointer-events: none;
+  /* Isolate the stacking context so per-hotspot z-index values (derived from
+     camera depth, sometimes negative) stay scoped INSIDE the layer. Without
+     this, a negative-z-index hotspot would render below the sibling 
,
+     hidden behind the rasterized glyphs. */
+  isolation: isolate;
 }
 
-.glyphcss-scene .glyphcss-hotspot {
+.glyph-scene .glyph-hotspot {
   position: absolute;
   pointer-events: all;
   cursor: pointer;
+  /* Center the label on the projected anchor point rather than anchoring its
+     top-left corner there. Without this, padding / label width visually offset
+     the content from the 3D vertex being labelled. */
+  transform: translate(-50%, -50%);
 }
 `;
diff --git a/packages/glyphcss/test/fixtures/single-triangle.txt b/packages/glyphcss/test/fixtures/single-triangle.txt
index 10eb9316..ca07f96d 100644
--- a/packages/glyphcss/test/fixtures/single-triangle.txt
+++ b/packages/glyphcss/test/fixtures/single-triangle.txt
@@ -1,20 +1,20 @@
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
+@%%%@%%%@%%%@%%%@%                      
+%%%%%%%%%%%%%%%%%%%%                    
+%%@%%%@%%%@%%%@%%%@%%%                  
+%%%%%%%%%%%%%%%%%%%%%%%%                
+@%%%@%%%@%%%@%%%@%%%@%%%@%%             
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%           
+%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@         
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%       
+@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%     
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%  
+%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%         
+%%%%%%%%%%%%%%%%%%%                     
+%%@%%%@                                 
                                         
\ No newline at end of file
diff --git a/packages/glyphcss/test/fixtures/tetrahedron.txt b/packages/glyphcss/test/fixtures/tetrahedron.txt
index 10eb9316..42b16d35 100644
--- a/packages/glyphcss/test/fixtures/tetrahedron.txt
+++ b/packages/glyphcss/test/fixtures/tetrahedron.txt
@@ -1,20 +1,20 @@
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
+                      @@@+*             
+                    @@@@@+++            
+                  @@@@@@@+*+            
+                 @@@@@@@@++++           
+               @@@@@@@@@*+*+*+          
+             @@@@@@@@@@@++++++          
+            @@@@@@@@@@@@*+*+*+*         
+          @@@@@@@@@@@@@=+++++++         
+        @@@@@@@@@@@@===-==*+*+*+        
+       @@@@@@@@@@=-=-=-=-=-++++++       
+     @@@@@@@@@===-===-===-==*+*+*       
+   @@@@@@@@=-=-=-=-=-=-=-=-=-+++++      
+  @@@@@@===-===-===-===-===-==*+*+      
+@@@@@=-=-=-=-=-=-=-=-=-=-=-=-=-=+++     
+@@===-===-===-===-===-===-===-===+*+    
+-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=++    
+===-===-===-===-===-===-===-===-===+*   
+-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=+   
+                     -===-===-===-===+  
                                         
\ No newline at end of file
diff --git a/packages/glyphcss/test/fixtures/unit-cube.txt b/packages/glyphcss/test/fixtures/unit-cube.txt
index 16735269..5fe550fe 100644
--- a/packages/glyphcss/test/fixtures/unit-cube.txt
+++ b/packages/glyphcss/test/fixtures/unit-cube.txt
@@ -1,20 +1,20 @@
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
-                   ----                 
-                 ------                 
-                   ----                 
-                     -                  
-                                        
-                                        
-                                        
-                                        
-                                        
-                                        
\ No newline at end of file
+@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+   %%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%%%@%
+      %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
\ No newline at end of file
diff --git a/packages/glyphcss/test/parity-asciss.test.ts b/packages/glyphcss/test/solid-snapshot.test.ts
similarity index 70%
rename from packages/glyphcss/test/parity-asciss.test.ts
rename to packages/glyphcss/test/solid-snapshot.test.ts
index 9b832f04..f4aca9fa 100644
--- a/packages/glyphcss/test/parity-asciss.test.ts
+++ b/packages/glyphcss/test/solid-snapshot.test.ts
@@ -1,33 +1,29 @@
 /**
- * Byte-parity test: glyphcss's rasterize() must produce byte-identical output
- * to asciss's rasterize() for the same input.
+ * Snapshot test for the solid-mode rasterizer.
  *
- * Only solid mode is tested — wireframe uses Math.random() for glyph selection
- * and cannot be deterministically byte-compared. Algorithm bytes are unchanged
- * since rasterize.ts was copied verbatim from asciss (only import path updated).
+ * Originally this asserted byte-identical output to asciss. The rasterizer has
+ * since diverged: half-space rasterization with per-pixel barycentric depth
+ * replaced asciss's scanline + per-triangle average depth, fixing
+ * angle-dependent silhouette artifacts (the old scheme picked a single average
+ * depth per triangle, which flipped winners between adjacent surface triangles
+ * with very small camera rotations and showed up as dark bands cutting through
+ * solid shapes).
  *
- * Fixtures were generated by /tmp/gen-asciss-fixtures.mjs using asciss's own
- * rasterize() with fixed camera+grid+mode+lights parameters.
- */
-/**
- * Byte-parity test: glyphcss's rasterize() must produce byte-identical output
- * to asciss's rasterize() for the same input.
- *
- * Only solid mode is tested — wireframe uses Math.random() for glyph selection
- * and cannot be deterministically byte-compared. Algorithm bytes are unchanged
- * since rasterize.ts was copied verbatim from asciss (only import path updated).
- *
- * Fixtures were generated by /tmp/gen-asciss-fixtures.mjs using asciss's own
- * rasterize() with fixed camera+grid+mode+lights parameters.
+ * The fixtures are now snapshots of the current rasterizer's output for three
+ * reference meshes (unit cube, single triangle, tetrahedron) and guard against
+ * accidental regressions. Only solid mode is snapshotted — wireframe uses
+ * Math.random() for glyph selection and is not deterministic.
  *
- * Triangle inputs (N=3 polygons) fan-triangulate to exactly one triangle each.
+ * To regenerate after an intentional rasterizer change: write a one-off
+ * vitest that calls rasterize() with the parameters below and writes the
+ * output to test/fixtures/.txt, run it once, then delete it.
  */
 import { describe, it, expect } from "vitest";
 import { readFileSync } from "fs";
 import { resolve } from "path";
 import { rasterize } from "../src/render/rasterize";
 import { buildRasterizeContext } from "../src/api/rasterizeContext";
-import { createGlyphcssPerspectiveCamera } from "../src/api/createGlyphcssCamera";
+import { createGlyphPerspectiveCamera } from "../src/api/createGlyphCamera";
 import type { Polygon, Vec3 } from "@glyphcss/core";
 
 const FIXTURE_DIR = resolve(__dirname, "fixtures");
@@ -81,7 +77,7 @@ function makeTetrahedronPolygons(): Polygon[] {
 
 describe("parity: glyphcss rasterize vs asciss fixtures", () => {
   it("unit cube (solid, no colors) matches asciss output byte-for-byte", () => {
-    const camera = createGlyphcssPerspectiveCamera({ rotX: 0.4, rotY: 0.5, zoom: 0.35 });
+    const camera = createGlyphPerspectiveCamera({ rotX: 0.4, rotY: 0.5, zoom: 0.35, distance: 100 });
     const ctx = buildRasterizeContext({
       camera,
       grid: GRID,
@@ -98,7 +94,7 @@ describe("parity: glyphcss rasterize vs asciss fixtures", () => {
   });
 
   it("single triangle (solid, no colors) matches asciss output byte-for-byte", () => {
-    const camera = createGlyphcssPerspectiveCamera({ rotX: 0.2, rotY: 0.3, zoom: 0.5 });
+    const camera = createGlyphPerspectiveCamera({ rotX: 0.2, rotY: 0.3, zoom: 0.5, distance: 100 });
     const ctx = buildRasterizeContext({
       camera,
       grid: GRID,
@@ -115,7 +111,7 @@ describe("parity: glyphcss rasterize vs asciss fixtures", () => {
   });
 
   it("tetrahedron (solid, no colors) matches asciss output byte-for-byte", () => {
-    const camera = createGlyphcssPerspectiveCamera({ rotX: 0.3, rotY: 0.8, zoom: 0.4 });
+    const camera = createGlyphPerspectiveCamera({ rotX: 0.3, rotY: 0.8, zoom: 0.4, distance: 100 });
     const ctx = buildRasterizeContext({
       camera,
       grid: GRID,
diff --git a/packages/react/README.md b/packages/react/README.md
index 68f154ac..aeb37a79 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -16,49 +16,60 @@ Requires React 18 or 19 as a peer dependency.
 
 ```tsx
 import {
-  GlyphcssScene,
-  GlyphcssCamera,
-  GlyphcssMesh,
-  GlyphcssOrbitControls,
+  GlyphCamera,
+  GlyphScene,
+  GlyphMesh,
+  GlyphOrbitControls,
 } from "@glyphcss/react";
 
 export function App() {
   return (
-    
-      
-        
-        
-      
-    
+    
+      
+        
+        
+      
+    
   );
 }
 ```
 
 ## Component reference
 
-### ``
+### `` / ``
 
-Root of every React glyphcss render tree. Owns the `
` output element and rasterizes all meshes on camera or state change.
+Orthographic camera. `GlyphCamera` is the ergonomic default alias. Wraps
+`` — the camera is always the outermost element.
 
 | Prop | Type | Default | Description |
 |---|---|---|---|
-| `cols` | `number` | `80` | Grid width in character cells |
-| `rows` | `number` | `40` | Grid height in character cells |
-| `mode` | `"wireframe" \| "solid" \| "voxel"` | `"solid"` | Render mode |
-| `className` | `string` | — | CSS class on the `
` container |
+| `rotX` | `number` | `0` | Tilt in radians |
+| `rotY` | `number` | `0` | Azimuth in radians |
+| `zoom` | `number` | `0.4` | Mesh fraction of min(cols, rows) |
 
-### `` / ``
+### ``
 
-Perspective camera. `GlyphcssCamera` is the ergonomic alias.
+Perspective (foreshortened) camera. Required for ``.
 
 | Prop | Type | Default | Description |
 |---|---|---|---|
-| `fov` | `number` | `60` | Vertical field of view in degrees |
-| `rotX` | `number` | `35` | Tilt in degrees |
-| `rotY` | `number` | `45` | Azimuth in degrees |
-| `zoom` | `number` | `1` | Zoom multiplier |
+| `rotX` | `number` | `0` | Tilt in radians |
+| `rotY` | `number` | `0` | Azimuth in radians |
+| `distance` | `number` | `3` | Perspective distance in world units |
+| `zoom` | `number` | `0.4` | Mesh fraction of min(cols, rows) |
+
+### ``
+
+Root of every React glyphcss render tree. Owns the `
` output element and rasterizes all meshes on camera or state change. Must be a child of a camera component.
+
+| Prop | Type | Default | Description |
+|---|---|---|---|
+| `cols` | `number` | `80` | Grid width in character cells |
+| `rows` | `number` | `40` | Grid height in character cells |
+| `mode` | `"wireframe" \| "solid" \| "voxel"` | `"solid"` | Render mode |
+| `className` | `string` | — | CSS class on the `
` container |
 
-### ``
+### ``
 
 Loads and displays a 3D mesh. Supports `.obj`, `.glb`, `.gltf`, `.vox`.
 
@@ -67,16 +78,16 @@ Loads and displays a 3D mesh. Supports `.obj`, `.glb`, `.gltf`, `.vox`.
 | `src` | `string` | URL of the mesh file |
 | `color` | `string` | Override mesh color |
 
-### `` / ``
+### `` / ``
 
 Mouse/touch/keyboard camera controls.
 
 ### Hooks
 
-- `useGlyphcssCamera()` — access the camera context
-- `useGlyphcssSceneContext()` — access scene state
-- `useGlyphcssMesh(handle)` — mesh state and imperative API
-- `useGlyphcssAnimation(clips, controller)` — three.js-style animation mixer
+- `useGlyphCamera()` — access the camera context
+- `useGlyphSceneContext()` — access scene state
+- `useGlyphMesh(handle)` — mesh state and imperative API
+- `useGlyphAnimation(clips, controller)` — three.js-style animation mixer
 
 ## License
 
diff --git a/packages/react/src/animation/useGlyphcssAnimation.test.ts b/packages/react/src/animation/useGlyphAnimation.test.ts
similarity index 90%
rename from packages/react/src/animation/useGlyphcssAnimation.test.ts
rename to packages/react/src/animation/useGlyphAnimation.test.ts
index cdebd453..0b4c19e4 100644
--- a/packages/react/src/animation/useGlyphcssAnimation.test.ts
+++ b/packages/react/src/animation/useGlyphAnimation.test.ts
@@ -1,24 +1,24 @@
 import { describe, it, expect, vi, afterEach } from "vitest";
 import React, { act } from "react";
 import { createRoot } from "react-dom/client";
-import { useGlyphcssAnimation } from "./useGlyphcssAnimation";
-import type { UseGlyphcssAnimationResult } from "./useGlyphcssAnimation";
-import type { GlyphcssAnimationTarget, GlyphcssAnimationClip, ParseAnimationController, Polygon } from "@glyphcss/core";
+import { useGlyphAnimation } from "./useGlyphAnimation";
+import type { UseGlyphAnimationResult } from "./useGlyphAnimation";
+import type { GlyphAnimationTarget, GlyphAnimationClip, ParseAnimationController, Polygon } from "@glyphcss/core";
 
 const TRI: Polygon = { vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], color: "#f00" };
 
-function makeClip(index: number, name: string, duration = 1): GlyphcssAnimationClip {
+function makeClip(index: number, name: string, duration = 1): GlyphAnimationClip {
   return { index, name, duration, channelCount: 1 };
 }
 
-function makeController(clips: GlyphcssAnimationClip[]): ParseAnimationController {
+function makeController(clips: GlyphAnimationClip[]): ParseAnimationController {
   return {
     clips,
     sample: (_clip, _t) => [TRI],
   };
 }
 
-function makeTarget(): GlyphcssAnimationTarget & { calls: Polygon[][] } {
+function makeTarget(): GlyphAnimationTarget & { calls: Polygon[][] } {
   const calls: Polygon[][] = [];
   return { calls, setPolygons(polys) { calls.push(polys); } };
 }
@@ -26,20 +26,20 @@ function makeTarget(): GlyphcssAnimationTarget & { calls: Polygon[][] } {
 // ── Harness ─────────────────────────────────────────────────────────────────
 
 interface HarnessProps {
-  clips?: GlyphcssAnimationClip[];
+  clips?: GlyphAnimationClip[];
   controller?: ParseAnimationController;
-  root?: GlyphcssAnimationTarget | null;
-  onResult: (r: UseGlyphcssAnimationResult) => void;
+  root?: GlyphAnimationTarget | null;
+  onResult: (r: UseGlyphAnimationResult) => void;
 }
 
 function HarnessComponent({ clips, controller, root, onResult }: HarnessProps) {
-  const result = useGlyphcssAnimation(clips, controller, root ?? undefined);
+  const result = useGlyphAnimation(clips, controller, root ?? undefined);
   onResult(result);
   return null;
 }
 
 function renderHarness(props: Omit) {
-  let captured: UseGlyphcssAnimationResult | null = null;
+  let captured: UseGlyphAnimationResult | null = null;
   const container = document.createElement("div");
   const root = createRoot(container);
 
@@ -77,7 +77,7 @@ afterEach(() => {
 
 // ── No-input state ────────────────────────────────────────────────────────────
 
-describe("useGlyphcssAnimation — no inputs", () => {
+describe("useGlyphAnimation — no inputs", () => {
   it("returns mixer=null when no clips are passed", () => {
     const harness = renderHarness({});
     expect(harness.result.mixer).toBeNull();
@@ -116,7 +116,7 @@ describe("useGlyphcssAnimation — no inputs", () => {
 
 // ── With clips + controller + root ────────────────────────────────────────────
 
-describe("useGlyphcssAnimation — with inputs", () => {
+describe("useGlyphAnimation — with inputs", () => {
   it("exposes clip names from the clips array", () => {
     const clips = [makeClip(0, "walk"), makeClip(1, "run")];
     const ctrl = makeController(clips);
@@ -161,7 +161,7 @@ describe("useGlyphcssAnimation — with inputs", () => {
 
 // ── RAF loop ─────────────────────────────────────────────────────────────────
 
-describe("useGlyphcssAnimation — RAF loop", () => {
+describe("useGlyphAnimation — RAF loop", () => {
   it("calls requestAnimationFrame when clips and controller are provided", () => {
     const rafSpy = vi.fn((cb: FrameRequestCallback) => {
       // Don't actually call the callback to avoid infinite loop
@@ -221,7 +221,7 @@ describe("useGlyphcssAnimation — RAF loop", () => {
 
 // ── Mixer drives setPolygons ──────────────────────────────────────────────────
 
-describe("useGlyphcssAnimation — mixer drives setPolygons", () => {
+describe("useGlyphAnimation — mixer drives setPolygons", () => {
   it("playing an action and advancing the mixer calls setPolygons on the target", () => {
     const clips = [makeClip(0, "walk", 2)];
     const target = makeTarget();
@@ -270,7 +270,7 @@ describe("useGlyphcssAnimation — mixer drives setPolygons", () => {
 
 // ── actions proxy ─────────────────────────────────────────────────────────────
 
-describe("useGlyphcssAnimation — actions proxy", () => {
+describe("useGlyphAnimation — actions proxy", () => {
   it("actions object has enumerable keys matching clip names", () => {
     const clips = [makeClip(0, "walk"), makeClip(1, "run")];
     const ctrl = makeController(clips);
diff --git a/packages/react/src/animation/useGlyphcssAnimation.ts b/packages/react/src/animation/useGlyphAnimation.ts
similarity index 70%
rename from packages/react/src/animation/useGlyphcssAnimation.ts
rename to packages/react/src/animation/useGlyphAnimation.ts
index 47fbd9ed..5b5adcb6 100644
--- a/packages/react/src/animation/useGlyphcssAnimation.ts
+++ b/packages/react/src/animation/useGlyphAnimation.ts
@@ -1,9 +1,9 @@
 /**
- * useGlyphcssAnimation — React hook mirroring drei's `useAnimations`.
+ * useGlyphAnimation — React hook mirroring drei's `useAnimations`.
  *
  * Returns a `mixer`, `actions` record (lazy-instantiated per clip name),
  * `clips`, `names`, and `ref`. The `ref` can be attached to any imperative
- * handle that implements `GlyphcssAnimationTarget` (i.e. exposes `setPolygons`).
+ * handle that implements `GlyphAnimationTarget` (i.e. exposes `setPolygons`).
  *
  * Drives `mixer.update(dt)` via `requestAnimationFrame`, computing dt from
  * `performance.now()` deltas (no three.js dependency).
@@ -18,24 +18,24 @@
  */
 import { useEffect, useRef, useMemo } from "react";
 import type { RefObject } from "react";
-import { createGlyphcssAnimationMixer } from "@glyphcss/core";
+import { createGlyphAnimationMixer } from "@glyphcss/core";
 import type {
-  GlyphcssAnimationClip,
-  GlyphcssAnimationAction,
-  GlyphcssAnimationMixer,
-  GlyphcssAnimationTarget,
+  GlyphAnimationClip,
+  GlyphAnimationAction,
+  GlyphAnimationMixer,
+  GlyphAnimationTarget,
   ParseAnimationController,
 } from "@glyphcss/core";
 
-export type { GlyphcssAnimationClip, GlyphcssAnimationAction, GlyphcssAnimationMixer };
+export type { GlyphAnimationClip, GlyphAnimationAction, GlyphAnimationMixer };
 
-export interface UseGlyphcssAnimationResult {
-  /** Attach to a `GlyphcssAnimationTarget`-compatible handle when not using `root`. */
-  ref: RefObject;
+export interface UseGlyphAnimationResult {
+  /** Attach to a `GlyphAnimationTarget`-compatible handle when not using `root`. */
+  ref: RefObject;
   /** The active mixer, or null if inputs are not ready yet. */
-  mixer: GlyphcssAnimationMixer | null;
+  mixer: GlyphAnimationMixer | null;
   /** Resolved clip list (empty when `clips` is undefined). */
-  clips: GlyphcssAnimationClip[];
+  clips: GlyphAnimationClip[];
   /** Clip names in input order. */
   names: string[];
   /**
@@ -43,28 +43,28 @@ export interface UseGlyphcssAnimationResult {
    * instantiates the action if it does not exist yet. Returns null when the
    * mixer is not ready.
    */
-  actions: Record;
+  actions: Record;
 }
 
 function resolveRoot(
-  rootArg: RefObject | GlyphcssAnimationTarget | null | undefined,
-): GlyphcssAnimationTarget | null {
+  rootArg: RefObject | GlyphAnimationTarget | null | undefined,
+): GlyphAnimationTarget | null {
   if (!rootArg) return null;
   if ("current" in rootArg) return rootArg.current;
   return rootArg;
 }
 
-export function useGlyphcssAnimation(
-  clips: GlyphcssAnimationClip[] | undefined,
+export function useGlyphAnimation(
+  clips: GlyphAnimationClip[] | undefined,
   controller: ParseAnimationController | undefined,
-  root?: RefObject | GlyphcssAnimationTarget | null,
-): UseGlyphcssAnimationResult {
-  // Internal ref — users can attach this to any GlyphcssAnimationTarget-compatible
+  root?: RefObject | GlyphAnimationTarget | null,
+): UseGlyphAnimationResult {
+  // Internal ref — users can attach this to any GlyphAnimationTarget-compatible
   // handle when they don't pass `root` explicitly.
-  const internalRef = useRef(null);
+  const internalRef = useRef(null);
 
   // Stable ref to the live mixer. Updated synchronously inside effects.
-  const mixerRef = useRef(null);
+  const mixerRef = useRef(null);
 
   // Build (and tear down) the mixer whenever clips or controller change.
   useEffect(() => {
@@ -77,7 +77,7 @@ export function useGlyphcssAnimation(
       mixerRef.current = null;
       return;
     }
-    mixerRef.current = createGlyphcssAnimationMixer(resolvedRoot, controller);
+    mixerRef.current = createGlyphAnimationMixer(resolvedRoot, controller);
     return () => {
       mixerRef.current?.stopAllAction();
       mixerRef.current?.uncacheRoot();
@@ -119,8 +119,8 @@ export function useGlyphcssAnimation(
   const resolvedNames = resolvedClips.map((c) => c.name);
 
   // Lazy actions proxy: accessing actions[name] instantiates via clipAction.
-  const actions = useMemo>(() => {
-    const target: Record = {};
+  const actions = useMemo>(() => {
+    const target: Record = {};
     for (const clip of resolvedClips) {
       Object.defineProperty(target, clip.name, {
         enumerable: true,
diff --git a/packages/react/src/glyphcss/animation/useGlyphcssAnimation.test.tsx b/packages/react/src/glyphcss/animation/useGlyphAnimation.test.tsx
similarity index 72%
rename from packages/react/src/glyphcss/animation/useGlyphcssAnimation.test.tsx
rename to packages/react/src/glyphcss/animation/useGlyphAnimation.test.tsx
index a9b7099c..44f1bf08 100644
--- a/packages/react/src/glyphcss/animation/useGlyphcssAnimation.test.tsx
+++ b/packages/react/src/glyphcss/animation/useGlyphAnimation.test.tsx
@@ -1,14 +1,15 @@
 /**
- * Feature-level tests for the glyphcss useGlyphcssAnimation re-export.
+ * Feature-level tests for the glyphcss useGlyphAnimation re-export.
  * Tests use a thin consumer component to exercise observable behavior.
  */
 import { describe, it, expect, afterEach, vi } from "vitest";
-import React, { act, useRef } from "react";
+import React, { act } from "react";
 import { createRoot } from "react-dom/client";
-import { GlyphcssScene } from "../scene/GlyphcssScene";
-import { GlyphcssMesh } from "../scene/GlyphcssMesh";
-import { useGlyphcssAnimation } from "./useGlyphcssAnimation";
-import type { Polygon, GlyphcssAnimationClip, ParseAnimationController, GlyphcssAnimationTarget } from "@glyphcss/core";
+import { GlyphScene } from "../scene/GlyphScene";
+import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera";
+import { GlyphMesh } from "../scene/GlyphMesh";
+import { useGlyphAnimation } from "./useGlyphAnimation";
+import type { Polygon, GlyphAnimationClip, ParseAnimationController, GlyphAnimationTarget } from "@glyphcss/core";
 
 const POLYGON: Polygon = {
   vertices: [
@@ -20,7 +21,7 @@ const POLYGON: Polygon = {
 };
 
 // A minimal animation clip for testing
-const CLIP: GlyphcssAnimationClip = {
+const CLIP: GlyphAnimationClip = {
   name: "idle",
   duration: 1,
   tracks: [],
@@ -28,7 +29,7 @@ const CLIP: GlyphcssAnimationClip = {
 
 // A minimal parse animation controller stub
 const CONTROLLER: ParseAnimationController = {
-  parse: (_clip: GlyphcssAnimationClip, _target: GlyphcssAnimationTarget) => ({
+  parse: (_clip: GlyphAnimationClip, _target: GlyphAnimationTarget) => ({
     play: () => {},
     stop: () => {},
     reset: () => {},
@@ -37,17 +38,17 @@ const CONTROLLER: ParseAnimationController = {
 } as unknown as ParseAnimationController;
 
 /**
- * Consumer component that calls useGlyphcssAnimation and renders
+ * Consumer component that calls useGlyphAnimation and renders
  * observable state as data attributes.
  */
 function AnimationConsumer({
   clips,
   controller,
 }: {
-  clips?: GlyphcssAnimationClip[];
+  clips?: GlyphAnimationClip[];
   controller?: ParseAnimationController;
 }): React.ReactElement {
-  const { names, clips: resolvedClips } = useGlyphcssAnimation(clips, controller);
+  const { names, clips: resolvedClips } = useGlyphAnimation(clips, controller);
   return React.createElement("div", {
     className: "animation-consumer",
     "data-clip-count": resolvedClips.length,
@@ -56,7 +57,7 @@ function AnimationConsumer({
 }
 
 function renderWithAnimation(
-  animProps: { clips?: GlyphcssAnimationClip[]; controller?: ParseAnimationController } = {},
+  animProps: { clips?: GlyphAnimationClip[]; controller?: ParseAnimationController } = {},
 ): { container: HTMLElement; root: ReturnType } {
   const container = document.createElement("div");
   document.body.appendChild(container);
@@ -64,17 +65,21 @@ function renderWithAnimation(
   act(() =>
     root.render(
       React.createElement(
-        GlyphcssScene,
+        GlyphPerspectiveCamera,
         {},
-        React.createElement(GlyphcssMesh, { polygons: [POLYGON] }),
-        React.createElement(AnimationConsumer, animProps),
+        React.createElement(
+          GlyphScene,
+          {},
+          React.createElement(GlyphMesh, { polygons: [POLYGON] }),
+          React.createElement(AnimationConsumer, animProps),
+        ),
       ),
     ),
   );
   return { container, root };
 }
 
-describe("useGlyphcssAnimation (glyphcss re-export) — no clips", () => {
+describe("useGlyphAnimation (glyphcss re-export) — no clips", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -102,7 +107,7 @@ describe("useGlyphcssAnimation (glyphcss re-export) — no clips", () => {
   });
 });
 
-describe("useGlyphcssAnimation (glyphcss re-export) — with clips", () => {
+describe("useGlyphAnimation (glyphcss re-export) — with clips", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -133,18 +138,18 @@ describe("useGlyphcssAnimation (glyphcss re-export) — with clips", () => {
   });
 });
 
-describe("useGlyphcssAnimation — ref attachment pattern", () => {
+describe("useGlyphAnimation — ref attachment pattern", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
   /**
-   * Consumer that attaches the animation ref to a GlyphcssMesh handle to
+   * Consumer that attaches the animation ref to a GlyphMesh handle to
    * simulate real usage.
    */
   function RefAttachConsumer(): React.ReactElement {
-    const { ref, names } = useGlyphcssAnimation(undefined, undefined);
+    const { ref, names } = useGlyphAnimation(undefined, undefined);
     const attached = ref.current !== null;
     return React.createElement("div", {
       className: "ref-consumer",
@@ -160,9 +165,13 @@ describe("useGlyphcssAnimation — ref attachment pattern", () => {
     act(() =>
       root.render(
         React.createElement(
-          GlyphcssScene,
+          GlyphPerspectiveCamera,
           {},
-          React.createElement(RefAttachConsumer, null),
+          React.createElement(
+            GlyphScene,
+            {},
+            React.createElement(RefAttachConsumer, null),
+          ),
         ),
       ),
     );
diff --git a/packages/react/src/glyphcss/animation/useGlyphAnimation.ts b/packages/react/src/glyphcss/animation/useGlyphAnimation.ts
new file mode 100644
index 00000000..fbf77c5d
--- /dev/null
+++ b/packages/react/src/glyphcss/animation/useGlyphAnimation.ts
@@ -0,0 +1,11 @@
+/**
+ * useGlyphAnimation — wraps createGlyphAnimationMixer from @glyphcss/core.
+ * The animation system is paint-backend-agnostic: it mutates polygon arrays
+ * and calls setPolygons on the target, which is independent of whether the
+ * output is ASCII text or any other backend.
+ *
+ * For the ASCII backend, the animation target should be a GlyphMesh handle
+ * or any object that implements GlyphAnimationTarget (setPolygons).
+ */
+export { useGlyphAnimation } from "../../animation/useGlyphAnimation";
+export type { UseGlyphAnimationResult } from "../../animation/useGlyphAnimation";
diff --git a/packages/react/src/glyphcss/animation/useGlyphcssAnimation.ts b/packages/react/src/glyphcss/animation/useGlyphcssAnimation.ts
deleted file mode 100644
index 4d1490d1..00000000
--- a/packages/react/src/glyphcss/animation/useGlyphcssAnimation.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * useGlyphcssAnimation — wraps createGlyphcssAnimationMixer from @glyphcss/core.
- * The animation system is paint-backend-agnostic: it mutates polygon arrays
- * and calls setPolygons on the target, which is independent of whether the
- * output is ASCII text or any other backend.
- *
- * For the ASCII backend, the animation target should be a GlyphcssMesh handle
- * or any object that implements GlyphcssAnimationTarget (setPolygons).
- */
-export { useGlyphcssAnimation } from "../../animation/useGlyphcssAnimation";
-export type { UseGlyphcssAnimationResult } from "../../animation/useGlyphcssAnimation";
diff --git a/packages/react/src/glyphcss/camera/GlyphcssCamera.test.tsx b/packages/react/src/glyphcss/camera/GlyphCamera.test.tsx
similarity index 53%
rename from packages/react/src/glyphcss/camera/GlyphcssCamera.test.tsx
rename to packages/react/src/glyphcss/camera/GlyphCamera.test.tsx
index f50d7bf9..7a670bb6 100644
--- a/packages/react/src/glyphcss/camera/GlyphcssCamera.test.tsx
+++ b/packages/react/src/glyphcss/camera/GlyphCamera.test.tsx
@@ -1,11 +1,11 @@
 import { describe, it, expect, afterEach, vi } from "vitest";
 import React, { act } from "react";
 import { createRoot } from "react-dom/client";
-import { GlyphcssScene } from "../scene/GlyphcssScene";
-import { GlyphcssCamera } from "./GlyphcssCamera";
+import { GlyphScene } from "../scene/GlyphScene";
+import { GlyphCamera } from "./GlyphCamera";
 
 function renderScene(
-  cameraProps: React.ComponentProps = {},
+  cameraProps: React.ComponentProps = {},
 ): { container: HTMLElement; root: ReturnType } {
   const container = document.createElement("div");
   document.body.appendChild(container);
@@ -13,16 +13,16 @@ function renderScene(
   act(() =>
     root.render(
       React.createElement(
-        GlyphcssScene,
-        {},
-        React.createElement(GlyphcssCamera, cameraProps),
+        GlyphCamera,
+        cameraProps,
+        React.createElement(GlyphScene, {}),
       ),
     ),
   );
   return { container, root };
 }
 
-describe("GlyphcssCamera (alias for Perspective) — mount inside scene", () => {
+describe("GlyphCamera (alias for Orthographic) — wraps scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -32,13 +32,13 @@ describe("GlyphcssCamera (alias for Perspective) — mount inside scene", () =>
     expect(() => renderScene()).not.toThrow();
   });
 
-  it("scene host is present after mounting GlyphcssCamera", () => {
-    const { container } = renderScene({ distance: 5 });
-    expect(container.querySelector(".glyphcss-host")).toBeTruthy();
+  it("scene host is present after mounting GlyphCamera", () => {
+    const { container } = renderScene({ zoom: 0.5 });
+    expect(container.querySelector(".glyph-host")).toBeTruthy();
   });
 
-  it("accepts distance prop", () => {
-    expect(() => renderScene({ distance: 8 })).not.toThrow();
+  it("accepts zoom prop", () => {
+    expect(() => renderScene({ zoom: 0.8 })).not.toThrow();
   });
 
   it("accepts rotX/rotY props", () => {
@@ -46,25 +46,25 @@ describe("GlyphcssCamera (alias for Perspective) — mount inside scene", () =>
   });
 
   it("unmounts cleanly", () => {
-    const { container, root } = renderScene({ distance: 4 });
+    const { container, root } = renderScene({ zoom: 0.4 });
     act(() => root.unmount());
-    expect(container.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(container.querySelector(".glyph-output")).toBeFalsy();
   });
 });
 
-describe("GlyphcssCamera — outside scene", () => {
+describe("GlyphCamera — standalone (no scene child)", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when mounted outside GlyphcssScene", () => {
+  it("mounts without throwing when used without a scene child", () => {
     const container = document.createElement("div");
     const root = createRoot(container);
     expect(() => {
       act(() =>
-        root.render(React.createElement(GlyphcssCamera, {})),
+        root.render(React.createElement(GlyphCamera, {})),
       );
-    }).toThrow();
+    }).not.toThrow();
   });
 });
diff --git a/packages/react/src/glyphcss/camera/GlyphCamera.tsx b/packages/react/src/glyphcss/camera/GlyphCamera.tsx
new file mode 100644
index 00000000..18b49a37
--- /dev/null
+++ b/packages/react/src/glyphcss/camera/GlyphCamera.tsx
@@ -0,0 +1,3 @@
+/** GlyphCamera — alias for GlyphOrthographicCamera (ergonomic default). */
+export { GlyphOrthographicCamera as GlyphCamera } from "./GlyphOrthographicCamera";
+export type { GlyphOrthographicCameraProps as GlyphCameraProps } from "./GlyphOrthographicCamera";
diff --git a/packages/react/src/glyphcss/camera/GlyphcssOrthographicCamera.test.tsx b/packages/react/src/glyphcss/camera/GlyphOrthographicCamera.test.tsx
similarity index 61%
rename from packages/react/src/glyphcss/camera/GlyphcssOrthographicCamera.test.tsx
rename to packages/react/src/glyphcss/camera/GlyphOrthographicCamera.test.tsx
index 6f791eed..df92a12b 100644
--- a/packages/react/src/glyphcss/camera/GlyphcssOrthographicCamera.test.tsx
+++ b/packages/react/src/glyphcss/camera/GlyphOrthographicCamera.test.tsx
@@ -1,11 +1,11 @@
 import { describe, it, expect, afterEach, vi } from "vitest";
 import React, { act } from "react";
 import { createRoot } from "react-dom/client";
-import { GlyphcssScene } from "../scene/GlyphcssScene";
-import { GlyphcssOrthographicCamera } from "./GlyphcssOrthographicCamera";
+import { GlyphScene } from "../scene/GlyphScene";
+import { GlyphOrthographicCamera } from "./GlyphOrthographicCamera";
 
 function renderScene(
-  cameraProps: React.ComponentProps = {},
+  cameraProps: React.ComponentProps = {},
 ): { container: HTMLElement; root: ReturnType } {
   const container = document.createElement("div");
   document.body.appendChild(container);
@@ -13,16 +13,16 @@ function renderScene(
   act(() =>
     root.render(
       React.createElement(
-        GlyphcssScene,
-        {},
-        React.createElement(GlyphcssOrthographicCamera, cameraProps),
+        GlyphOrthographicCamera,
+        cameraProps,
+        React.createElement(GlyphScene, {}),
       ),
     ),
   );
   return { container, root };
 }
 
-describe("GlyphcssOrthographicCamera — mount inside scene", () => {
+describe("GlyphOrthographicCamera — wraps scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -32,14 +32,14 @@ describe("GlyphcssOrthographicCamera — mount inside scene", () => {
     expect(() => renderScene()).not.toThrow();
   });
 
-  it("scene host is still rendered after mounting camera", () => {
+  it("scene host is still rendered when orthographic camera wraps it", () => {
     const { container } = renderScene();
-    expect(container.querySelector(".glyphcss-host")).toBeTruthy();
+    expect(container.querySelector(".glyph-host")).toBeTruthy();
   });
 
   it("scene output 
 is present after mounting orthographic camera", () => {
     const { container } = renderScene();
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
   });
 
   it("accepts zoom prop without throwing", () => {
@@ -56,39 +56,39 @@ describe("GlyphcssOrthographicCamera — mount inside scene", () => {
 
   it("re-renders when zoom changes", () => {
     const { container, root } = renderScene({ zoom: 0.4 });
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
     act(() =>
       root.render(
         React.createElement(
-          GlyphcssScene,
-          {},
-          React.createElement(GlyphcssOrthographicCamera, { zoom: 0.8 }),
+          GlyphOrthographicCamera,
+          { zoom: 0.8 },
+          React.createElement(GlyphScene, {}),
         ),
       ),
     );
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
   });
 
   it("unmounts cleanly", () => {
     const { container, root } = renderScene({ zoom: 0.5 });
     act(() => root.unmount());
-    expect(container.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(container.querySelector(".glyph-output")).toBeFalsy();
   });
 });
 
-describe("GlyphcssOrthographicCamera — outside scene", () => {
+describe("GlyphOrthographicCamera — standalone (no scene child)", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when mounted outside GlyphcssScene", () => {
+  it("mounts without throwing when used without a scene child", () => {
     const container = document.createElement("div");
     const root = createRoot(container);
     expect(() => {
       act(() =>
-        root.render(React.createElement(GlyphcssOrthographicCamera, {})),
+        root.render(React.createElement(GlyphOrthographicCamera, {})),
       );
-    }).toThrow();
+    }).not.toThrow();
   });
 });
diff --git a/packages/react/src/glyphcss/camera/GlyphOrthographicCamera.tsx b/packages/react/src/glyphcss/camera/GlyphOrthographicCamera.tsx
new file mode 100644
index 00000000..3e401fe9
--- /dev/null
+++ b/packages/react/src/glyphcss/camera/GlyphOrthographicCamera.tsx
@@ -0,0 +1,68 @@
+/**
+ * GlyphOrthographicCamera — outer wrapper that creates an orthographic camera
+ * handle and provides it via GlyphCameraContext.  must be placed
+ * inside this component.
+ */
+import { memo, useEffect, useMemo, useRef } from "react";
+import type { CSSProperties, ReactNode } from "react";
+import type { GlyphCamera, GlyphOrthographicCameraOptions } from "glyphcss";
+import { createGlyphOrthographicCamera } from "glyphcss";
+import { GlyphCameraContext } from "./context";
+
+export interface GlyphOrthographicCameraProps {
+  rotX?: number;
+  rotY?: number;
+  /** Orthographic zoom (fraction of min(cols, rows)). Default 0.4. */
+  zoom?: number;
+  /** Center of projection in normalized grid coords. Default [0.5, 0.5]. */
+  center?: [number, number];
+  className?: string;
+  style?: CSSProperties;
+  children?: ReactNode;
+}
+
+function GlyphOrthographicCameraInner({
+  rotX,
+  rotY,
+  zoom,
+  center,
+  className,
+  style,
+  children,
+}: GlyphOrthographicCameraProps) {
+  const cameraRef = useRef(null);
+  const sceneRerenderRef = useRef<(() => void) | null>(null);
+
+  if (!cameraRef.current) {
+    const opts: GlyphOrthographicCameraOptions = {};
+    if (rotX !== undefined) opts.rotX = rotX;
+    if (rotY !== undefined) opts.rotY = rotY;
+    if (zoom !== undefined) opts.zoom = zoom;
+    if (center !== undefined) opts.center = center;
+    cameraRef.current = createGlyphOrthographicCamera(opts);
+  }
+
+  // Sync prop changes to the camera handle and trigger scene rerender
+  useEffect(() => {
+    const camera = cameraRef.current;
+    if (!camera) return;
+    let dirty = false;
+    if (rotX !== undefined && camera.rotX !== rotX) { camera.rotX = rotX; dirty = true; }
+    if (rotY !== undefined && camera.rotY !== rotY) { camera.rotY = rotY; dirty = true; }
+    if (zoom !== undefined && camera.zoom !== zoom) { camera.zoom = zoom; dirty = true; }
+    if (dirty) {
+      sceneRerenderRef.current?.();
+    }
+  });
+
+  const rerender = useMemo(() => () => sceneRerenderRef.current?.(), []); // eslint-disable-line react-hooks/exhaustive-deps
+  const ctxValue = useMemo(() => ({ cameraRef, rerender, sceneRerenderRef }), [cameraRef, rerender]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  return (
+    
+      
{children}
+
+ ); +} + +export const GlyphOrthographicCamera = memo(GlyphOrthographicCameraInner); diff --git a/packages/react/src/glyphcss/camera/GlyphcssPerspectiveCamera.test.tsx b/packages/react/src/glyphcss/camera/GlyphPerspectiveCamera.test.tsx similarity index 63% rename from packages/react/src/glyphcss/camera/GlyphcssPerspectiveCamera.test.tsx rename to packages/react/src/glyphcss/camera/GlyphPerspectiveCamera.test.tsx index 7482048b..9591f4a1 100644 --- a/packages/react/src/glyphcss/camera/GlyphcssPerspectiveCamera.test.tsx +++ b/packages/react/src/glyphcss/camera/GlyphPerspectiveCamera.test.tsx @@ -1,11 +1,11 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import React, { act } from "react"; import { createRoot } from "react-dom/client"; -import { GlyphcssScene } from "../scene/GlyphcssScene"; -import { GlyphcssPerspectiveCamera } from "./GlyphcssPerspectiveCamera"; +import { GlyphScene } from "../scene/GlyphScene"; +import { GlyphPerspectiveCamera } from "./GlyphPerspectiveCamera"; function renderScene( - cameraProps: React.ComponentProps = {}, + cameraProps: React.ComponentProps = {}, ): { container: HTMLElement; root: ReturnType } { const container = document.createElement("div"); document.body.appendChild(container); @@ -13,16 +13,16 @@ function renderScene( act(() => root.render( React.createElement( - GlyphcssScene, - {}, - React.createElement(GlyphcssPerspectiveCamera, cameraProps), + GlyphPerspectiveCamera, + cameraProps, + React.createElement(GlyphScene, {}), ), ), ); return { container, root }; } -describe("GlyphcssPerspectiveCamera — mount inside scene", () => { +describe("GlyphPerspectiveCamera — wraps scene", () => { afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ""; @@ -32,14 +32,14 @@ describe("GlyphcssPerspectiveCamera — mount inside scene", () => { expect(() => renderScene()).not.toThrow(); }); - it("scene host is still rendered after mounting camera", () => { + it("scene host is still rendered when camera wraps it", () => { const { container } = renderScene({ distance: 5 }); - expect(container.querySelector(".glyphcss-host")).toBeTruthy(); + expect(container.querySelector(".glyph-host")).toBeTruthy(); }); - it("scene output
 is still rendered after mounting camera", () => {
+  it("scene output 
 is still rendered when camera wraps it", () => {
     const { container } = renderScene({ distance: 5 });
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
   });
 
   it("accepts distance prop without throwing", () => {
@@ -64,39 +64,39 @@ describe("GlyphcssPerspectiveCamera — mount inside scene", () => {
 
   it("re-renders when distance changes", () => {
     const { container, root } = renderScene({ distance: 3 });
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
     act(() =>
       root.render(
         React.createElement(
-          GlyphcssScene,
-          {},
-          React.createElement(GlyphcssPerspectiveCamera, { distance: 7 }),
+          GlyphPerspectiveCamera,
+          { distance: 7 },
+          React.createElement(GlyphScene, {}),
         ),
       ),
     );
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
   });
 
   it("unmounts cleanly and host is removed", () => {
     const { container, root } = renderScene({ distance: 3 });
     act(() => root.unmount());
-    expect(container.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(container.querySelector(".glyph-output")).toBeFalsy();
   });
 });
 
-describe("GlyphcssPerspectiveCamera — outside scene", () => {
+describe("GlyphPerspectiveCamera — standalone (no scene child)", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when mounted outside GlyphcssScene", () => {
+  it("mounts without throwing when used without a scene child", () => {
     const container = document.createElement("div");
     const root = createRoot(container);
     expect(() => {
       act(() =>
-        root.render(React.createElement(GlyphcssPerspectiveCamera, {})),
+        root.render(React.createElement(GlyphPerspectiveCamera, {})),
       );
-    }).toThrow();
+    }).not.toThrow();
   });
 });
diff --git a/packages/react/src/glyphcss/camera/GlyphPerspectiveCamera.tsx b/packages/react/src/glyphcss/camera/GlyphPerspectiveCamera.tsx
new file mode 100644
index 00000000..69ec207c
--- /dev/null
+++ b/packages/react/src/glyphcss/camera/GlyphPerspectiveCamera.tsx
@@ -0,0 +1,78 @@
+/**
+ * GlyphPerspectiveCamera — outer wrapper that creates a perspective camera
+ * handle and provides it via GlyphCameraContext.  must be placed
+ * inside this component.
+ */
+import { memo, useEffect, useMemo, useRef } from "react";
+import type { CSSProperties, ReactNode } from "react";
+import type { GlyphCamera, GlyphPerspectiveCameraOptions } from "glyphcss";
+import { createGlyphPerspectiveCamera } from "glyphcss";
+import { GlyphCameraContext } from "./context";
+
+export interface GlyphPerspectiveCameraProps {
+  rotX?: number;
+  rotY?: number;
+  /** Perspective distance. Default 3. */
+  distance?: number;
+  /** Camera zoom — mesh fraction of min(cols, rows). Default 0.4. */
+  zoom?: number;
+  /** Extra horizontal stretch on top of cellAspect. Default 1.0. */
+  stretch?: number;
+  /** Center of projection in normalized grid coords. Default [0.5, 0.5]. */
+  center?: [number, number];
+  className?: string;
+  style?: CSSProperties;
+  children?: ReactNode;
+}
+
+function GlyphPerspectiveCameraInner({
+  rotX,
+  rotY,
+  distance,
+  zoom,
+  stretch,
+  center,
+  className,
+  style,
+  children,
+}: GlyphPerspectiveCameraProps) {
+  const cameraRef = useRef(null);
+  const sceneRerenderRef = useRef<(() => void) | null>(null);
+
+  if (!cameraRef.current) {
+    const opts: GlyphPerspectiveCameraOptions = {};
+    if (rotX !== undefined) opts.rotX = rotX;
+    if (rotY !== undefined) opts.rotY = rotY;
+    if (distance !== undefined) opts.distance = distance;
+    if (zoom !== undefined) opts.zoom = zoom;
+    if (stretch !== undefined) opts.stretch = stretch;
+    if (center !== undefined) opts.center = center;
+    cameraRef.current = createGlyphPerspectiveCamera(opts);
+  }
+
+  // Sync prop changes to the camera handle and trigger scene rerender
+  useEffect(() => {
+    const camera = cameraRef.current;
+    if (!camera) return;
+    let dirty = false;
+    if (rotX !== undefined && camera.rotX !== rotX) { camera.rotX = rotX; dirty = true; }
+    if (rotY !== undefined && camera.rotY !== rotY) { camera.rotY = rotY; dirty = true; }
+    if (distance !== undefined && camera.distance !== distance) { camera.distance = distance; dirty = true; }
+    if (zoom !== undefined && camera.zoom !== zoom) { camera.zoom = zoom; dirty = true; }
+    if (stretch !== undefined && camera.stretch !== stretch) { camera.stretch = stretch; dirty = true; }
+    if (dirty) {
+      sceneRerenderRef.current?.();
+    }
+  });
+
+  const rerender = useMemo(() => () => sceneRerenderRef.current?.(), []); // eslint-disable-line react-hooks/exhaustive-deps
+  const ctxValue = useMemo(() => ({ cameraRef, rerender, sceneRerenderRef }), [cameraRef, rerender]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  return (
+    
+      
{children}
+
+ ); +} + +export const GlyphPerspectiveCamera = memo(GlyphPerspectiveCameraInner); diff --git a/packages/react/src/glyphcss/camera/GlyphcssCamera.tsx b/packages/react/src/glyphcss/camera/GlyphcssCamera.tsx deleted file mode 100644 index 3322994a..00000000 --- a/packages/react/src/glyphcss/camera/GlyphcssCamera.tsx +++ /dev/null @@ -1,3 +0,0 @@ -/** GlyphcssCamera — alias for GlyphcssPerspectiveCamera (ergonomic default). */ -export { GlyphcssPerspectiveCamera as GlyphcssCamera } from "./GlyphcssPerspectiveCamera"; -export type { GlyphcssPerspectiveCameraProps as GlyphcssCameraProps } from "./GlyphcssPerspectiveCamera"; diff --git a/packages/react/src/glyphcss/camera/GlyphcssOrthographicCamera.tsx b/packages/react/src/glyphcss/camera/GlyphcssOrthographicCamera.tsx deleted file mode 100644 index cd4bd5bd..00000000 --- a/packages/react/src/glyphcss/camera/GlyphcssOrthographicCamera.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/** - * GlyphcssOrthographicCamera — configures an orthographic ASCII camera on the - * parent GlyphcssScene. - */ -import { memo, useEffect, useMemo, useRef } from "react"; -import type { ReactNode } from "react"; -import type { GlyphcssCamera, GlyphcssOrthographicCameraOptions } from "glyphcss"; -import { createGlyphcssOrthographicCamera } from "glyphcss"; -import { useGlyphcssSceneContext } from "../scene/context"; -import { GlyphcssCameraContext } from "./context"; - -export interface GlyphcssOrthographicCameraProps { - rotX?: number; - rotY?: number; - /** Orthographic zoom (fraction of min(cols, rows)). Default 0.4. */ - zoom?: number; - /** Center of projection in normalized grid coords. Default [0.5, 0.5]. */ - center?: [number, number]; - children?: ReactNode; -} - -function GlyphcssOrthographicCameraInner({ - rotX, - rotY, - zoom, - center, - children, -}: GlyphcssOrthographicCameraProps) { - const { sceneRef } = useGlyphcssSceneContext(); - const cameraRef = useRef(null); - - if (!cameraRef.current) { - const opts: GlyphcssOrthographicCameraOptions = {}; - if (rotX !== undefined) opts.rotX = rotX; - if (rotY !== undefined) opts.rotY = rotY; - if (zoom !== undefined) opts.zoom = zoom; - if (center !== undefined) opts.center = center; - cameraRef.current = createGlyphcssOrthographicCamera(opts); - } - - useEffect(() => { - const scene = sceneRef.current; - if (!scene || !cameraRef.current) return; - scene.setOptions({ camera: cameraRef.current }); - scene.rerender(); - }, [sceneRef]); - - useEffect(() => { - const camera = cameraRef.current; - if (!camera) return; - let dirty = false; - if (rotX !== undefined && camera.rotX !== rotX) { camera.rotX = rotX; dirty = true; } - if (rotY !== undefined && camera.rotY !== rotY) { camera.rotY = rotY; dirty = true; } - if (zoom !== undefined && camera.zoom !== zoom) { camera.zoom = zoom; dirty = true; } - if (dirty) sceneRef.current?.rerender(); - }); - - const rerender = () => sceneRef.current?.rerender(); - const ctxValue = useMemo(() => ({ cameraRef, rerender }), [cameraRef]); // eslint-disable-line react-hooks/exhaustive-deps - - return ( - - {children} - - ); -} - -export const GlyphcssOrthographicCamera = memo(GlyphcssOrthographicCameraInner); diff --git a/packages/react/src/glyphcss/camera/GlyphcssPerspectiveCamera.tsx b/packages/react/src/glyphcss/camera/GlyphcssPerspectiveCamera.tsx deleted file mode 100644 index 5b67f0e5..00000000 --- a/packages/react/src/glyphcss/camera/GlyphcssPerspectiveCamera.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/** - * GlyphcssPerspectiveCamera — configures a perspective ASCII camera on the - * parent GlyphcssScene. Mirrors PolyPerspectiveCamera's prop surface, adapted - * for the ASCII rasterizer (no CSS perspective value; uses GlyphcssCamera - * distance/scale/stretch instead). - * - * Must be placed inside . - */ -import { memo, useEffect, useMemo, useRef } from "react"; -import type { ReactNode } from "react"; -import type { GlyphcssCamera, GlyphcssPerspectiveCameraOptions } from "glyphcss"; -import { createGlyphcssPerspectiveCamera } from "glyphcss"; -import { useGlyphcssSceneContext } from "../scene/context"; -import { GlyphcssCameraContext } from "./context"; - -export interface GlyphcssPerspectiveCameraProps { - rotX?: number; - rotY?: number; - /** Perspective distance. Default 3. */ - distance?: number; - /** Camera zoom — mesh fraction of min(cols, rows). Default 0.4. */ - zoom?: number; - /** Extra horizontal stretch on top of cellAspect. Default 1.0. */ - stretch?: number; - /** Center of projection in normalized grid coords. Default [0.5, 0.5]. */ - center?: [number, number]; - children?: ReactNode; -} - -function GlyphcssPerspectiveCameraInner({ - rotX, - rotY, - distance, - zoom, - stretch, - center, - children, -}: GlyphcssPerspectiveCameraProps) { - const { sceneRef } = useGlyphcssSceneContext(); - const cameraRef = useRef(null); - - if (!cameraRef.current) { - const opts: GlyphcssPerspectiveCameraOptions = {}; - if (rotX !== undefined) opts.rotX = rotX; - if (rotY !== undefined) opts.rotY = rotY; - if (distance !== undefined) opts.distance = distance; - if (zoom !== undefined) opts.zoom = zoom; - if (stretch !== undefined) opts.stretch = stretch; - if (center !== undefined) opts.center = center; - cameraRef.current = createGlyphcssPerspectiveCamera(opts); - } - - // Register camera with the scene on mount - useEffect(() => { - const scene = sceneRef.current; - if (!scene || !cameraRef.current) return; - scene.setOptions({ camera: cameraRef.current }); - scene.rerender(); - }, [sceneRef]); - - // Sync prop changes - useEffect(() => { - const camera = cameraRef.current; - if (!camera) return; - let dirty = false; - if (rotX !== undefined && camera.rotX !== rotX) { camera.rotX = rotX; dirty = true; } - if (rotY !== undefined && camera.rotY !== rotY) { camera.rotY = rotY; dirty = true; } - if (distance !== undefined && camera.distance !== distance) { camera.distance = distance; dirty = true; } - if (zoom !== undefined && camera.zoom !== zoom) { camera.zoom = zoom; dirty = true; } - if (stretch !== undefined && camera.stretch !== stretch) { camera.stretch = stretch; dirty = true; } - if (dirty) { - sceneRef.current?.rerender(); - } - }); - - const rerender = () => sceneRef.current?.rerender(); - const ctxValue = useMemo(() => ({ cameraRef, rerender }), [cameraRef]); // eslint-disable-line react-hooks/exhaustive-deps - - return ( - - {children} - - ); -} - -export const GlyphcssPerspectiveCamera = memo(GlyphcssPerspectiveCameraInner); diff --git a/packages/react/src/glyphcss/camera/context.ts b/packages/react/src/glyphcss/camera/context.ts index 38614238..05643baf 100644 --- a/packages/react/src/glyphcss/camera/context.ts +++ b/packages/react/src/glyphcss/camera/context.ts @@ -1,18 +1,33 @@ import { createContext, useContext } from "react"; -import type { GlyphcssCamera } from "glyphcss"; +import type { GlyphCamera } from "glyphcss"; -export interface GlyphcssCameraContextValue { - cameraRef: React.MutableRefObject; +export interface GlyphCameraContextValue { + cameraRef: React.MutableRefObject; /** Notify the scene to re-render after camera changes. */ rerender: () => void; + /** + * Set by the child GlyphScene so the camera can trigger rerenders when + * props change after the scene is mounted. + */ + sceneRerenderRef: React.MutableRefObject<(() => void) | null>; } -export const GlyphcssCameraContext = createContext(null); +export const GlyphCameraContext = createContext(null); -export function useGlyphcssCamera(): GlyphcssCameraContextValue { - const ctx = useContext(GlyphcssCameraContext); +export function useGlyphCamera(): GlyphCameraContextValue { + const ctx = useContext(GlyphCameraContext); if (!ctx) { - throw new Error("glyphcss: camera hook must be used inside a GlyphcssCamera."); + throw new Error("glyphcss: camera hook must be used inside a GlyphCamera."); + } + return ctx; +} + +export function useGlyphCameraContext(): GlyphCameraContextValue { + const ctx = useContext(GlyphCameraContext); + if (!ctx) { + throw new Error( + "glyphcss: GlyphScene must be placed inside a GlyphPerspectiveCamera or GlyphOrthographicCamera.", + ); } return ctx; } diff --git a/packages/react/src/glyphcss/camera/index.ts b/packages/react/src/glyphcss/camera/index.ts index a66f840b..8dbca26f 100644 --- a/packages/react/src/glyphcss/camera/index.ts +++ b/packages/react/src/glyphcss/camera/index.ts @@ -1,10 +1,10 @@ -export { GlyphcssCamera } from "./GlyphcssCamera"; -export type { GlyphcssCameraProps } from "./GlyphcssCamera"; -export { GlyphcssPerspectiveCamera } from "./GlyphcssPerspectiveCamera"; -export type { GlyphcssPerspectiveCameraProps } from "./GlyphcssPerspectiveCamera"; -export { GlyphcssOrthographicCamera } from "./GlyphcssOrthographicCamera"; -export type { GlyphcssOrthographicCameraProps } from "./GlyphcssOrthographicCamera"; -export { GlyphcssCameraContext, useGlyphcssCamera } from "./context"; -export type { GlyphcssCameraContextValue } from "./context"; -export { useGlyphcssCameraHook } from "./useGlyphcssCamera"; -export type { UseGlyphcssCameraOptions, UseGlyphcssCameraResult } from "./useGlyphcssCamera"; +export { GlyphCamera } from "./GlyphCamera"; +export type { GlyphCameraProps } from "./GlyphCamera"; +export { GlyphPerspectiveCamera } from "./GlyphPerspectiveCamera"; +export type { GlyphPerspectiveCameraProps } from "./GlyphPerspectiveCamera"; +export { GlyphOrthographicCamera } from "./GlyphOrthographicCamera"; +export type { GlyphOrthographicCameraProps } from "./GlyphOrthographicCamera"; +export { GlyphCameraContext, useGlyphCamera, useGlyphCameraContext } from "./context"; +export type { GlyphCameraContextValue } from "./context"; +export { useGlyphCameraHook } from "./useGlyphCamera"; +export type { UseGlyphCameraOptions, UseGlyphCameraResult } from "./useGlyphCamera"; diff --git a/packages/react/src/glyphcss/camera/useGlyphcssCamera.test.tsx b/packages/react/src/glyphcss/camera/useGlyphCamera.test.tsx similarity index 64% rename from packages/react/src/glyphcss/camera/useGlyphcssCamera.test.tsx rename to packages/react/src/glyphcss/camera/useGlyphCamera.test.tsx index 6f2dc3c1..2a9aa32b 100644 --- a/packages/react/src/glyphcss/camera/useGlyphcssCamera.test.tsx +++ b/packages/react/src/glyphcss/camera/useGlyphCamera.test.tsx @@ -1,20 +1,20 @@ /** - * Tests for useGlyphcssCamera via a thin consumer component. + * Tests for useGlyphCamera via a thin consumer component. * We do not import renderHook — we test observable rendering behavior. */ import { describe, it, expect, afterEach, vi } from "vitest"; import React, { act } from "react"; import { createRoot } from "react-dom/client"; -import { GlyphcssScene } from "../scene/GlyphcssScene"; -import { GlyphcssPerspectiveCamera } from "./GlyphcssPerspectiveCamera"; -import { useGlyphcssCamera } from "./context"; +import { GlyphScene } from "../scene/GlyphScene"; +import { GlyphPerspectiveCamera } from "./GlyphPerspectiveCamera"; +import { useGlyphCamera } from "./context"; /** - * Consumer component that reads from GlyphcssCameraContext and renders the + * Consumer component that reads from GlyphCameraContext and renders the * cameraRef presence as a data attribute on a div. */ function CameraConsumer(): React.ReactElement { - const { cameraRef } = useGlyphcssCamera(); + const { cameraRef } = useGlyphCamera(); return React.createElement("div", { "data-has-camera": cameraRef.current !== null ? "true" : "false", className: "camera-consumer", @@ -22,7 +22,7 @@ function CameraConsumer(): React.ReactElement { } function renderWithCamera( - cameraProps: React.ComponentProps = {}, + cameraProps: React.ComponentProps = {}, ): { container: HTMLElement; root: ReturnType } { const container = document.createElement("div"); document.body.appendChild(container); @@ -30,11 +30,11 @@ function renderWithCamera( act(() => root.render( React.createElement( - GlyphcssScene, - {}, + GlyphPerspectiveCamera, + cameraProps, React.createElement( - GlyphcssPerspectiveCamera, - cameraProps, + GlyphScene, + {}, React.createElement(CameraConsumer, null), ), ), @@ -43,7 +43,7 @@ function renderWithCamera( return { container, root }; } -describe("useGlyphcssCamera — via consumer inside camera context", () => { +describe("useGlyphCamera — via consumer inside camera context", () => { afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ""; @@ -57,7 +57,7 @@ describe("useGlyphcssCamera — via consumer inside camera context", () => { it("scene output is still rendered when camera consumer is present", () => { const { container } = renderWithCamera({ distance: 5 }); - expect(container.querySelector(".glyphcss-output")).toBeTruthy(); + expect(container.querySelector(".glyph-output")).toBeTruthy(); }); it("camera consumer mounts without throwing", () => { @@ -67,28 +67,24 @@ describe("useGlyphcssCamera — via consumer inside camera context", () => { it("unmounts cleanly when camera and consumer are present", () => { const { container, root } = renderWithCamera({ distance: 3 }); act(() => root.unmount()); - expect(container.querySelector(".glyphcss-output")).toBeFalsy(); + expect(container.querySelector(".glyph-output")).toBeFalsy(); }); }); -describe("useGlyphcssCamera — error when outside camera context", () => { +describe("useGlyphCamera — error when outside camera context", () => { afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ""; }); - it("throws when used outside GlyphcssCamera", () => { - // CameraConsumer calls useGlyphcssCamera which requires the camera context. + it("throws when used outside GlyphCamera", () => { + // CameraConsumer calls useGlyphCamera which requires the camera context. const container = document.createElement("div"); const root = createRoot(container); expect(() => { act(() => root.render( - React.createElement( - GlyphcssScene, - {}, - React.createElement(CameraConsumer, null), - ), + React.createElement(CameraConsumer, null), ), ); }).toThrow(); diff --git a/packages/react/src/glyphcss/camera/useGlyphCamera.ts b/packages/react/src/glyphcss/camera/useGlyphCamera.ts new file mode 100644 index 00000000..6c1fe8b3 --- /dev/null +++ b/packages/react/src/glyphcss/camera/useGlyphCamera.ts @@ -0,0 +1,28 @@ +import { useCallback, useRef } from "react"; +import type { GlyphCamera, GlyphPerspectiveCameraOptions } from "glyphcss"; +import { createGlyphPerspectiveCamera } from "glyphcss"; +import { useGlyphCamera } from "./context"; + +export interface UseGlyphCameraOptions extends GlyphPerspectiveCameraOptions {} + +export interface UseGlyphCameraResult { + cameraRef: React.MutableRefObject; + rerender: () => void; +} + +export function useGlyphCameraHook(options: UseGlyphCameraOptions): UseGlyphCameraResult { + const cameraRef = useRef(null); + if (!cameraRef.current) { + cameraRef.current = createGlyphPerspectiveCamera(options); + } + + const { sceneRerenderRef } = useGlyphCamera(); + + const rerender = useCallback(() => { + sceneRerenderRef.current?.(); + }, [sceneRerenderRef]); + + return { cameraRef, rerender }; +} + +export { useGlyphCamera } from "./context"; diff --git a/packages/react/src/glyphcss/camera/useGlyphcssCamera.ts b/packages/react/src/glyphcss/camera/useGlyphcssCamera.ts deleted file mode 100644 index addf9270..00000000 --- a/packages/react/src/glyphcss/camera/useGlyphcssCamera.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; -import type { GlyphcssCamera, GlyphcssPerspectiveCameraOptions } from "glyphcss"; -import { createGlyphcssPerspectiveCamera } from "glyphcss"; -import { useGlyphcssSceneContext } from "../scene/context"; - -export interface UseGlyphcssCameraOptions extends GlyphcssPerspectiveCameraOptions {} - -export interface UseGlyphcssCameraResult { - cameraRef: React.MutableRefObject; - rerender: () => void; -} - -export function useGlyphcssCameraHook(options: UseGlyphcssCameraOptions): UseGlyphcssCameraResult { - const cameraRef = useRef(null); - if (!cameraRef.current) { - cameraRef.current = createGlyphcssPerspectiveCamera(options); - } - - const { sceneRef } = useGlyphcssSceneContext(); - - const rerender = useCallback(() => { - const scene = sceneRef.current; - if (scene) scene.rerender(); - }, [sceneRef]); - - // Sync camera props to the scene - useEffect(() => { - const camera = cameraRef.current; - if (!camera) return; - if (options.rotX !== undefined) camera.rotX = options.rotX; - if (options.rotY !== undefined) camera.rotY = options.rotY; - if (options.distance !== undefined) camera.distance = options.distance; - if (options.zoom !== undefined) camera.zoom = options.zoom; - if (options.stretch !== undefined) camera.stretch = options.stretch; - - // Set the camera on the scene - const scene = sceneRef.current; - if (scene) { - scene.setOptions({ camera }); - scene.rerender(); - } - }); - - return { cameraRef, rerender }; -} - -export { useGlyphcssCamera } from "./context"; diff --git a/packages/react/src/glyphcss/controls/GlyphcssFirstPersonControls.test.tsx b/packages/react/src/glyphcss/controls/GlyphFirstPersonControls.test.tsx similarity index 62% rename from packages/react/src/glyphcss/controls/GlyphcssFirstPersonControls.test.tsx rename to packages/react/src/glyphcss/controls/GlyphFirstPersonControls.test.tsx index 166169b9..309c950b 100644 --- a/packages/react/src/glyphcss/controls/GlyphcssFirstPersonControls.test.tsx +++ b/packages/react/src/glyphcss/controls/GlyphFirstPersonControls.test.tsx @@ -1,11 +1,12 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import React, { act } from "react"; import { createRoot } from "react-dom/client"; -import { GlyphcssScene } from "../scene/GlyphcssScene"; -import { GlyphcssFirstPersonControls } from "./GlyphcssFirstPersonControls"; +import { GlyphScene } from "../scene/GlyphScene"; +import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera"; +import { GlyphFirstPersonControls } from "./GlyphFirstPersonControls"; function renderScene( - controlsProps: React.ComponentProps = {}, + controlsProps: React.ComponentProps = {}, ): { container: HTMLElement; root: ReturnType } { const container = document.createElement("div"); document.body.appendChild(container); @@ -13,16 +14,20 @@ function renderScene( act(() => root.render( React.createElement( - GlyphcssScene, + GlyphPerspectiveCamera, {}, - React.createElement(GlyphcssFirstPersonControls, controlsProps), + React.createElement( + GlyphScene, + {}, + React.createElement(GlyphFirstPersonControls, controlsProps), + ), ), ), ); return { container, root }; } -describe("GlyphcssFirstPersonControls — mount inside scene", () => { +describe("GlyphFirstPersonControls — mount inside scene", () => { afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ""; @@ -34,7 +39,7 @@ describe("GlyphcssFirstPersonControls — mount inside scene", () => { it("scene host is present after mounting first-person controls", () => { const { container } = renderScene(); - expect(container.querySelector(".glyphcss-host")).toBeTruthy(); + expect(container.querySelector(".glyph-host")).toBeTruthy(); }); it("accepts drag=false without throwing", () => { @@ -58,34 +63,38 @@ describe("GlyphcssFirstPersonControls — mount inside scene", () => { act(() => root.render( React.createElement( - GlyphcssScene, + GlyphPerspectiveCamera, {}, - React.createElement(GlyphcssFirstPersonControls, { drag: false, keyboard: false }), + React.createElement( + GlyphScene, + {}, + React.createElement(GlyphFirstPersonControls, { drag: false, keyboard: false }), + ), ), ), ); - expect(container.querySelector(".glyphcss-scene")).toBeTruthy(); + expect(container.querySelector(".glyph-scene")).toBeTruthy(); }); it("unmounts cleanly", () => { const { container, root } = renderScene(); act(() => root.unmount()); - expect(container.querySelector(".glyphcss-output")).toBeFalsy(); + expect(container.querySelector(".glyph-output")).toBeFalsy(); }); }); -describe("GlyphcssFirstPersonControls — outside scene", () => { +describe("GlyphFirstPersonControls — outside scene", () => { afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ""; }); - it("throws when mounted outside GlyphcssScene", () => { + it("throws when mounted outside GlyphScene", () => { const container = document.createElement("div"); const root = createRoot(container); expect(() => { act(() => - root.render(React.createElement(GlyphcssFirstPersonControls, {})), + root.render(React.createElement(GlyphFirstPersonControls, {})), ); }).toThrow(); }); diff --git a/packages/react/src/glyphcss/controls/GlyphcssFirstPersonControls.tsx b/packages/react/src/glyphcss/controls/GlyphFirstPersonControls.tsx similarity index 60% rename from packages/react/src/glyphcss/controls/GlyphcssFirstPersonControls.tsx rename to packages/react/src/glyphcss/controls/GlyphFirstPersonControls.tsx index 312b6466..83e1e8a5 100644 --- a/packages/react/src/glyphcss/controls/GlyphcssFirstPersonControls.tsx +++ b/packages/react/src/glyphcss/controls/GlyphFirstPersonControls.tsx @@ -1,13 +1,13 @@ /** - * GlyphcssFirstPersonControls — first-person controls for an ASCII GlyphcssScene. + * GlyphFirstPersonControls — first-person controls for an ASCII GlyphScene. * Pointer-drag looks around; WASD / arrow keys move. */ import { useEffect, useRef } from "react"; -import type { GlyphcssFirstPersonControlsHandle, GlyphcssFirstPersonControlsOptions } from "glyphcss"; -import { createGlyphcssFirstPersonControls } from "glyphcss"; -import { useGlyphcssSceneContext } from "../scene/context"; +import type { GlyphFirstPersonControlsHandle, GlyphFirstPersonControlsOptions } from "glyphcss"; +import { createGlyphFirstPersonControls } from "glyphcss"; +import { useGlyphSceneContext } from "../scene/context"; -export interface GlyphcssFirstPersonControlsProps { +export interface GlyphFirstPersonControlsProps { drag?: boolean; keyboard?: boolean; moveSpeed?: number; @@ -15,29 +15,29 @@ export interface GlyphcssFirstPersonControlsProps { invert?: boolean | number; } -export function GlyphcssFirstPersonControls({ +export function GlyphFirstPersonControls({ drag = true, keyboard = true, moveSpeed = 0.05, lookSpeed = 0.004, invert = false, -}: GlyphcssFirstPersonControlsProps): null { - const { sceneRef } = useGlyphcssSceneContext(); - const controlsRef = useRef(null); +}: GlyphFirstPersonControlsProps): null { + const { sceneRef } = useGlyphSceneContext(); + const controlsRef = useRef(null); const propsRef = useRef({ drag, keyboard, moveSpeed, lookSpeed, invert }); propsRef.current = { drag, keyboard, moveSpeed, lookSpeed, invert }; useEffect(() => { const scene = sceneRef.current; if (!scene) return; - const opts: GlyphcssFirstPersonControlsOptions = { + const opts: GlyphFirstPersonControlsOptions = { drag: propsRef.current.drag, keyboard: propsRef.current.keyboard, moveSpeed: propsRef.current.moveSpeed, lookSpeed: propsRef.current.lookSpeed, invert: propsRef.current.invert, }; - const controls = createGlyphcssFirstPersonControls(scene, opts); + const controls = createGlyphFirstPersonControls(scene, opts); controlsRef.current = controls; return () => { controls.destroy(); diff --git a/packages/react/src/glyphcss/controls/GlyphcssMapControls.test.tsx b/packages/react/src/glyphcss/controls/GlyphMapControls.test.tsx similarity index 63% rename from packages/react/src/glyphcss/controls/GlyphcssMapControls.test.tsx rename to packages/react/src/glyphcss/controls/GlyphMapControls.test.tsx index 22df78dc..697f4f36 100644 --- a/packages/react/src/glyphcss/controls/GlyphcssMapControls.test.tsx +++ b/packages/react/src/glyphcss/controls/GlyphMapControls.test.tsx @@ -1,11 +1,12 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import React, { act } from "react"; import { createRoot } from "react-dom/client"; -import { GlyphcssScene } from "../scene/GlyphcssScene"; -import { GlyphcssMapControls } from "./GlyphcssMapControls"; +import { GlyphScene } from "../scene/GlyphScene"; +import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera"; +import { GlyphMapControls } from "./GlyphMapControls"; function renderScene( - controlsProps: React.ComponentProps = {}, + controlsProps: React.ComponentProps = {}, ): { container: HTMLElement; root: ReturnType } { const container = document.createElement("div"); document.body.appendChild(container); @@ -13,16 +14,20 @@ function renderScene( act(() => root.render( React.createElement( - GlyphcssScene, + GlyphPerspectiveCamera, {}, - React.createElement(GlyphcssMapControls, controlsProps), + React.createElement( + GlyphScene, + {}, + React.createElement(GlyphMapControls, controlsProps), + ), ), ), ); return { container, root }; } -describe("GlyphcssMapControls — mount inside scene", () => { +describe("GlyphMapControls — mount inside scene", () => { afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ""; @@ -34,7 +39,7 @@ describe("GlyphcssMapControls — mount inside scene", () => { it("scene host is present after mounting map controls", () => { const { container } = renderScene(); - expect(container.querySelector(".glyphcss-host")).toBeTruthy(); + expect(container.querySelector(".glyph-host")).toBeTruthy(); }); it("mounts with drag=false", () => { @@ -60,34 +65,38 @@ describe("GlyphcssMapControls — mount inside scene", () => { act(() => root.render( React.createElement( - GlyphcssScene, + GlyphPerspectiveCamera, {}, - React.createElement(GlyphcssMapControls, { drag: false, wheel: false }), + React.createElement( + GlyphScene, + {}, + React.createElement(GlyphMapControls, { drag: false, wheel: false }), + ), ), ), ); - expect(container.querySelector(".glyphcss-scene")).toBeTruthy(); + expect(container.querySelector(".glyph-scene")).toBeTruthy(); }); it("unmounts cleanly", () => { const { container, root } = renderScene(); act(() => root.unmount()); - expect(container.querySelector(".glyphcss-output")).toBeFalsy(); + expect(container.querySelector(".glyph-output")).toBeFalsy(); }); }); -describe("GlyphcssMapControls — outside scene", () => { +describe("GlyphMapControls — outside scene", () => { afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ""; }); - it("throws when mounted outside GlyphcssScene", () => { + it("throws when mounted outside GlyphScene", () => { const container = document.createElement("div"); const root = createRoot(container); expect(() => { act(() => - root.render(React.createElement(GlyphcssMapControls, {})), + root.render(React.createElement(GlyphMapControls, {})), ); }).toThrow(); }); diff --git a/packages/react/src/glyphcss/controls/GlyphcssMapControls.tsx b/packages/react/src/glyphcss/controls/GlyphMapControls.tsx similarity index 63% rename from packages/react/src/glyphcss/controls/GlyphcssMapControls.tsx rename to packages/react/src/glyphcss/controls/GlyphMapControls.tsx index 98386403..37cea609 100644 --- a/packages/react/src/glyphcss/controls/GlyphcssMapControls.tsx +++ b/packages/react/src/glyphcss/controls/GlyphMapControls.tsx @@ -1,40 +1,40 @@ /** - * GlyphcssMapControls — map/pan-mode controls for an ASCII GlyphcssScene. + * GlyphMapControls — map/pan-mode controls for an ASCII GlyphScene. * Left-drag pans; right-drag or Shift+left orbits. Wheel zooms. */ import { useEffect, useRef } from "react"; -import type { GlyphcssMapControlsHandle, GlyphcssMapControlsOptions } from "glyphcss"; -import { createGlyphcssMapControls } from "glyphcss"; -import { useGlyphcssSceneContext } from "../scene/context"; +import type { GlyphMapControlsHandle, GlyphMapControlsOptions } from "glyphcss"; +import { createGlyphMapControls } from "glyphcss"; +import { useGlyphSceneContext } from "../scene/context"; -export interface GlyphcssMapControlsProps { +export interface GlyphMapControlsProps { drag?: boolean; wheel?: boolean; invert?: boolean | number; animate?: false | { speed?: number; axis?: "x" | "y"; pauseOnInteraction?: boolean }; } -export function GlyphcssMapControls({ +export function GlyphMapControls({ drag = true, wheel = true, invert = false, animate = false, -}: GlyphcssMapControlsProps): null { - const { sceneRef } = useGlyphcssSceneContext(); - const controlsRef = useRef(null); +}: GlyphMapControlsProps): null { + const { sceneRef } = useGlyphSceneContext(); + const controlsRef = useRef(null); const propsRef = useRef({ drag, wheel, invert, animate }); propsRef.current = { drag, wheel, invert, animate }; useEffect(() => { const scene = sceneRef.current; if (!scene) return; - const opts: GlyphcssMapControlsOptions = { + const opts: GlyphMapControlsOptions = { drag: propsRef.current.drag, wheel: propsRef.current.wheel, invert: propsRef.current.invert, animate: propsRef.current.animate === false ? false : propsRef.current.animate, }; - const controls = createGlyphcssMapControls(scene, opts); + const controls = createGlyphMapControls(scene, opts); controlsRef.current = controls; return () => { controls.destroy(); diff --git a/packages/react/src/glyphcss/controls/GlyphcssOrbitControls.test.tsx b/packages/react/src/glyphcss/controls/GlyphOrbitControls.test.tsx similarity index 62% rename from packages/react/src/glyphcss/controls/GlyphcssOrbitControls.test.tsx rename to packages/react/src/glyphcss/controls/GlyphOrbitControls.test.tsx index b5ab7336..83bda19b 100644 --- a/packages/react/src/glyphcss/controls/GlyphcssOrbitControls.test.tsx +++ b/packages/react/src/glyphcss/controls/GlyphOrbitControls.test.tsx @@ -1,11 +1,12 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import React, { act } from "react"; import { createRoot } from "react-dom/client"; -import { GlyphcssScene } from "../scene/GlyphcssScene"; -import { GlyphcssOrbitControls } from "./GlyphcssOrbitControls"; +import { GlyphScene } from "../scene/GlyphScene"; +import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera"; +import { GlyphOrbitControls } from "./GlyphOrbitControls"; function renderScene( - controlsProps: React.ComponentProps = {}, + controlsProps: React.ComponentProps = {}, ): { container: HTMLElement; root: ReturnType } { const container = document.createElement("div"); document.body.appendChild(container); @@ -13,16 +14,20 @@ function renderScene( act(() => root.render( React.createElement( - GlyphcssScene, + GlyphPerspectiveCamera, {}, - React.createElement(GlyphcssOrbitControls, controlsProps), + React.createElement( + GlyphScene, + {}, + React.createElement(GlyphOrbitControls, controlsProps), + ), ), ), ); return { container, root }; } -describe("GlyphcssOrbitControls — mount inside scene", () => { +describe("GlyphOrbitControls — mount inside scene", () => { afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ""; @@ -34,13 +39,13 @@ describe("GlyphcssOrbitControls — mount inside scene", () => { it("scene host is present after mounting controls", () => { const { container } = renderScene(); - expect(container.querySelector(".glyphcss-host")).toBeTruthy(); + expect(container.querySelector(".glyph-host")).toBeTruthy(); }); it("renders null — no extra DOM elements from controls", () => { const { container } = renderScene(); // Controls return null, only the scene host + scene + pre should exist - const host = container.querySelector(".glyphcss-host"); + const host = container.querySelector(".glyph-host"); expect(host).toBeTruthy(); }); @@ -64,23 +69,27 @@ describe("GlyphcssOrbitControls — mount inside scene", () => { it("updates props without throwing (drag toggle)", () => { const { container, root } = renderScene({ drag: true }); - expect(container.querySelector(".glyphcss-scene")).toBeTruthy(); + expect(container.querySelector(".glyph-scene")).toBeTruthy(); act(() => root.render( React.createElement( - GlyphcssScene, + GlyphPerspectiveCamera, {}, - React.createElement(GlyphcssOrbitControls, { drag: false }), + React.createElement( + GlyphScene, + {}, + React.createElement(GlyphOrbitControls, { drag: false }), + ), ), ), ); - expect(container.querySelector(".glyphcss-scene")).toBeTruthy(); + expect(container.querySelector(".glyph-scene")).toBeTruthy(); }); it("unmounts cleanly — host is removed from DOM", () => { const { container, root } = renderScene(); act(() => root.unmount()); - expect(container.querySelector(".glyphcss-output")).toBeFalsy(); + expect(container.querySelector(".glyph-output")).toBeFalsy(); }); it("can be mounted and remounted without leaks", () => { @@ -90,14 +99,18 @@ describe("GlyphcssOrbitControls — mount inside scene", () => { act(() => r1.render( React.createElement( - GlyphcssScene, + GlyphPerspectiveCamera, {}, - React.createElement(GlyphcssOrbitControls, {}), + React.createElement( + GlyphScene, + {}, + React.createElement(GlyphOrbitControls, {}), + ), ), ), ); act(() => r1.unmount()); - expect(c1.querySelector(".glyphcss-output")).toBeFalsy(); + expect(c1.querySelector(".glyph-output")).toBeFalsy(); const c2 = document.createElement("div"); document.body.appendChild(c2); @@ -105,29 +118,33 @@ describe("GlyphcssOrbitControls — mount inside scene", () => { act(() => r2.render( React.createElement( - GlyphcssScene, + GlyphPerspectiveCamera, {}, - React.createElement(GlyphcssOrbitControls, {}), + React.createElement( + GlyphScene, + {}, + React.createElement(GlyphOrbitControls, {}), + ), ), ), ); - expect(c2.querySelector(".glyphcss-host")).toBeTruthy(); + expect(c2.querySelector(".glyph-host")).toBeTruthy(); act(() => r2.unmount()); }); }); -describe("GlyphcssOrbitControls — outside scene", () => { +describe("GlyphOrbitControls — outside scene", () => { afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ""; }); - it("throws when mounted outside GlyphcssScene", () => { + it("throws when mounted outside GlyphScene", () => { const container = document.createElement("div"); const root = createRoot(container); expect(() => { act(() => - root.render(React.createElement(GlyphcssOrbitControls, {})), + root.render(React.createElement(GlyphOrbitControls, {})), ); }).toThrow(); }); diff --git a/packages/react/src/glyphcss/controls/GlyphcssOrbitControls.tsx b/packages/react/src/glyphcss/controls/GlyphOrbitControls.tsx similarity index 61% rename from packages/react/src/glyphcss/controls/GlyphcssOrbitControls.tsx rename to packages/react/src/glyphcss/controls/GlyphOrbitControls.tsx index 2f02fb6b..7ce1f5fd 100644 --- a/packages/react/src/glyphcss/controls/GlyphcssOrbitControls.tsx +++ b/packages/react/src/glyphcss/controls/GlyphOrbitControls.tsx @@ -1,15 +1,15 @@ /** - * GlyphcssOrbitControls — orbit controls for an ASCII GlyphcssScene. + * GlyphOrbitControls — orbit controls for an ASCII GlyphScene. * - * Mirrors PolyOrbitControls's props; wraps createGlyphcssOrbitControls from - * the glyphcss package. Must be placed inside a . + * Mirrors PolyOrbitControls's props; wraps createGlyphOrbitControls from + * the glyphcss package. Must be placed inside a . */ import { useEffect, useRef } from "react"; -import type { GlyphcssOrbitControlsHandle, GlyphcssOrbitControlsOptions } from "glyphcss"; -import { createGlyphcssOrbitControls } from "glyphcss"; -import { useGlyphcssSceneContext } from "../scene/context"; +import type { GlyphOrbitControlsHandle, GlyphOrbitControlsOptions } from "glyphcss"; +import { createGlyphOrbitControls } from "glyphcss"; +import { useGlyphSceneContext } from "../scene/context"; -export interface GlyphcssOrbitControlsProps { +export interface GlyphOrbitControlsProps { /** Pointer-drag. Default true. */ drag?: boolean; /** Wheel / pinch zoom. Default true. */ @@ -20,14 +20,14 @@ export interface GlyphcssOrbitControlsProps { animate?: false | { speed?: number; axis?: "x" | "y"; pauseOnInteraction?: boolean }; } -export function GlyphcssOrbitControls({ +export function GlyphOrbitControls({ drag = true, wheel = true, invert = false, animate = false, -}: GlyphcssOrbitControlsProps): null { - const { sceneRef } = useGlyphcssSceneContext(); - const controlsRef = useRef(null); +}: GlyphOrbitControlsProps): null { + const { sceneRef } = useGlyphSceneContext(); + const controlsRef = useRef(null); const propsRef = useRef({ drag, wheel, invert, animate }); propsRef.current = { drag, wheel, invert, animate }; @@ -36,13 +36,13 @@ export function GlyphcssOrbitControls({ const scene = sceneRef.current; if (!scene) return; - const opts: GlyphcssOrbitControlsOptions = { + const opts: GlyphOrbitControlsOptions = { drag: propsRef.current.drag, wheel: propsRef.current.wheel, invert: propsRef.current.invert, animate: propsRef.current.animate === false ? false : propsRef.current.animate, }; - const controls = createGlyphcssOrbitControls(scene, opts); + const controls = createGlyphOrbitControls(scene, opts); controlsRef.current = controls; return () => { diff --git a/packages/react/src/glyphcss/controls/index.ts b/packages/react/src/glyphcss/controls/index.ts index 34008e2e..9d249651 100644 --- a/packages/react/src/glyphcss/controls/index.ts +++ b/packages/react/src/glyphcss/controls/index.ts @@ -1,6 +1,6 @@ -export { GlyphcssOrbitControls } from "./GlyphcssOrbitControls"; -export type { GlyphcssOrbitControlsProps } from "./GlyphcssOrbitControls"; -export { GlyphcssMapControls } from "./GlyphcssMapControls"; -export type { GlyphcssMapControlsProps } from "./GlyphcssMapControls"; -export { GlyphcssFirstPersonControls } from "./GlyphcssFirstPersonControls"; -export type { GlyphcssFirstPersonControlsProps } from "./GlyphcssFirstPersonControls"; +export { GlyphOrbitControls } from "./GlyphOrbitControls"; +export type { GlyphOrbitControlsProps } from "./GlyphOrbitControls"; +export { GlyphMapControls } from "./GlyphMapControls"; +export type { GlyphMapControlsProps } from "./GlyphMapControls"; +export { GlyphFirstPersonControls } from "./GlyphFirstPersonControls"; +export type { GlyphFirstPersonControlsProps } from "./GlyphFirstPersonControls"; diff --git a/packages/react/src/glyphcss/helpers/GlyphcssAxesHelper.test.tsx b/packages/react/src/glyphcss/helpers/GlyphAxesHelper.test.tsx similarity index 59% rename from packages/react/src/glyphcss/helpers/GlyphcssAxesHelper.test.tsx rename to packages/react/src/glyphcss/helpers/GlyphAxesHelper.test.tsx index 36a2b420..10be091b 100644 --- a/packages/react/src/glyphcss/helpers/GlyphcssAxesHelper.test.tsx +++ b/packages/react/src/glyphcss/helpers/GlyphAxesHelper.test.tsx @@ -1,11 +1,12 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import React, { act } from "react"; import { createRoot } from "react-dom/client"; -import { GlyphcssScene } from "../scene/GlyphcssScene"; -import { GlyphcssAxesHelper } from "./GlyphcssAxesHelper"; +import { GlyphScene } from "../scene/GlyphScene"; +import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera"; +import { GlyphAxesHelper } from "./GlyphAxesHelper"; function renderScene( - helperProps: React.ComponentProps = {}, + helperProps: React.ComponentProps = {}, ): { container: HTMLElement; root: ReturnType } { const container = document.createElement("div"); document.body.appendChild(container); @@ -13,16 +14,20 @@ function renderScene( act(() => root.render( React.createElement( - GlyphcssScene, + GlyphPerspectiveCamera, {}, - React.createElement(GlyphcssAxesHelper, helperProps), + React.createElement( + GlyphScene, + {}, + React.createElement(GlyphAxesHelper, helperProps), + ), ), ), ); return { container, root }; } -describe("GlyphcssAxesHelper — mount inside scene", () => { +describe("GlyphAxesHelper — mount inside scene", () => { afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ""; @@ -34,12 +39,12 @@ describe("GlyphcssAxesHelper — mount inside scene", () => { it("scene host is present after mounting axes helper", () => { const { container } = renderScene(); - expect(container.querySelector(".glyphcss-host")).toBeTruthy(); + expect(container.querySelector(".glyph-host")).toBeTruthy(); }); it("scene output
 is present after mounting axes helper", () => {
     const { container } = renderScene();
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
   });
 
   it("accepts size=2 without throwing", () => {
@@ -59,19 +64,23 @@ describe("GlyphcssAxesHelper — mount inside scene", () => {
     act(() =>
       root.render(
         React.createElement(
-          GlyphcssScene,
+          GlyphPerspectiveCamera,
           {},
-          React.createElement(GlyphcssAxesHelper, { size: 3 }),
+          React.createElement(
+            GlyphScene,
+            {},
+            React.createElement(GlyphAxesHelper, { size: 3 }),
+          ),
         ),
       ),
     );
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
   });
 
   it("unmounts cleanly", () => {
     const { container, root } = renderScene({ size: 1 });
     act(() => root.unmount());
-    expect(container.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(container.querySelector(".glyph-output")).toBeFalsy();
   });
 
   it("can be mounted twice in sequence without leaks", () => {
@@ -80,37 +89,45 @@ describe("GlyphcssAxesHelper — mount inside scene", () => {
     const r1 = createRoot(c1);
     act(() =>
       r1.render(
-        React.createElement(GlyphcssScene, {}, React.createElement(GlyphcssAxesHelper, {})),
+        React.createElement(
+          GlyphPerspectiveCamera,
+          {},
+          React.createElement(GlyphScene, {}, React.createElement(GlyphAxesHelper, {})),
+        ),
       ),
     );
     act(() => r1.unmount());
-    expect(c1.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(c1.querySelector(".glyph-output")).toBeFalsy();
 
     const c2 = document.createElement("div");
     document.body.appendChild(c2);
     const r2 = createRoot(c2);
     act(() =>
       r2.render(
-        React.createElement(GlyphcssScene, {}, React.createElement(GlyphcssAxesHelper, { size: 2 })),
+        React.createElement(
+          GlyphPerspectiveCamera,
+          {},
+          React.createElement(GlyphScene, {}, React.createElement(GlyphAxesHelper, { size: 2 })),
+        ),
       ),
     );
-    expect(c2.querySelector(".glyphcss-host")).toBeTruthy();
+    expect(c2.querySelector(".glyph-host")).toBeTruthy();
     act(() => r2.unmount());
   });
 });
 
-describe("GlyphcssAxesHelper — outside scene", () => {
+describe("GlyphAxesHelper — outside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when mounted outside GlyphcssScene", () => {
+  it("throws when mounted outside GlyphScene", () => {
     const container = document.createElement("div");
     const root = createRoot(container);
     expect(() => {
       act(() =>
-        root.render(React.createElement(GlyphcssAxesHelper, {})),
+        root.render(React.createElement(GlyphAxesHelper, {})),
       );
     }).toThrow();
   });
diff --git a/packages/react/src/glyphcss/helpers/GlyphcssAxesHelper.tsx b/packages/react/src/glyphcss/helpers/GlyphAxesHelper.tsx
similarity index 76%
rename from packages/react/src/glyphcss/helpers/GlyphcssAxesHelper.tsx
rename to packages/react/src/glyphcss/helpers/GlyphAxesHelper.tsx
index cf8ea6e0..3e13b1be 100644
--- a/packages/react/src/glyphcss/helpers/GlyphcssAxesHelper.tsx
+++ b/packages/react/src/glyphcss/helpers/GlyphAxesHelper.tsx
@@ -1,16 +1,16 @@
 /**
- * GlyphcssAxesHelper — ASCII-mode axes helper.
+ * GlyphAxesHelper — ASCII-mode axes helper.
  *
  * In ASCII rendering there are no polygon mesh overlays, so this helper
  * registers axis-indicator polygons with the scene so they appear in the
  * rasterized output. Mirrors PolyAxesHelper's prop surface.
  */
 import { memo, useEffect, useMemo, useRef } from "react";
-import type { GlyphcssMeshHandle } from "glyphcss";
+import type { GlyphMeshHandle } from "glyphcss";
 import type { Vec3, Polygon } from "@glyphcss/core";
-import { useGlyphcssSceneContext } from "../scene/context";
+import { useGlyphSceneContext } from "../scene/context";
 
-export interface GlyphcssAxesHelperProps {
+export interface GlyphAxesHelperProps {
   /** Length of each axis bar in world units. Default 1. */
   size?: number;
 }
@@ -36,9 +36,9 @@ function axisPolygons(size: number): Polygon[] {
   return polygons;
 }
 
-function GlyphcssAxesHelperInner({ size = 1 }: GlyphcssAxesHelperProps) {
-  const { sceneRef } = useGlyphcssSceneContext();
-  const meshRef = useRef(null);
+function GlyphAxesHelperInner({ size = 1 }: GlyphAxesHelperProps) {
+  const { sceneRef } = useGlyphSceneContext();
+  const meshRef = useRef(null);
   const polygons = useMemo(() => axisPolygons(size), [size]);
 
   useEffect(() => {
@@ -55,4 +55,4 @@ function GlyphcssAxesHelperInner({ size = 1 }: GlyphcssAxesHelperProps) {
   return null;
 }
 
-export const GlyphcssAxesHelper = memo(GlyphcssAxesHelperInner);
+export const GlyphAxesHelper = memo(GlyphAxesHelperInner);
diff --git a/packages/react/src/glyphcss/helpers/GlyphcssDirectionalLightHelper.test.tsx b/packages/react/src/glyphcss/helpers/GlyphDirectionalLightHelper.test.tsx
similarity index 62%
rename from packages/react/src/glyphcss/helpers/GlyphcssDirectionalLightHelper.test.tsx
rename to packages/react/src/glyphcss/helpers/GlyphDirectionalLightHelper.test.tsx
index f398d609..96e95c12 100644
--- a/packages/react/src/glyphcss/helpers/GlyphcssDirectionalLightHelper.test.tsx
+++ b/packages/react/src/glyphcss/helpers/GlyphDirectionalLightHelper.test.tsx
@@ -1,11 +1,12 @@
 import { describe, it, expect, afterEach, vi } from "vitest";
 import React, { act } from "react";
 import { createRoot } from "react-dom/client";
-import { GlyphcssScene } from "../scene/GlyphcssScene";
-import { GlyphcssDirectionalLightHelper } from "./GlyphcssDirectionalLightHelper";
+import { GlyphScene } from "../scene/GlyphScene";
+import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera";
+import { GlyphDirectionalLightHelper } from "./GlyphDirectionalLightHelper";
 
 function renderScene(
-  helperProps: React.ComponentProps = {},
+  helperProps: React.ComponentProps = {},
 ): { container: HTMLElement; root: ReturnType } {
   const container = document.createElement("div");
   document.body.appendChild(container);
@@ -13,16 +14,20 @@ function renderScene(
   act(() =>
     root.render(
       React.createElement(
-        GlyphcssScene,
+        GlyphPerspectiveCamera,
         {},
-        React.createElement(GlyphcssDirectionalLightHelper, helperProps),
+        React.createElement(
+          GlyphScene,
+          {},
+          React.createElement(GlyphDirectionalLightHelper, helperProps),
+        ),
       ),
     ),
   );
   return { container, root };
 }
 
-describe("GlyphcssDirectionalLightHelper — mount inside scene", () => {
+describe("GlyphDirectionalLightHelper — mount inside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -34,12 +39,12 @@ describe("GlyphcssDirectionalLightHelper — mount inside scene", () => {
 
   it("scene host is present after mounting light helper", () => {
     const { container } = renderScene();
-    expect(container.querySelector(".glyphcss-host")).toBeTruthy();
+    expect(container.querySelector(".glyph-host")).toBeTruthy();
   });
 
   it("scene output 
 is present after mounting light helper", () => {
     const { container } = renderScene();
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
   });
 
   it("accepts custom position", () => {
@@ -65,34 +70,38 @@ describe("GlyphcssDirectionalLightHelper — mount inside scene", () => {
     act(() =>
       root.render(
         React.createElement(
-          GlyphcssScene,
+          GlyphPerspectiveCamera,
           {},
-          React.createElement(GlyphcssDirectionalLightHelper, { position: [2, 2, 2] }),
+          React.createElement(
+            GlyphScene,
+            {},
+            React.createElement(GlyphDirectionalLightHelper, { position: [2, 2, 2] }),
+          ),
         ),
       ),
     );
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
   });
 
   it("unmounts cleanly", () => {
     const { container, root } = renderScene({ position: [1, 1, 1] });
     act(() => root.unmount());
-    expect(container.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(container.querySelector(".glyph-output")).toBeFalsy();
   });
 });
 
-describe("GlyphcssDirectionalLightHelper — outside scene", () => {
+describe("GlyphDirectionalLightHelper — outside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when mounted outside GlyphcssScene", () => {
+  it("throws when mounted outside GlyphScene", () => {
     const container = document.createElement("div");
     const root = createRoot(container);
     expect(() => {
       act(() =>
-        root.render(React.createElement(GlyphcssDirectionalLightHelper, {})),
+        root.render(React.createElement(GlyphDirectionalLightHelper, {})),
       );
     }).toThrow();
   });
diff --git a/packages/react/src/glyphcss/helpers/GlyphcssDirectionalLightHelper.tsx b/packages/react/src/glyphcss/helpers/GlyphDirectionalLightHelper.tsx
similarity index 76%
rename from packages/react/src/glyphcss/helpers/GlyphcssDirectionalLightHelper.tsx
rename to packages/react/src/glyphcss/helpers/GlyphDirectionalLightHelper.tsx
index 199da407..c5e7788d 100644
--- a/packages/react/src/glyphcss/helpers/GlyphcssDirectionalLightHelper.tsx
+++ b/packages/react/src/glyphcss/helpers/GlyphDirectionalLightHelper.tsx
@@ -1,15 +1,15 @@
 /**
- * GlyphcssDirectionalLightHelper — ASCII-mode directional light helper.
+ * GlyphDirectionalLightHelper — ASCII-mode directional light helper.
  *
  * Shows a small octahedron at the light source position in the ASCII output.
  * Shows the light origin as an ASCII octahedron glyph in the output.
  */
 import { memo, useEffect, useMemo, useRef } from "react";
-import type { GlyphcssMeshHandle } from "glyphcss";
+import type { GlyphMeshHandle } from "glyphcss";
 import type { Vec3, Polygon } from "@glyphcss/core";
-import { useGlyphcssSceneContext } from "../scene/context";
+import { useGlyphSceneContext } from "../scene/context";
 
-export interface GlyphcssDirectionalLightHelperProps {
+export interface GlyphDirectionalLightHelperProps {
   /** Light source position in world space. Default [1, 1, 1]. */
   position?: Vec3;
   /** Glyph color. Default "#ffff00". */
@@ -39,13 +39,13 @@ function lightMarkerPolygons(position: Vec3, color: string, size: number): Polyg
   ];
 }
 
-function GlyphcssDirectionalLightHelperInner({
+function GlyphDirectionalLightHelperInner({
   position = [1, 1, 1],
   color = "#ffff00",
   size = 0.1,
-}: GlyphcssDirectionalLightHelperProps) {
-  const { sceneRef } = useGlyphcssSceneContext();
-  const meshRef = useRef(null);
+}: GlyphDirectionalLightHelperProps) {
+  const { sceneRef } = useGlyphSceneContext();
+  const meshRef = useRef(null);
   const polygons = useMemo(
     () => lightMarkerPolygons(position, color, size),
     [position, color, size],
@@ -65,4 +65,4 @@ function GlyphcssDirectionalLightHelperInner({
   return null;
 }
 
-export const GlyphcssDirectionalLightHelper = memo(GlyphcssDirectionalLightHelperInner);
+export const GlyphDirectionalLightHelper = memo(GlyphDirectionalLightHelperInner);
diff --git a/packages/react/src/glyphcss/helpers/index.ts b/packages/react/src/glyphcss/helpers/index.ts
index b8e244cf..91ae8517 100644
--- a/packages/react/src/glyphcss/helpers/index.ts
+++ b/packages/react/src/glyphcss/helpers/index.ts
@@ -1,4 +1,4 @@
-export { GlyphcssAxesHelper } from "./GlyphcssAxesHelper";
-export type { GlyphcssAxesHelperProps } from "./GlyphcssAxesHelper";
-export { GlyphcssDirectionalLightHelper } from "./GlyphcssDirectionalLightHelper";
-export type { GlyphcssDirectionalLightHelperProps } from "./GlyphcssDirectionalLightHelper";
+export { GlyphAxesHelper } from "./GlyphAxesHelper";
+export type { GlyphAxesHelperProps } from "./GlyphAxesHelper";
+export { GlyphDirectionalLightHelper } from "./GlyphDirectionalLightHelper";
+export type { GlyphDirectionalLightHelperProps } from "./GlyphDirectionalLightHelper";
diff --git a/packages/react/src/glyphcss/index.ts b/packages/react/src/glyphcss/index.ts
index eacd4282..38194c2a 100644
--- a/packages/react/src/glyphcss/index.ts
+++ b/packages/react/src/glyphcss/index.ts
@@ -1,42 +1,42 @@
 // ── Scene ───────────────────────────────────────────────────────────────────
-export { GlyphcssScene, GlyphcssMesh, GlyphcssGround, GlyphcssHotspot, GlyphcssSceneContext, useGlyphcssSceneContext, useGlyphcssMesh, findGlyphcssMeshHandle, pointInMeshElement, findMeshUnderPoint } from "./scene";
+export { GlyphScene, GlyphMesh, GlyphGround, GlyphHotspot, GlyphSceneContext, useGlyphSceneContext, useGlyphMesh, findGlyphMeshHandle, pointInMeshElement, findMeshUnderPoint } from "./scene";
 export type {
-  GlyphcssSceneProps,
-  GlyphcssMeshProps,
-  GlyphcssGroundProps,
-  GlyphcssHotspotProps,
-  GlyphcssSceneContextValue,
-  UseGlyphcssMeshResult,
-  UseGlyphcssMeshOptions,
+  GlyphSceneProps,
+  GlyphMeshProps,
+  GlyphGroundProps,
+  GlyphHotspotProps,
+  GlyphSceneContextValue,
+  UseGlyphMeshResult,
+  UseGlyphMeshOptions,
 } from "./scene";
 
 // ── Camera ──────────────────────────────────────────────────────────────────
-export { GlyphcssCamera, GlyphcssPerspectiveCamera, GlyphcssOrthographicCamera, GlyphcssCameraContext, useGlyphcssCamera } from "./camera";
+export { GlyphCamera, GlyphPerspectiveCamera, GlyphOrthographicCamera, GlyphCameraContext, useGlyphCamera } from "./camera";
 export type {
-  GlyphcssCameraProps,
-  GlyphcssPerspectiveCameraProps,
-  GlyphcssOrthographicCameraProps,
-  GlyphcssCameraContextValue,
+  GlyphCameraProps,
+  GlyphPerspectiveCameraProps,
+  GlyphOrthographicCameraProps,
+  GlyphCameraContextValue,
 } from "./camera";
 
 // ── Controls ────────────────────────────────────────────────────────────────
-export { GlyphcssOrbitControls, GlyphcssMapControls, GlyphcssFirstPersonControls } from "./controls";
+export { GlyphOrbitControls, GlyphMapControls, GlyphFirstPersonControls } from "./controls";
 export type {
-  GlyphcssOrbitControlsProps,
-  GlyphcssMapControlsProps,
-  GlyphcssFirstPersonControlsProps,
+  GlyphOrbitControlsProps,
+  GlyphMapControlsProps,
+  GlyphFirstPersonControlsProps,
 } from "./controls";
 
 // ── Helpers ─────────────────────────────────────────────────────────────────
-export { GlyphcssAxesHelper, GlyphcssDirectionalLightHelper } from "./helpers";
+export { GlyphAxesHelper, GlyphDirectionalLightHelper } from "./helpers";
 export type {
-  GlyphcssAxesHelperProps,
-  GlyphcssDirectionalLightHelperProps,
+  GlyphAxesHelperProps,
+  GlyphDirectionalLightHelperProps,
 } from "./helpers";
 
 // ── Styles ──────────────────────────────────────────────────────────────────
-export { injectGlyphcssBaseStyles } from "./styles";
+export { injectGlyphBaseStyles } from "./styles";
 
 // ── Animation ───────────────────────────────────────────────────────────────
-export { useGlyphcssAnimation } from "./animation/useGlyphcssAnimation";
-export type { UseGlyphcssAnimationResult } from "./animation/useGlyphcssAnimation";
+export { useGlyphAnimation } from "./animation/useGlyphAnimation";
+export type { UseGlyphAnimationResult } from "./animation/useGlyphAnimation";
diff --git a/packages/react/src/glyphcss/scene/GlyphcssGround.test.tsx b/packages/react/src/glyphcss/scene/GlyphGround.test.tsx
similarity index 61%
rename from packages/react/src/glyphcss/scene/GlyphcssGround.test.tsx
rename to packages/react/src/glyphcss/scene/GlyphGround.test.tsx
index e89fd6d0..a727aee9 100644
--- a/packages/react/src/glyphcss/scene/GlyphcssGround.test.tsx
+++ b/packages/react/src/glyphcss/scene/GlyphGround.test.tsx
@@ -1,24 +1,29 @@
 import { describe, it, expect, afterEach, vi } from "vitest";
 import React, { act } from "react";
 import { createRoot } from "react-dom/client";
-import { GlyphcssScene } from "./GlyphcssScene";
-import { GlyphcssGround } from "./GlyphcssGround";
+import { GlyphScene } from "./GlyphScene";
+import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera";
+import { GlyphGround } from "./GlyphGround";
 
 function renderInScene(
-  groundProps: React.ComponentProps = {},
+  groundProps: React.ComponentProps = {},
 ): HTMLElement {
   const container = document.createElement("div");
   document.body.appendChild(container);
   const root = createRoot(container);
   act(() =>
     root.render(
-      React.createElement(GlyphcssScene, {}, React.createElement(GlyphcssGround, groundProps)),
+      React.createElement(
+        GlyphPerspectiveCamera,
+        {},
+        React.createElement(GlyphScene, {}, React.createElement(GlyphGround, groundProps)),
+      ),
     ),
   );
   return container;
 }
 
-describe("GlyphcssGround (React) — mounts inside scene", () => {
+describe("GlyphGround (React) — mounts inside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -28,9 +33,9 @@ describe("GlyphcssGround (React) — mounts inside scene", () => {
     expect(() => renderInScene()).not.toThrow();
   });
 
-  it("renders a .glyphcss-mesh wrapper inside the scene", () => {
+  it("renders a .glyph-mesh wrapper inside the scene", () => {
     const container = renderInScene();
-    expect(container.querySelector(".glyphcss-mesh")).toBeTruthy();
+    expect(container.querySelector(".glyph-mesh")).toBeTruthy();
   });
 
   it("accepts size prop without throwing", () => {
@@ -49,24 +54,24 @@ describe("GlyphcssGround (React) — mounts inside scene", () => {
     expect(() => renderInScene({ id: "ground" })).not.toThrow();
   });
 
-  it("sets data-glyphcss-mesh-id when id is provided", () => {
+  it("sets data-glyph-mesh-id when id is provided", () => {
     const container = renderInScene({ id: "ground-plane" });
-    const mesh = container.querySelector("[data-glyphcss-mesh-id='ground-plane']");
+    const mesh = container.querySelector("[data-glyph-mesh-id='ground-plane']");
     expect(mesh).toBeTruthy();
   });
 });
 
-describe("GlyphcssGround (React) — throws outside scene", () => {
+describe("GlyphGround (React) — throws outside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when mounted outside GlyphcssScene", () => {
+  it("throws when mounted outside GlyphScene", () => {
     const container = document.createElement("div");
     const root = createRoot(container);
     expect(() => {
-      act(() => root.render(React.createElement(GlyphcssGround)));
+      act(() => root.render(React.createElement(GlyphGround)));
     }).toThrow();
   });
 });
diff --git a/packages/react/src/glyphcss/scene/GlyphcssGround.tsx b/packages/react/src/glyphcss/scene/GlyphGround.tsx
similarity index 75%
rename from packages/react/src/glyphcss/scene/GlyphcssGround.tsx
rename to packages/react/src/glyphcss/scene/GlyphGround.tsx
index 45f178b0..55e5381d 100644
--- a/packages/react/src/glyphcss/scene/GlyphcssGround.tsx
+++ b/packages/react/src/glyphcss/scene/GlyphGround.tsx
@@ -1,6 +1,6 @@
 /**
- * GlyphcssGround — convenience wrapper around `planePolygons` that registers
- * a horizontal ground plane with the parent GlyphcssScene.
+ * GlyphGround — convenience wrapper around `planePolygons` that registers
+ * a horizontal ground plane with the parent GlyphScene.
  *
  * Mirrors voxcss's `` component prop surface.
  */
@@ -8,9 +8,9 @@ import { memo, useMemo } from "react";
 import type { CSSProperties } from "react";
 import type { Vec3 } from "@glyphcss/core";
 import { planePolygons } from "@glyphcss/core";
-import { GlyphcssMesh } from "./GlyphcssMesh";
+import { GlyphMesh } from "./GlyphMesh";
 
-export interface GlyphcssGroundProps {
+export interface GlyphGroundProps {
   /** Half-extent of the ground plane in world units. Default 5. */
   size?: number;
   /** Fill color. Default "#444444". */
@@ -25,7 +25,7 @@ export interface GlyphcssGroundProps {
   style?: CSSProperties;
 }
 
-function GlyphcssGroundInner({
+function GlyphGroundInner({
   size = 5,
   color = "#444444",
   position = [0, -0.5, 0],
@@ -33,7 +33,7 @@ function GlyphcssGroundInner({
   id,
   className,
   style,
-}: GlyphcssGroundProps) {
+}: GlyphGroundProps) {
   // XZ plane (axis=1 → normal along Y)
   const polygons = useMemo(
     () =>
@@ -47,7 +47,7 @@ function GlyphcssGroundInner({
   );
 
   return (
-    ,
+  hotspotProps: React.ComponentProps,
   children?: React.ReactNode,
 ): { container: HTMLElement; root: ReturnType } {
   const container = document.createElement("div");
@@ -14,16 +15,20 @@ function renderScene(
   act(() =>
     root.render(
       React.createElement(
-        GlyphcssScene,
+        GlyphPerspectiveCamera,
         {},
-        React.createElement(GlyphcssHotspot, hotspotProps, children),
+        React.createElement(
+          GlyphScene,
+          {},
+          React.createElement(GlyphHotspot, hotspotProps, children),
+        ),
       ),
     ),
   );
   return { container, root };
 }
 
-describe("GlyphcssHotspot — mount inside scene (no children)", () => {
+describe("GlyphHotspot — mount inside scene (no children)", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -37,13 +42,13 @@ describe("GlyphcssHotspot — mount inside scene (no children)", () => {
 
   it("scene host is present after mounting hotspot", () => {
     const { container } = renderScene({ id: "hs1", at: [0, 0, 0] });
-    expect(container.querySelector(".glyphcss-host")).toBeTruthy();
+    expect(container.querySelector(".glyph-host")).toBeTruthy();
   });
 
   it("renders null (no DOM node) when no children", () => {
     const { container } = renderScene({ id: "hs1", at: [0, 0, 0] });
-    // With no children/onClick/className, GlyphcssHotspot returns null
-    expect(container.querySelector("[data-glyphcss-hotspot-id]")).toBeFalsy();
+    // With no children/onClick/className, GlyphHotspot returns null
+    expect(container.querySelector("[data-glyph-hotspot-id]")).toBeFalsy();
   });
 
   it("accepts a size prop without throwing", () => {
@@ -55,27 +60,28 @@ describe("GlyphcssHotspot — mount inside scene (no children)", () => {
   it("unmounts cleanly", () => {
     const { container, root } = renderScene({ id: "hs1", at: [0, 0, 0] });
     act(() => root.unmount());
-    expect(container.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(container.querySelector(".glyph-output")).toBeFalsy();
   });
 });
 
-describe("GlyphcssHotspot — mount inside scene (with children)", () => {
+describe("GlyphHotspot — mount inside scene (with children)", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("renders a sentinel div when children are provided", () => {
+  it("portals children into the hotspot overlay element", () => {
     const { container } = renderScene(
       { id: "hs-child", at: [0, 1, 0] },
       React.createElement("span", { className: "tooltip" }, "hello"),
     );
-    // When children are provided, the sentinel div with data-glyphcss-hotspot-id is rendered
-    const sentinel = container.querySelector("[data-glyphcss-hotspot-id='hs-child']");
-    expect(sentinel).toBeTruthy();
+    // Children are portalled into the div.glyph-hotspot[data-hotspot-id] overlay.
+    const overlay = container.querySelector("[data-hotspot-id='hs-child']");
+    expect(overlay).toBeTruthy();
+    expect(overlay?.querySelector(".tooltip")).toBeTruthy();
   });
 
-  it("renders children inside the sentinel", () => {
+  it("renders children inside the hotspot overlay", () => {
     const { container } = renderScene(
       { id: "hs-child2", at: [0, 1, 0] },
       React.createElement("span", { className: "tooltip-inner" }, "world"),
@@ -85,38 +91,38 @@ describe("GlyphcssHotspot — mount inside scene (with children)", () => {
     expect(tooltip?.textContent).toBe("world");
   });
 
-  it("applies className to the sentinel div", () => {
+  it("applies className to the hotspot overlay element", () => {
     const { container } = renderScene(
       { id: "hs-cls", at: [0, 0, 0], className: "my-hotspot" },
       React.createElement("span", {}, "x"),
     );
-    const sentinel = container.querySelector(".my-hotspot");
-    expect(sentinel).toBeTruthy();
+    const overlay = container.querySelector(".my-hotspot");
+    expect(overlay).toBeTruthy();
   });
 
-  it("sentinel is removed after unmount", () => {
+  it("overlay and children are removed after unmount", () => {
     const { container, root } = renderScene(
       { id: "hs-unmount", at: [0, 0, 0] },
       React.createElement("span", {}, "bye"),
     );
     act(() => root.unmount());
-    expect(container.querySelector("[data-glyphcss-hotspot-id]")).toBeFalsy();
+    expect(container.querySelector("[data-hotspot-id='hs-unmount']")).toBeFalsy();
   });
 });
 
-describe("GlyphcssHotspot — outside scene", () => {
+describe("GlyphHotspot — outside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when mounted outside GlyphcssScene", () => {
+  it("throws when mounted outside GlyphScene", () => {
     const container = document.createElement("div");
     const root = createRoot(container);
     expect(() => {
       act(() =>
         root.render(
-          React.createElement(GlyphcssHotspot, { id: "err", at: [0, 0, 0] }),
+          React.createElement(GlyphHotspot, { id: "err", at: [0, 0, 0] }),
         ),
       );
     }).toThrow();
diff --git a/packages/react/src/glyphcss/scene/GlyphHotspot.tsx b/packages/react/src/glyphcss/scene/GlyphHotspot.tsx
new file mode 100644
index 00000000..cd89edd3
--- /dev/null
+++ b/packages/react/src/glyphcss/scene/GlyphHotspot.tsx
@@ -0,0 +1,88 @@
+/**
+ * GlyphHotspot — an interactive overlay element positioned at a 3D world point.
+ *
+ * Wraps `scene.addHotspot()`. The scene projects the `at` position into grid
+ * cell coordinates on each render, and the glyphcss backend positions the
+ * overlay div accordingly over the 
 output.
+ *
+ * Children are portalled into the absolutely-positioned overlay div so they
+ * track the hotspot as the camera moves.
+ */
+import { memo, useEffect, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import type { ReactNode, MouseEventHandler } from "react";
+import type { Vec3 } from "@glyphcss/core";
+import type { GlyphHotspotHandle } from "glyphcss";
+import { useGlyphSceneContext } from "./context";
+
+export interface GlyphHotspotProps {
+  /** Stable identifier for this hotspot. */
+  id: string;
+  /** 3D world-space anchor. */
+  at: Vec3;
+  /** Hitbox size in character cells `[cols, rows]`. Default `[1, 1]`. */
+  size?: [number, number];
+  onClick?: MouseEventHandler;
+  className?: string;
+  "aria-label"?: string;
+  children?: ReactNode;
+}
+
+function GlyphHotspotInner({
+  id,
+  at,
+  size,
+  onClick,
+  className,
+  children,
+}: GlyphHotspotProps) {
+  const { sceneRef } = useGlyphSceneContext();
+  const hotspotRef = useRef(null);
+  const onClickRef = useRef(onClick);
+  onClickRef.current = onClick;
+  // Track the overlay DOM element so we can portal children into it.
+  const [overlayEl, setOverlayEl] = useState(null);
+
+  // Register with the scene's hotspot system
+  const atKey = useMemo(() => at.join(","), [at]);
+  const sizeKey = size ? size.join(",") : "";
+
+  useEffect(() => {
+    const scene = sceneRef.current;
+    if (!scene) return;
+    const handle = scene.addHotspot(
+      { id, at, size },
+      () => onClickRef.current?.({} as Parameters>[0]),
+    );
+    hotspotRef.current = handle;
+    setOverlayEl(handle.el);
+    return () => {
+      handle.remove();
+      hotspotRef.current = null;
+      setOverlayEl(null);
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [sceneRef, id, atKey, sizeKey]);
+
+  // Wire the onClick handler to the overlay element.
+  useEffect(() => {
+    const el = overlayEl;
+    if (!el || !onClick) return;
+    const handler = (e: MouseEvent) => onClick(e as unknown as Parameters>[0]);
+    el.addEventListener("click", handler);
+    return () => el.removeEventListener("click", handler);
+  }, [overlayEl, onClick]);
+
+  // Apply className to the overlay div.
+  useEffect(() => {
+    if (!overlayEl) return;
+    overlayEl.className = `glyph-hotspot${className ? ` ${className}` : ""}`;
+  }, [overlayEl, className]);
+
+  // Portal children into the absolutely-positioned overlay div so they move
+  // with the hotspot on every render cycle.
+  if (!children || !overlayEl) return null;
+  return createPortal(children, overlayEl);
+}
+
+export const GlyphHotspot = memo(GlyphHotspotInner);
diff --git a/packages/react/src/glyphcss/scene/GlyphcssMesh.test.tsx b/packages/react/src/glyphcss/scene/GlyphMesh.test.tsx
similarity index 71%
rename from packages/react/src/glyphcss/scene/GlyphcssMesh.test.tsx
rename to packages/react/src/glyphcss/scene/GlyphMesh.test.tsx
index 6485920b..d1286ac9 100644
--- a/packages/react/src/glyphcss/scene/GlyphcssMesh.test.tsx
+++ b/packages/react/src/glyphcss/scene/GlyphMesh.test.tsx
@@ -1,8 +1,9 @@
 import { describe, it, expect, afterEach, vi } from "vitest";
 import React, { act } from "react";
 import { createRoot } from "react-dom/client";
-import { GlyphcssScene } from "./GlyphcssScene";
-import { GlyphcssMesh } from "./GlyphcssMesh";
+import { GlyphScene } from "./GlyphScene";
+import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera";
+import { GlyphMesh } from "./GlyphMesh";
 import type { Polygon } from "@glyphcss/core";
 
 const POLYGON: Polygon = {
@@ -15,42 +16,46 @@ const POLYGON: Polygon = {
 };
 
 function renderMesh(
-  meshProps: React.ComponentProps,
+  meshProps: React.ComponentProps,
 ): HTMLElement {
   const container = document.createElement("div");
   document.body.appendChild(container);
   const root = createRoot(container);
   act(() =>
     root.render(
-      React.createElement(GlyphcssScene, {}, React.createElement(GlyphcssMesh, meshProps)),
+      React.createElement(
+        GlyphPerspectiveCamera,
+        {},
+        React.createElement(GlyphScene, {}, React.createElement(GlyphMesh, meshProps)),
+      ),
     ),
   );
   return container;
 }
 
-describe("GlyphcssMesh (React) — id prop wiring", () => {
+describe("GlyphMesh (React) — id prop wiring", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("sets data-glyphcss-mesh-id on the wrapper div when id is given", () => {
+  it("sets data-glyph-mesh-id on the wrapper div when id is given", () => {
     const container = renderMesh({ id: "my-mesh", polygons: [POLYGON] });
-    const el = container.querySelector("[data-glyphcss-mesh-id='my-mesh']");
+    const el = container.querySelector("[data-glyph-mesh-id='my-mesh']");
     expect(el).toBeTruthy();
   });
 
-  it("does not set data-glyphcss-mesh-id when id is omitted", () => {
+  it("does not set data-glyph-mesh-id when id is omitted", () => {
     const container = renderMesh({ polygons: [POLYGON] });
-    const el = container.querySelector("[data-glyphcss-mesh-id]");
+    const el = container.querySelector("[data-glyph-mesh-id]");
     // attribute may be present but value should be empty/undefined
     if (el) {
-      expect(el.getAttribute("data-glyphcss-mesh-id")).toBeFalsy();
+      expect(el.getAttribute("data-glyph-mesh-id")).toBeFalsy();
     }
   });
 });
 
-describe("GlyphcssMesh (React) — event props accepted", () => {
+describe("GlyphMesh (React) — event props accepted", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
diff --git a/packages/react/src/glyphcss/scene/GlyphcssMesh.tsx b/packages/react/src/glyphcss/scene/GlyphMesh.tsx
similarity index 52%
rename from packages/react/src/glyphcss/scene/GlyphcssMesh.tsx
rename to packages/react/src/glyphcss/scene/GlyphMesh.tsx
index 273fea6a..b1f87aaf 100644
--- a/packages/react/src/glyphcss/scene/GlyphcssMesh.tsx
+++ b/packages/react/src/glyphcss/scene/GlyphMesh.tsx
@@ -1,5 +1,5 @@
 /**
- * GlyphcssMesh — register a polygon list with the parent GlyphcssScene.
+ * GlyphMesh — register a polygon list with the parent GlyphScene.
  *
  * Mirrors PolyMesh's prop surface (id, position/scale/rotation transform,
  * children) but for the ASCII paint backend — no atlas, no polygon leaves.
@@ -8,15 +8,27 @@
  */
 import { memo, useEffect, useMemo, useRef } from "react";
 import type { CSSProperties, ReactNode } from "react";
-import type { Vec3, Polygon } from "@glyphcss/core";
-import type { GlyphcssMeshTransform, GlyphcssPointerEvent, GlyphcssMouseEvent, GlyphcssWheelEvent } from "glyphcss";
-import { useGlyphcssSceneContext } from "./context";
+import { resolveGeometry } from "@glyphcss/core";
+import type { Vec3, Polygon, GlyphGeometryName } from "@glyphcss/core";
+import type { GlyphMeshTransform, GlyphPointerEvent, GlyphMouseEvent, GlyphWheelEvent } from "glyphcss";
+import { useGlyphSceneContext } from "./context";
 import { registerMeshElement, unregisterMeshElement } from "./events";
-import type { GlyphcssMeshHandle } from "./context";
+import type { GlyphMeshHandle } from "./context";
 
-export interface GlyphcssMeshProps {
+export interface GlyphMeshProps {
   id?: string;
   polygons?: Polygon[];
+  /**
+   * Built-in geometry name. Resolved via `resolveGeometry` when neither
+   * `polygons` nor `src` is provided.
+   *
+   * Precedence: explicit `polygons` > `src` > `geometry`.
+   */
+  geometry?: GlyphGeometryName;
+  /** Uniform size passed to `resolveGeometry` when `geometry` is set. Defaults to 1. */
+  size?: number;
+  /** Fill color passed to `resolveGeometry` when `geometry` is set. */
+  color?: string;
   position?: Vec3;
   scale?: number | Vec3;
   rotation?: Vec3;
@@ -26,32 +38,40 @@ export interface GlyphcssMeshProps {
   // Pointer/mouse interaction — type surface matches voxcss PolyMesh.
   // TODO(hit-layer): wire these to the hit layer raycasting once the
   // rasterizer hit-map is wired to the hit-layer dispatch.
-  onPointerDown?: (event: GlyphcssPointerEvent) => void;
-  onPointerUp?: (event: GlyphcssPointerEvent) => void;
-  onPointerMove?: (event: GlyphcssPointerEvent) => void;
-  onPointerEnter?: (event: GlyphcssPointerEvent) => void;
-  onPointerLeave?: (event: GlyphcssPointerEvent) => void;
-  onClick?: (event: GlyphcssMouseEvent) => void;
-  onWheel?: (event: GlyphcssWheelEvent) => void;
+  onPointerDown?: (event: GlyphPointerEvent) => void;
+  onPointerUp?: (event: GlyphPointerEvent) => void;
+  onPointerMove?: (event: GlyphPointerEvent) => void;
+  onPointerEnter?: (event: GlyphPointerEvent) => void;
+  onPointerLeave?: (event: GlyphPointerEvent) => void;
+  onClick?: (event: GlyphMouseEvent) => void;
+  onWheel?: (event: GlyphWheelEvent) => void;
 }
 
-function GlyphcssMeshInner({
+function GlyphMeshInner({
   id,
   polygons: polygonsProp,
+  geometry,
+  size = 1,
+  color,
   position,
   scale,
   rotation,
   className,
   style,
   children,
-}: GlyphcssMeshProps) {
-  const { sceneRef } = useGlyphcssSceneContext();
-  const meshRef = useRef(null);
+}: GlyphMeshProps) {
+  const { sceneRef } = useGlyphSceneContext();
+  const meshRef = useRef(null);
   const wrapperRef = useRef(null);
 
-  const polygons = useMemo(() => polygonsProp ?? [], [polygonsProp]);
+  // Precedence: explicit polygons > geometry shortcut
+  const polygons = useMemo(() => {
+    if (polygonsProp !== undefined) return polygonsProp;
+    if (geometry !== undefined) return resolveGeometry(geometry, { size, color });
+    return [];
+  }, [polygonsProp, geometry, size, color]);
 
-  const transform = useMemo(() => ({
+  const transform = useMemo(() => ({
     id,
     position,
     scale,
@@ -88,12 +108,12 @@ function GlyphcssMeshInner({
     return () => unregisterMeshElement(el);
   });
 
-  const computedClassName = `glyphcss-mesh${className ? ` ${className}` : ""}`;
+  const computedClassName = `glyph-mesh${className ? ` ${className}` : ""}`;
 
   return (
     
@@ -102,4 +122,4 @@ function GlyphcssMeshInner({ ); } -export const GlyphcssMesh = memo(GlyphcssMeshInner); +export const GlyphMesh = memo(GlyphMeshInner); diff --git a/packages/react/src/glyphcss/scene/GlyphcssScene.test.tsx b/packages/react/src/glyphcss/scene/GlyphScene.test.tsx similarity index 56% rename from packages/react/src/glyphcss/scene/GlyphcssScene.test.tsx rename to packages/react/src/glyphcss/scene/GlyphScene.test.tsx index ae2868a6..465ab722 100644 --- a/packages/react/src/glyphcss/scene/GlyphcssScene.test.tsx +++ b/packages/react/src/glyphcss/scene/GlyphScene.test.tsx @@ -1,9 +1,10 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import React, { act } from "react"; import { createRoot } from "react-dom/client"; -import { GlyphcssScene } from "./GlyphcssScene"; -import { GlyphcssMesh } from "./GlyphcssMesh"; -import { GlyphcssOrbitControls } from "../controls/GlyphcssOrbitControls"; +import { GlyphScene } from "./GlyphScene"; +import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera"; +import { GlyphMesh } from "./GlyphMesh"; +import { GlyphOrbitControls } from "../controls/GlyphOrbitControls"; import type { Polygon } from "@glyphcss/core"; const POLYGON: Polygon = { @@ -16,7 +17,7 @@ const POLYGON: Polygon = { }; function renderScene( - sceneProps: React.ComponentProps, + sceneProps: React.ComponentProps, children?: React.ReactNode, ): HTMLElement { const container = document.createElement("div"); @@ -24,40 +25,44 @@ function renderScene( const root = createRoot(container); act(() => root.render( - React.createElement(GlyphcssScene, sceneProps, children), + React.createElement( + GlyphPerspectiveCamera, + {}, + React.createElement(GlyphScene, sceneProps, children), + ), ), ); return container; } -describe("GlyphcssScene — basic rendering", () => { +describe("GlyphScene — basic rendering", () => { afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = ""; }); - it("renders a .glyphcss-host element", () => { + it("renders a .glyph-host element", () => { const container = renderScene({}); - const host = container.querySelector(".glyphcss-host"); + const host = container.querySelector(".glyph-host"); expect(host).toBeTruthy(); }); - it("renders a .glyphcss-scene element inside the host", () => { + it("renders a .glyph-scene element inside the host", () => { const container = renderScene({}); - const scene = container.querySelector(".glyphcss-scene"); + const scene = container.querySelector(".glyph-scene"); expect(scene).toBeTruthy(); }); - it("renders a .glyphcss-output
 inside the scene", () => {
+  it("renders a .glyph-output 
 inside the scene", () => {
     const container = renderScene({});
-    const pre = container.querySelector(".glyphcss-output");
+    const pre = container.querySelector(".glyph-output");
     expect(pre).toBeTruthy();
     expect(pre?.tagName.toLowerCase()).toBe("pre");
   });
 
   it("applies custom className to the host element", () => {
     const container = renderScene({ className: "my-scene" });
-    const host = container.querySelector(".glyphcss-host");
+    const host = container.querySelector(".glyph-host");
     expect(host?.classList.contains("my-scene")).toBe(true);
   });
 
@@ -72,7 +77,7 @@ describe("GlyphcssScene — basic rendering", () => {
   });
 });
 
-describe("GlyphcssScene — options forwarding", () => {
+describe("GlyphScene — options forwarding", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -80,7 +85,7 @@ describe("GlyphcssScene — options forwarding", () => {
 
   it("renders with custom cols/rows", () => {
     const container = renderScene({ cols: 40, rows: 12 });
-    const scene = container.querySelector(".glyphcss-scene");
+    const scene = container.querySelector(".glyph-scene");
     expect(scene).toBeTruthy();
   });
 
@@ -93,59 +98,69 @@ describe("GlyphcssScene — options forwarding", () => {
   });
 });
 
-describe("GlyphcssScene — GlyphcssMesh child", () => {
+describe("GlyphScene — GlyphMesh child", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("mounts a GlyphcssMesh without throwing", () => {
+  it("mounts a GlyphMesh without throwing", () => {
     expect(() =>
       renderScene(
         {},
-        React.createElement(GlyphcssMesh, { polygons: [POLYGON] }),
+        React.createElement(GlyphMesh, { polygons: [POLYGON] }),
       ),
     ).not.toThrow();
   });
 
-  it("GlyphcssMesh renders a wrapper div", () => {
+  it("GlyphMesh renders a wrapper div", () => {
     const container = renderScene(
       {},
-      React.createElement(GlyphcssMesh, { id: "test-mesh", polygons: [POLYGON] }),
+      React.createElement(GlyphMesh, { id: "test-mesh", polygons: [POLYGON] }),
     );
-    const mesh = container.querySelector(".glyphcss-mesh");
+    const mesh = container.querySelector(".glyph-mesh");
     expect(mesh).toBeTruthy();
   });
 });
 
-describe("GlyphcssScene — controls", () => {
+describe("GlyphScene — controls", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("GlyphcssOrbitControls mounts without throwing", () => {
+  it("GlyphOrbitControls mounts without throwing", () => {
     expect(() =>
       renderScene(
         {},
-        React.createElement(GlyphcssOrbitControls, { drag: false, wheel: false }),
+        React.createElement(GlyphOrbitControls, { drag: false, wheel: false }),
       ),
     ).not.toThrow();
   });
 });
 
-describe("GlyphcssScene — error (no context)", () => {
+describe("GlyphScene — error (no context)", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("GlyphcssMesh throws when used outside GlyphcssScene", () => {
+  it("GlyphMesh throws when used outside GlyphScene", () => {
+    const container = document.createElement("div");
+    const root = createRoot(container);
+    expect(() => {
+      act(() =>
+        root.render(React.createElement(GlyphMesh, { polygons: [] })),
+      );
+    }).toThrow();
+  });
+
+  it("GlyphScene throws when used without a camera ancestor", () => {
     const container = document.createElement("div");
     const root = createRoot(container);
     expect(() => {
       act(() =>
-        root.render(React.createElement(GlyphcssMesh, { polygons: [] })),
+        root.render(React.createElement(GlyphScene, {})),
       );
     }).toThrow();
   });
diff --git a/packages/react/src/glyphcss/scene/GlyphcssScene.tsx b/packages/react/src/glyphcss/scene/GlyphScene.tsx
similarity index 50%
rename from packages/react/src/glyphcss/scene/GlyphcssScene.tsx
rename to packages/react/src/glyphcss/scene/GlyphScene.tsx
index 23936862..eba810cf 100644
--- a/packages/react/src/glyphcss/scene/GlyphcssScene.tsx
+++ b/packages/react/src/glyphcss/scene/GlyphScene.tsx
@@ -1,9 +1,10 @@
 /**
- * GlyphcssScene — React wrapper for the ASCII paint backend.
+ * GlyphScene — React wrapper for the ASCII paint backend.
  *
- * Mounts a `createGlyphcssScene` handle in a host div, injects base styles,
- * and provides the scene handle via GlyphcssSceneContext so child components
- * (GlyphcssMesh, GlyphcssHotspot, controls) can register with it.
+ * Must be placed inside a  or .
+ * Reads the camera handle from GlyphCameraContext, mounts a `createGlyphScene`
+ * handle in a host div, and provides the scene handle via GlyphSceneContext so
+ * child components (GlyphMesh, GlyphHotspot, controls) can register with it.
  *
  * No atlas, no matrix3d, no CSS polygon leaves — the ASCII rasterizer
  * writes into a single 
 element per render.
@@ -12,14 +13,15 @@ import { memo, useCallback, useEffect, useMemo, useRef } from "react";
 import type { CSSProperties, ReactNode } from "react";
 import type { RenderMode } from "@glyphcss/core";
 import type {
-  GlyphcssSceneOptions,
-  GlyphcssDirectionalLight,
-  GlyphcssAmbientLight,
+  GlyphSceneOptions,
+  GlyphDirectionalLight,
+  GlyphAmbientLight,
 } from "glyphcss";
-import { createGlyphcssScene, injectGlyphcssBaseStyles } from "glyphcss";
-import { GlyphcssSceneContext } from "./context";
+import { createGlyphScene, injectGlyphBaseStyles } from "glyphcss";
+import { useGlyphCameraContext } from "../camera/context";
+import { GlyphSceneContext } from "./context";
 
-export interface GlyphcssSceneProps {
+export interface GlyphSceneProps {
   /** Render mode: "wireframe" | "solid". Default "solid". */
   mode?: RenderMode;
   /** Named glyph palette. Defaults to "default". */
@@ -32,14 +34,20 @@ export interface GlyphcssSceneProps {
   rows?: number;
   /** Character cell aspect ratio (height/width). Default 2.0. */
   cellAspect?: number;
-  directionalLight?: GlyphcssDirectionalLight;
-  ambientLight?: GlyphcssAmbientLight;
+  directionalLight?: GlyphDirectionalLight;
+  ambientLight?: GlyphAmbientLight;
+  /** Smooth (Gouraud) shading. Default false. */
+  smoothShading?: boolean;
+  /** Crease angle in degrees. Edges sharper than this stay flat-shaded. Default 60. */
+  creaseAngle?: number;
+  /** Observe host element and adapt cols/rows to fill it. Default false. */
+  autoSize?: boolean;
   className?: string;
   style?: CSSProperties;
   children?: ReactNode;
 }
 
-function GlyphcssSceneInner({
+function GlyphSceneInner({
   mode,
   glyphPalette,
   useColors,
@@ -48,15 +56,19 @@ function GlyphcssSceneInner({
   cellAspect,
   directionalLight,
   ambientLight,
+  smoothShading,
+  creaseAngle,
+  autoSize,
   className,
   style,
   children,
-}: GlyphcssSceneProps) {
+}: GlyphSceneProps) {
+  const { cameraRef, sceneRerenderRef } = useGlyphCameraContext();
   const hostRef = useRef(null);
-  const sceneRef = useRef | null>(null);
+  const sceneRef = useRef | null>(null);
 
-  // Build the initial scene options once
-  const initialOpts: GlyphcssSceneOptions = useMemo(() => ({
+  // Build the initial scene options once (camera must exist at this point)
+  const initialOpts: GlyphSceneOptions = useMemo(() => ({
     mode,
     glyphPalette,
     useColors,
@@ -65,26 +77,35 @@ function GlyphcssSceneInner({
     cellAspect,
     directionalLight,
     ambientLight,
+    smoothShading,
+    creaseAngle,
+    autoSize,
+    camera: cameraRef.current ?? undefined,
   }), []); // eslint-disable-line react-hooks/exhaustive-deps
 
   // Mount / destroy the scene when the host element mounts / unmounts
   const hostCallbackRef = useCallback((el: HTMLDivElement | null) => {
     if (el && !sceneRef.current) {
       hostRef.current = el;
-      injectGlyphcssBaseStyles(el.ownerDocument ?? undefined);
-      sceneRef.current = createGlyphcssScene(el, initialOpts);
+      injectGlyphBaseStyles(el.ownerDocument ?? undefined);
+      const camera = cameraRef.current ?? undefined;
+      sceneRef.current = createGlyphScene(el, { ...initialOpts, camera });
+      // Register the scene rerender function with the camera context so
+      // prop changes on the camera component trigger rerenders.
+      sceneRerenderRef.current = () => sceneRef.current?.rerender();
     } else if (!el && sceneRef.current) {
       sceneRef.current.destroy();
       sceneRef.current = null;
+      sceneRerenderRef.current = null;
       hostRef.current = null;
     }
-  }, [initialOpts]);
+  }, [initialOpts, cameraRef, sceneRerenderRef]);
 
   // Sync option props to the live scene handle
   useEffect(() => {
     const scene = sceneRef.current;
     if (!scene) return;
-    const partial: Partial = {};
+    const partial: Partial = {};
     if (mode !== undefined) partial.mode = mode;
     if (glyphPalette !== undefined) partial.glyphPalette = glyphPalette;
     if (useColors !== undefined) partial.useColors = useColors;
@@ -93,22 +114,25 @@ function GlyphcssSceneInner({
     if (cellAspect !== undefined) partial.cellAspect = cellAspect;
     if (directionalLight !== undefined) partial.directionalLight = directionalLight;
     if (ambientLight !== undefined) partial.ambientLight = ambientLight;
+    if (smoothShading !== undefined) partial.smoothShading = smoothShading;
+    if (creaseAngle !== undefined) partial.creaseAngle = creaseAngle;
+    if (autoSize !== undefined) partial.autoSize = autoSize;
     if (Object.keys(partial).length > 0) {
       scene.setOptions(partial);
     }
-  }, [mode, glyphPalette, useColors, cols, rows, cellAspect, directionalLight, ambientLight]);
+  }, [mode, glyphPalette, useColors, cols, rows, cellAspect, directionalLight, ambientLight, smoothShading, creaseAngle, autoSize]);
 
   const ctxValue = useMemo(() => ({ sceneRef }), [sceneRef]);
 
-  const computedClassName = `glyphcss-host${className ? ` ${className}` : ""}`;
+  const computedClassName = `glyph-host${className ? ` ${className}` : ""}`;
 
   return (
-    
+    
       
{children}
-
+ ); } -export const GlyphcssScene = memo(GlyphcssSceneInner); +export const GlyphScene = memo(GlyphSceneInner); diff --git a/packages/react/src/glyphcss/scene/GlyphcssHotspot.tsx b/packages/react/src/glyphcss/scene/GlyphcssHotspot.tsx deleted file mode 100644 index 54742589..00000000 --- a/packages/react/src/glyphcss/scene/GlyphcssHotspot.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/** - * GlyphcssHotspot — an interactive overlay element positioned at a 3D world point. - * - * Wraps `scene.addHotspot()`. The scene projects the `at` position into grid - * cell coordinates on each render, and the glyphcss backend positions the - * overlay div accordingly over the
 output.
- *
- * Children render inside the absolutely-positioned overlay div.
- */
-import { memo, useEffect, useMemo, useRef } from "react";
-import type { ReactNode, MouseEventHandler } from "react";
-import type { Vec3 } from "@glyphcss/core";
-import type { GlyphcssHotspotHandle } from "glyphcss";
-import { useGlyphcssSceneContext } from "./context";
-
-export interface GlyphcssHotspotProps {
-  /** Stable identifier for this hotspot. */
-  id: string;
-  /** 3D world-space anchor. */
-  at: Vec3;
-  /** Hitbox size in character cells `[cols, rows]`. Default `[1, 1]`. */
-  size?: [number, number];
-  onClick?: MouseEventHandler;
-  className?: string;
-  "aria-label"?: string;
-  children?: ReactNode;
-}
-
-function GlyphcssHotspotInner({
-  id,
-  at,
-  size,
-  onClick,
-  className,
-  children,
-}: GlyphcssHotspotProps) {
-  const { sceneRef } = useGlyphcssSceneContext();
-  const hotspotRef = useRef(null);
-  const onClickRef = useRef(onClick);
-  onClickRef.current = onClick;
-
-  // Register with the scene's hotspot system
-  const atKey = useMemo(() => at.join(","), [at]);
-  const sizeKey = size ? size.join(",") : "";
-
-  useEffect(() => {
-    const scene = sceneRef.current;
-    if (!scene) return;
-    const handle = scene.addHotspot(
-      { id, at, size },
-      () => {
-        // Dispatch a synthetic click — the hotspot overlay handles native clicks,
-        // but the onClick prop wires React event handlers.
-      },
-    );
-    hotspotRef.current = handle;
-    return () => {
-      handle.remove();
-      hotspotRef.current = null;
-    };
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [sceneRef, id, atKey, sizeKey]);
-
-  // The actual visible element is rendered by the scene's hotspot-layer,
-  // not by this React component. We render children as a portal-like slot
-  // only when there are custom children to show (tooltips etc).
-  // For the basic case (no children), this component is purely imperative.
-  if (!children && !onClick && !className) return null;
-
-  // When children are provided, render them alongside — the scene-injected
-  // hotspot div handles the positioning, and children can be placed in the
-  // glyphcss-hotspot div via the scene's DOM directly. Since the scene
-  // inserts the div into hotspot-layer (not into this React tree), we
-  // expose a data-hotspot-id attribute on a zero-size sentinel so callers
-  // can query and inject via refs if needed.
-  return (
-    
- {children} -
- ); -} - -export const GlyphcssHotspot = memo(GlyphcssHotspotInner); diff --git a/packages/react/src/glyphcss/scene/context.ts b/packages/react/src/glyphcss/scene/context.ts index 5a80a9a1..00adbe28 100644 --- a/packages/react/src/glyphcss/scene/context.ts +++ b/packages/react/src/glyphcss/scene/context.ts @@ -1,24 +1,24 @@ import { createContext, useContext } from "react"; -import type { GlyphcssSceneHandle, GlyphcssHotspotHandle, GlyphcssHotspotOptions, GlyphcssMeshHandle, GlyphcssMeshTransform } from "glyphcss"; +import type { GlyphSceneHandle, GlyphHotspotHandle, GlyphHotspotOptions, GlyphMeshHandle, GlyphMeshTransform } from "glyphcss"; -export interface GlyphcssSceneContextValue { - sceneRef: React.MutableRefObject; +export interface GlyphSceneContextValue { + sceneRef: React.MutableRefObject; } -export const GlyphcssSceneContext = createContext(null); +export const GlyphSceneContext = createContext(null); -export function useGlyphcssSceneContext(): GlyphcssSceneContextValue { - const ctx = useContext(GlyphcssSceneContext); +export function useGlyphSceneContext(): GlyphSceneContextValue { + const ctx = useContext(GlyphSceneContext); if (!ctx) { - throw new Error("glyphcss: GlyphcssMesh must be used inside a GlyphcssScene."); + throw new Error("glyphcss: component must be used inside a GlyphScene."); } return ctx; } -export interface GlyphcssMeshContextValue { - meshRef: React.MutableRefObject; +export interface GlyphMeshContextValue { + meshRef: React.MutableRefObject; } -export const GlyphcssMeshContext = createContext(null); +export const GlyphMeshContext = createContext(null); -export type { GlyphcssSceneHandle, GlyphcssHotspotHandle, GlyphcssHotspotOptions, GlyphcssMeshHandle, GlyphcssMeshTransform }; +export type { GlyphSceneHandle, GlyphHotspotHandle, GlyphHotspotOptions, GlyphMeshHandle, GlyphMeshTransform }; diff --git a/packages/react/src/glyphcss/scene/events.ts b/packages/react/src/glyphcss/scene/events.ts index 0e7c55a6..9aac6319 100644 --- a/packages/react/src/glyphcss/scene/events.ts +++ b/packages/react/src/glyphcss/scene/events.ts @@ -1,8 +1,8 @@ -import type { GlyphcssMeshHandle } from "./context"; +import type { GlyphMeshHandle } from "./context"; -const MESH_REGISTRY = new WeakMap(); +const MESH_REGISTRY = new WeakMap(); -export function registerMeshElement(el: HTMLElement, handle: GlyphcssMeshHandle): void { +export function registerMeshElement(el: HTMLElement, handle: GlyphMeshHandle): void { MESH_REGISTRY.set(el, handle); } @@ -10,7 +10,7 @@ export function unregisterMeshElement(el: HTMLElement): void { MESH_REGISTRY.delete(el); } -export function findGlyphcssMeshHandle(el: Element | null): GlyphcssMeshHandle | null { +export function findGlyphMeshHandle(el: Element | null): GlyphMeshHandle | null { let cur: Element | null = el; while (cur) { if (cur instanceof HTMLElement) { @@ -36,14 +36,14 @@ export function findMeshUnderPoint( clientX: number, clientY: number, filter?: (meshEl: HTMLElement) => boolean, -): GlyphcssMeshHandle | null { +): GlyphMeshHandle | null { if (typeof document === "undefined") return null; const meshEls = Array.from( - document.querySelectorAll(".glyphcss-mesh"), + document.querySelectorAll(".glyph-mesh"), ) as HTMLElement[]; for (const meshEl of meshEls) { if (filter && !filter(meshEl)) continue; - const handle = findGlyphcssMeshHandle(meshEl); + const handle = findGlyphMeshHandle(meshEl); if (!handle) continue; if (pointInMeshElement(meshEl, clientX, clientY)) return handle; } diff --git a/packages/react/src/glyphcss/scene/index.ts b/packages/react/src/glyphcss/scene/index.ts index b0bf4af9..dd067526 100644 --- a/packages/react/src/glyphcss/scene/index.ts +++ b/packages/react/src/glyphcss/scene/index.ts @@ -1,14 +1,14 @@ -export { GlyphcssScene } from "./GlyphcssScene"; -export type { GlyphcssSceneProps } from "./GlyphcssScene"; -export { GlyphcssMesh } from "./GlyphcssMesh"; -export type { GlyphcssMeshProps } from "./GlyphcssMesh"; -export { GlyphcssGround } from "./GlyphcssGround"; -export type { GlyphcssGroundProps } from "./GlyphcssGround"; -export { GlyphcssHotspot } from "./GlyphcssHotspot"; -export type { GlyphcssHotspotProps } from "./GlyphcssHotspot"; -export { GlyphcssSceneContext } from "./context"; -export type { GlyphcssSceneContextValue } from "./context"; -export { useGlyphcssSceneContext } from "./useGlyphcssSceneContext"; -export { useGlyphcssMesh } from "./useGlyphcssMesh"; -export type { UseGlyphcssMeshResult, UseGlyphcssMeshOptions } from "./useGlyphcssMesh"; -export { findGlyphcssMeshHandle, pointInMeshElement, findMeshUnderPoint } from "./events"; +export { GlyphScene } from "./GlyphScene"; +export type { GlyphSceneProps } from "./GlyphScene"; +export { GlyphMesh } from "./GlyphMesh"; +export type { GlyphMeshProps } from "./GlyphMesh"; +export { GlyphGround } from "./GlyphGround"; +export type { GlyphGroundProps } from "./GlyphGround"; +export { GlyphHotspot } from "./GlyphHotspot"; +export type { GlyphHotspotProps } from "./GlyphHotspot"; +export { GlyphSceneContext } from "./context"; +export type { GlyphSceneContextValue } from "./context"; +export { useGlyphSceneContext } from "./useGlyphSceneContext"; +export { useGlyphMesh } from "./useGlyphMesh"; +export type { UseGlyphMeshResult, UseGlyphMeshOptions } from "./useGlyphMesh"; +export { findGlyphMeshHandle, pointInMeshElement, findMeshUnderPoint } from "./events"; diff --git a/packages/react/src/glyphcss/scene/useGlyphcssMesh.ts b/packages/react/src/glyphcss/scene/useGlyphMesh.ts similarity index 59% rename from packages/react/src/glyphcss/scene/useGlyphcssMesh.ts rename to packages/react/src/glyphcss/scene/useGlyphMesh.ts index 320205d4..dce9e6f0 100644 --- a/packages/react/src/glyphcss/scene/useGlyphcssMesh.ts +++ b/packages/react/src/glyphcss/scene/useGlyphMesh.ts @@ -1,27 +1,27 @@ import { useEffect, useRef, useState } from "react"; import type { Polygon } from "@glyphcss/core"; -import type { GlyphcssMeshHandle, GlyphcssMeshTransform } from "glyphcss"; -import { useGlyphcssSceneContext } from "./context"; +import type { GlyphMeshHandle, GlyphMeshTransform } from "glyphcss"; +import { useGlyphSceneContext } from "./context"; -export interface UseGlyphcssMeshOptions { - transform?: GlyphcssMeshTransform; +export interface UseGlyphMeshOptions { + transform?: GlyphMeshTransform; } -export interface UseGlyphcssMeshResult { - meshRef: React.MutableRefObject; +export interface UseGlyphMeshResult { + meshRef: React.MutableRefObject; loading: boolean; } /** - * useGlyphcssMesh — register a polygon list with the parent GlyphcssScene. + * useGlyphMesh — register a polygon list with the parent GlyphScene. * Mirrors usePolyMesh but for the ASCII paint backend. */ -export function useGlyphcssMesh( +export function useGlyphMesh( polygons: Polygon[], - options?: UseGlyphcssMeshOptions, -): UseGlyphcssMeshResult { - const { sceneRef } = useGlyphcssSceneContext(); - const meshRef = useRef(null); + options?: UseGlyphMeshOptions, +): UseGlyphMeshResult { + const { sceneRef } = useGlyphSceneContext(); + const meshRef = useRef(null); const [loading] = useState(false); useEffect(() => { diff --git a/packages/react/src/glyphcss/scene/useGlyphSceneContext.ts b/packages/react/src/glyphcss/scene/useGlyphSceneContext.ts new file mode 100644 index 00000000..4221ae7c --- /dev/null +++ b/packages/react/src/glyphcss/scene/useGlyphSceneContext.ts @@ -0,0 +1,4 @@ +import { useGlyphSceneContext } from "./context"; + +export { useGlyphSceneContext }; +export type { GlyphSceneContextValue } from "./context"; diff --git a/packages/react/src/glyphcss/scene/useGlyphcssSceneContext.ts b/packages/react/src/glyphcss/scene/useGlyphcssSceneContext.ts deleted file mode 100644 index a74cde1d..00000000 --- a/packages/react/src/glyphcss/scene/useGlyphcssSceneContext.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { useGlyphcssSceneContext } from "./context"; - -export { useGlyphcssSceneContext }; -export type { GlyphcssSceneContextValue } from "./context"; diff --git a/packages/react/src/glyphcss/styles/index.ts b/packages/react/src/glyphcss/styles/index.ts index 0ecc82c9..295d5909 100644 --- a/packages/react/src/glyphcss/styles/index.ts +++ b/packages/react/src/glyphcss/styles/index.ts @@ -1 +1 @@ -export { injectGlyphcssBaseStyles } from "glyphcss"; +export { injectGlyphBaseStyles } from "glyphcss"; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 98bc3118..f5dec9ca 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -8,15 +8,15 @@ export type { Vec2, Vec3, Polygon, - GlyphcssDirectionalLight, - GlyphcssAmbientLight, + GlyphDirectionalLight, + GlyphAmbientLight, ParseAnimationClip, ParseAnimationController, ParseResult, - GlyphcssAnimationClip, - GlyphcssAnimationAction, - GlyphcssAnimationMixer, - GlyphcssAnimationTarget, + GlyphAnimationClip, + GlyphAnimationAction, + GlyphAnimationMixer, + GlyphAnimationTarget, LoopMode, ObjParseOptions, GltfParseOptions, @@ -47,6 +47,8 @@ export type { CameraCullRotation, ApproximateMergeOptions, OptimizeMeshPolygonsOptions, + GlyphGeometryName, + GlyphGeometryOptions, } from "@glyphcss/core"; export { CAMERA_BACKFACE_CULL_EPS, @@ -86,17 +88,63 @@ export { shadeColor, rotateVec3, inverseRotateVec3, - axesHelperPolygons, - arrowPolygons, - ringPolygons, + resolveGeometry, + tetrahedronPolygons, + cubePolygons, octahedronPolygons, + dodecahedronPolygons, + icosahedronPolygons, + smallStellatedDodecahedronPolygons, + greatDodecahedronPolygons, + greatStellatedDodecahedronPolygons, + greatIcosahedronPolygons, + cuboctahedronPolygons, + icosidodecahedronPolygons, + truncatedTetrahedronPolygons, + truncatedCubePolygons, + truncatedOctahedronPolygons, + truncatedDodecahedronPolygons, + truncatedIcosahedronPolygons, + truncatedCuboctahedronPolygons, + truncatedIcosidodecahedronPolygons, + rhombicuboctahedronPolygons, + rhombicosidodecahedronPolygons, + snubCubePolygons, + snubDodecahedronPolygons, + rhombicDodecahedronPolygons, + rhombicTriacontahedronPolygons, + triakisTetrahedronPolygons, + triakisOctahedronPolygons, + triakisIcosahedronPolygons, + tetrakisHexahedronPolygons, + pentakisDodecahedronPolygons, + disdyakisDodecahedronPolygons, + disdyakisTriacontahedronPolygons, + deltoidalIcositetrahedronPolygons, + deltoidalHexecontahedronPolygons, + pentagonalIcositetrahedronPolygons, + pentagonalHexecontahedronPolygons, + prismPolygons, + antiprismPolygons, + bipyramidPolygons, + trapezohedronPolygons, + spherePolygons, + cylinderPolygons, + conePolygons, + torusPolygons, + pyramidPolygons, + planePolygons, + ringPolygons, + ringQuadPolygons, + arrowPolygons, + axesHelperPolygons, buildSceneContext, computeSceneBbox, BASE_TILE, DEFAULT_CAMERA_STATE, DEFAULT_PROJECTION, normalizeInvertMultiplier, - createGlyphcssAnimationMixer, + createGlyphAnimationMixer, LoopOnce, LoopRepeat, LoopPingPong, @@ -104,56 +152,56 @@ export { // ── Glyphcss (ASCII paint backend) bindings ───────────────────────────────── export { - GlyphcssScene, - GlyphcssMesh, - GlyphcssGround, - GlyphcssHotspot, - GlyphcssSceneContext, - useGlyphcssSceneContext, - useGlyphcssMesh, - findGlyphcssMeshHandle, + GlyphScene, + GlyphMesh, + GlyphGround, + GlyphHotspot, + GlyphSceneContext, + useGlyphSceneContext, + useGlyphMesh, + findGlyphMeshHandle, pointInMeshElement, findMeshUnderPoint, - GlyphcssCamera, - GlyphcssPerspectiveCamera, - GlyphcssOrthographicCamera, - GlyphcssCameraContext, - useGlyphcssCamera, - GlyphcssOrbitControls, - GlyphcssMapControls, - GlyphcssFirstPersonControls, - GlyphcssAxesHelper, - GlyphcssDirectionalLightHelper, - injectGlyphcssBaseStyles, - useGlyphcssAnimation, + GlyphCamera, + GlyphPerspectiveCamera, + GlyphOrthographicCamera, + GlyphCameraContext, + useGlyphCamera, + GlyphOrbitControls, + GlyphMapControls, + GlyphFirstPersonControls, + GlyphAxesHelper, + GlyphDirectionalLightHelper, + injectGlyphBaseStyles, + useGlyphAnimation, } from "./glyphcss"; export type { - GlyphcssSceneProps, - GlyphcssMeshProps, - GlyphcssGroundProps, - GlyphcssHotspotProps, - GlyphcssSceneContextValue, - UseGlyphcssMeshResult, - UseGlyphcssMeshOptions, - GlyphcssCameraProps, - GlyphcssPerspectiveCameraProps, - GlyphcssOrthographicCameraProps, - GlyphcssCameraContextValue, - GlyphcssOrbitControlsProps, - GlyphcssMapControlsProps, - GlyphcssFirstPersonControlsProps, - GlyphcssAxesHelperProps, - GlyphcssDirectionalLightHelperProps, - UseGlyphcssAnimationResult, + GlyphSceneProps, + GlyphMeshProps, + GlyphGroundProps, + GlyphHotspotProps, + GlyphSceneContextValue, + UseGlyphMeshResult, + UseGlyphMeshOptions, + GlyphCameraProps, + GlyphPerspectiveCameraProps, + GlyphOrthographicCameraProps, + GlyphCameraContextValue, + GlyphOrbitControlsProps, + GlyphMapControlsProps, + GlyphFirstPersonControlsProps, + GlyphAxesHelperProps, + GlyphDirectionalLightHelperProps, + UseGlyphAnimationResult, } from "./glyphcss"; // ── Mesh handle type ────────────────────────────────────────────────────────── -export type { GlyphcssMeshHandle } from "glyphcss"; +export type { GlyphMeshHandle } from "glyphcss"; // ── Event types ─────────────────────────────────────────────────────────────── export type { - GlyphcssPointerEvent, - GlyphcssMouseEvent, - GlyphcssWheelEvent, - GlyphcssEventHandler, + GlyphPointerEvent, + GlyphMouseEvent, + GlyphWheelEvent, + GlyphEventHandler, } from "glyphcss"; diff --git a/packages/vue/README.md b/packages/vue/README.md index 49b19a85..bbb762b4 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -16,48 +16,59 @@ Requires Vue 3 as a peer dependency. ```vue ``` ## Component reference -### `` +### `` / `` -Root of every Vue glyphcss render tree. Owns the `
` output element and rasterizes all meshes on camera or state change.
+Orthographic camera. `GlyphCamera` is the ergonomic default alias. Wraps
+`` — the camera is always the outermost element.
 
 | Prop | Type | Default | Description |
 |---|---|---|---|
-| `cols` | `number` | `80` | Grid width in character cells |
-| `rows` | `number` | `40` | Grid height in character cells |
-| `mode` | `"wireframe" \| "solid" \| "voxel"` | `"solid"` | Render mode |
+| `rot-x` | `number` | `0` | Tilt in radians |
+| `rot-y` | `number` | `0` | Azimuth in radians |
+| `zoom` | `number` | `0.4` | Mesh fraction of min(cols, rows) |
 
-### `` / ``
+### ``
 
-Perspective camera. `GlyphcssCamera` is the ergonomic alias.
+Perspective (foreshortened) camera. Required for ``.
 
 | Prop | Type | Default | Description |
 |---|---|---|---|
-| `fov` | `number` | `60` | Vertical field of view in degrees |
-| `rot-x` | `number` | `35` | Tilt in degrees |
-| `rot-y` | `number` | `45` | Azimuth in degrees |
-| `zoom` | `number` | `1` | Zoom multiplier |
+| `rot-x` | `number` | `0` | Tilt in radians |
+| `rot-y` | `number` | `0` | Azimuth in radians |
+| `distance` | `number` | `3` | Perspective distance in world units |
+| `zoom` | `number` | `0.4` | Mesh fraction of min(cols, rows) |
+
+### ``
+
+Root of every Vue glyphcss render tree. Owns the `
` output element and rasterizes all meshes on camera or state change. Must be a child of a camera component.
+
+| Prop | Type | Default | Description |
+|---|---|---|---|
+| `cols` | `number` | `80` | Grid width in character cells |
+| `rows` | `number` | `40` | Grid height in character cells |
+| `mode` | `"wireframe" \| "solid" \| "voxel"` | `"solid"` | Render mode |
 
-### ``
+### ``
 
 Loads and displays a 3D mesh. Supports `.obj`, `.glb`, `.gltf`, `.vox`.
 
@@ -66,15 +77,15 @@ Loads and displays a 3D mesh. Supports `.obj`, `.glb`, `.gltf`, `.vox`.
 | `src` | `string` | URL of the mesh file |
 | `color` | `string` | Override mesh color |
 
-### `` / ``
+### `` / ``
 
 Mouse/touch/keyboard camera controls.
 
 ### Composables
 
-- `useGlyphcssCamera()` — access the camera context
-- `useGlyphcssSceneContext()` — access scene state
-- `useGlyphcssAnimation(clips, controller)` — three.js-style animation mixer
+- `useGlyphCamera()` — access the camera context
+- `useGlyphSceneContext()` — access scene state
+- `useGlyphAnimation(clips, controller)` — three.js-style animation mixer
 
 ## License
 
diff --git a/packages/vue/src/animation/useGlyphcssAnimation.test.ts b/packages/vue/src/animation/useGlyphAnimation.test.ts
similarity index 88%
rename from packages/vue/src/animation/useGlyphcssAnimation.test.ts
rename to packages/vue/src/animation/useGlyphAnimation.test.ts
index d1a062b0..5d053c49 100644
--- a/packages/vue/src/animation/useGlyphcssAnimation.test.ts
+++ b/packages/vue/src/animation/useGlyphAnimation.test.ts
@@ -1,26 +1,26 @@
 import { describe, it, expect, vi, afterEach } from "vitest";
 import { ref, computed, nextTick } from "vue";
 import { createApp, h } from "vue";
-import { useGlyphcssAnimation } from "./useGlyphcssAnimation";
-import type { UseGlyphcssAnimationResultVue } from "./useGlyphcssAnimation";
+import { useGlyphAnimation } from "./useGlyphAnimation";
+import type { UseGlyphAnimationResultVue } from "./useGlyphAnimation";
 import type {
-  GlyphcssAnimationTarget,
-  GlyphcssAnimationClip,
+  GlyphAnimationTarget,
+  GlyphAnimationClip,
   ParseAnimationController,
   Polygon,
 } from "@glyphcss/core";
 
 const TRI: Polygon = { vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], color: "#f00" };
 
-function makeClip(index: number, name: string, duration = 1): GlyphcssAnimationClip {
+function makeClip(index: number, name: string, duration = 1): GlyphAnimationClip {
   return { index, name, duration, channelCount: 1 };
 }
 
-function makeController(clips: GlyphcssAnimationClip[]): ParseAnimationController {
+function makeController(clips: GlyphAnimationClip[]): ParseAnimationController {
   return { clips, sample: () => [TRI] };
 }
 
-function makeTarget(): GlyphcssAnimationTarget & { calls: Polygon[][] } {
+function makeTarget(): GlyphAnimationTarget & { calls: Polygon[][] } {
   const calls: Polygon[][] = [];
   return { calls, setPolygons(polys) { calls.push(polys); } };
 }
@@ -28,20 +28,20 @@ function makeTarget(): GlyphcssAnimationTarget & { calls: Polygon[][] } {
 // ── Harness ──────────────────────────────────────────────────────────────────
 
 interface CaptureResult {
-  result: UseGlyphcssAnimationResultVue;
+  result: UseGlyphAnimationResultVue;
   app: ReturnType;
 }
 
 function mountComposable(
-  clips?: GlyphcssAnimationClip[],
+  clips?: GlyphAnimationClip[],
   controller?: ParseAnimationController,
-  root?: GlyphcssAnimationTarget | null,
+  root?: GlyphAnimationTarget | null,
 ): CaptureResult {
-  let captured!: UseGlyphcssAnimationResultVue;
+  let captured!: UseGlyphAnimationResultVue;
   const container = document.createElement("div");
   const app = createApp({
     setup() {
-      captured = useGlyphcssAnimation(
+      captured = useGlyphAnimation(
         ref(clips),
         ref(controller),
         root != null ? ref(root) : undefined,
@@ -60,7 +60,7 @@ afterEach(() => {
 
 // ── No-input state ────────────────────────────────────────────────────────────
 
-describe("useGlyphcssAnimation — no inputs", () => {
+describe("useGlyphAnimation — no inputs", () => {
   it("mixer.value is null when no clips are passed", () => {
     const { result } = mountComposable();
     expect(result.mixer.value).toBeNull();
@@ -98,7 +98,7 @@ describe("useGlyphcssAnimation — no inputs", () => {
 
 // ── With clips + controller + root ────────────────────────────────────────────
 
-describe("useGlyphcssAnimation — with inputs", () => {
+describe("useGlyphAnimation — with inputs", () => {
   it("clips.value matches provided clips", () => {
     const clips = [makeClip(0, "walk"), makeClip(1, "run")];
     const ctrl = makeController(clips);
@@ -136,7 +136,7 @@ describe("useGlyphcssAnimation — with inputs", () => {
 
 // ── Actions proxy ─────────────────────────────────────────────────────────────
 
-describe("useGlyphcssAnimation — actions proxy", () => {
+describe("useGlyphAnimation — actions proxy", () => {
   it("actions.value has enumerable keys matching clip names", () => {
     const clips = [makeClip(0, "walk"), makeClip(1, "run")];
     const ctrl = makeController(clips);
@@ -166,7 +166,7 @@ describe("useGlyphcssAnimation — actions proxy", () => {
 
 // ── RAF loop ─────────────────────────────────────────────────────────────────
 
-describe("useGlyphcssAnimation — RAF loop", () => {
+describe("useGlyphAnimation — RAF loop", () => {
   it("calls requestAnimationFrame when clips, controller, and root are provided", () => {
     const rafSpy = vi.fn((_cb: FrameRequestCallback) => 1);
     const cafSpy = vi.fn();
@@ -212,7 +212,7 @@ describe("useGlyphcssAnimation — RAF loop", () => {
 
 // ── Mixer drives setPolygons ──────────────────────────────────────────────────
 
-describe("useGlyphcssAnimation — mixer drives setPolygons", () => {
+describe("useGlyphAnimation — mixer drives setPolygons", () => {
   it("playing an action and manually updating mixer calls setPolygons on target", () => {
     const clips = [makeClip(0, "walk", 2)];
     const target = makeTarget();
@@ -260,17 +260,17 @@ describe("useGlyphcssAnimation — mixer drives setPolygons", () => {
 
 // ── Reactive inputs ────────────────────────────────────────────────────────────
 
-describe("useGlyphcssAnimation — reactive inputs", () => {
+describe("useGlyphAnimation — reactive inputs", () => {
   it("mixer rebuilds when clips ref changes", async () => {
-    const clipsRef = ref([makeClip(0, "walk")]);
+    const clipsRef = ref([makeClip(0, "walk")]);
     const ctrlRef = ref(makeController(clipsRef.value));
     const target = makeTarget();
 
-    let captured!: UseGlyphcssAnimationResultVue;
+    let captured!: UseGlyphAnimationResultVue;
     const container = document.createElement("div");
     const app = createApp({
       setup() {
-        captured = useGlyphcssAnimation(clipsRef, ctrlRef, ref(target));
+        captured = useGlyphAnimation(clipsRef, ctrlRef, ref(target));
         return () => h("div");
       },
     });
@@ -293,13 +293,13 @@ describe("useGlyphcssAnimation — reactive inputs", () => {
   it("builds mixer when root ref resolves after clips and controller are set", async () => {
     const clips = [makeClip(0, "walk", 2)];
     const ctrl = makeController(clips);
-    const rootRef = ref(null);
+    const rootRef = ref(null);
 
-    let captured!: UseGlyphcssAnimationResultVue;
+    let captured!: UseGlyphAnimationResultVue;
     const container = document.createElement("div");
     const app = createApp({
       setup() {
-        captured = useGlyphcssAnimation(ref(clips), ref(ctrl), rootRef);
+        captured = useGlyphAnimation(ref(clips), ref(ctrl), rootRef);
         return () => h("div");
       },
     });
diff --git a/packages/vue/src/animation/useGlyphcssAnimation.ts b/packages/vue/src/animation/useGlyphAnimation.ts
similarity index 76%
rename from packages/vue/src/animation/useGlyphcssAnimation.ts
rename to packages/vue/src/animation/useGlyphAnimation.ts
index e8fabc32..a1dc1cd4 100644
--- a/packages/vue/src/animation/useGlyphcssAnimation.ts
+++ b/packages/vue/src/animation/useGlyphAnimation.ts
@@ -1,8 +1,8 @@
 /**
- * useGlyphcssAnimation — Vue 3 composable mirroring drei's `useAnimations`.
+ * useGlyphAnimation — Vue 3 composable mirroring drei's `useAnimations`.
  *
  * Returns a `mixer`, `actions`, `clips`, `names`, and `ref`. The mixer is
- * built when `clips`, `controller`, and a root `GlyphcssAnimationTarget` are all
+ * built when `clips`, `controller`, and a root `GlyphAnimationTarget` are all
  * available. Drives `mixer.update(dt)` via `requestAnimationFrame` using
  * `performance.now()` deltas (no three.js dependency).
  *
@@ -21,31 +21,31 @@ import {
   computed,
 } from "vue";
 import type { Ref, ComputedRef, MaybeRef } from "vue";
-import { createGlyphcssAnimationMixer } from "@glyphcss/core";
+import { createGlyphAnimationMixer } from "@glyphcss/core";
 import type {
-  GlyphcssAnimationClip,
-  GlyphcssAnimationAction,
-  GlyphcssAnimationMixer,
-  GlyphcssAnimationTarget,
+  GlyphAnimationClip,
+  GlyphAnimationAction,
+  GlyphAnimationMixer,
+  GlyphAnimationTarget,
   ParseAnimationController,
 } from "@glyphcss/core";
 
-export type { GlyphcssAnimationClip, GlyphcssAnimationAction, GlyphcssAnimationMixer };
+export type { GlyphAnimationClip, GlyphAnimationAction, GlyphAnimationMixer };
 
-export interface UseGlyphcssAnimationResultVue {
+export interface UseGlyphAnimationResultVue {
   /** Attach to a mesh handle when not passing `root` directly. */
-  ref: Ref;
+  ref: Ref;
   /** The active mixer, or null if inputs are not ready yet. */
-  mixer: ComputedRef;
+  mixer: ComputedRef;
   /** Resolved clip list. */
-  clips: ComputedRef;
+  clips: ComputedRef;
   /** Clip names in input order. */
   names: ComputedRef;
   /**
    * Lazy action proxy keyed by clip name. Accessing `actions.value["walk"]`
    * instantiates the action via the mixer. Returns null when mixer is null.
    */
-  actions: ComputedRef>;
+  actions: ComputedRef>;
 }
 
 function unwrapRef(v: MaybeRef): T {
@@ -54,24 +54,24 @@ function unwrapRef(v: MaybeRef): T {
     : (v as T);
 }
 
-export function useGlyphcssAnimation(
-  clips: MaybeRef,
+export function useGlyphAnimation(
+  clips: MaybeRef,
   controller: MaybeRef,
-  root?: MaybeRef,
-): UseGlyphcssAnimationResultVue {
+  root?: MaybeRef,
+): UseGlyphAnimationResultVue {
   // Internal ref: used as the root target when `root` is not passed.
-  const internalRef = ref(null);
+  const internalRef = ref(null);
 
   // Plain mutable state (not reactive) — we don't want Vue to track mixer
   // internals. The mixerSignal ref is a reactive counter that we increment
   // to signal computed properties to re-evaluate.
-  let _mixer: GlyphcssAnimationMixer | null = null;
+  let _mixer: GlyphAnimationMixer | null = null;
   let _rafId: number | null = null;
   let _lastTime: number | null = null;
   // Reactive signal: incremented whenever the mixer is replaced.
   const _mixerEpoch = ref(0);
 
-  function resolveRoot(): GlyphcssAnimationTarget | null {
+  function resolveRoot(): GlyphAnimationTarget | null {
     if (root == null) return internalRef.value;
     const r = unwrapRef(root);
     return r ?? internalRef.value;
@@ -118,7 +118,7 @@ export function useGlyphcssAnimation(
     if (!resolvedClips || resolvedClips.length === 0 || !resolvedCtrl) return;
     const resolvedRoot = resolveRoot();
     if (!resolvedRoot) return;
-    _mixer = createGlyphcssAnimationMixer(resolvedRoot, resolvedCtrl);
+    _mixer = createGlyphAnimationMixer(resolvedRoot, resolvedCtrl);
     _mixerEpoch.value++;
     startLoop();
   }
@@ -144,7 +144,7 @@ export function useGlyphcssAnimation(
 
   // ── Computed API ─────────────────────────────────────────────────────────
 
-  const resolvedClipsComputed = computed(() => {
+  const resolvedClipsComputed = computed(() => {
     const c = unwrapRef(clips);
     return c ?? [];
   });
@@ -154,15 +154,15 @@ export function useGlyphcssAnimation(
   );
 
   // `mixer` computed reads _mixerEpoch to invalidate when the mixer changes.
-  const mixerComputed = computed(() => {
+  const mixerComputed = computed(() => {
     void _mixerEpoch.value; // subscribe to epoch changes
     return _mixer;
   });
 
-  const actionsComputed = computed>(() => {
+  const actionsComputed = computed>(() => {
     void _mixerEpoch.value; // subscribe to epoch changes
     const clips_ = resolvedClipsComputed.value;
-    const result: Record = {};
+    const result: Record = {};
     for (const clip of clips_) {
       Object.defineProperty(result, clip.name, {
         enumerable: true,
diff --git a/packages/vue/src/glyphcss/animation/useGlyphcssAnimation.test.ts b/packages/vue/src/glyphcss/animation/useGlyphAnimation.test.ts
similarity index 51%
rename from packages/vue/src/glyphcss/animation/useGlyphcssAnimation.test.ts
rename to packages/vue/src/glyphcss/animation/useGlyphAnimation.test.ts
index e9ea87b7..9c893560 100644
--- a/packages/vue/src/glyphcss/animation/useGlyphcssAnimation.test.ts
+++ b/packages/vue/src/glyphcss/animation/useGlyphAnimation.test.ts
@@ -1,15 +1,16 @@
 /**
- * Feature-level tests for the glyphcss Vue useGlyphcssAnimation composable.
+ * Feature-level tests for the glyphcss Vue useGlyphAnimation composable.
  * Tests use a thin consumer component to exercise observable behavior.
  */
 import { describe, it, expect, afterEach, vi } from "vitest";
 import { createApp, defineComponent, h, nextTick } from "vue";
-import { GlyphcssScene } from "../scene/GlyphcssScene";
-import { useGlyphcssAnimation } from "./useGlyphcssAnimation";
-import type { GlyphcssAnimationClip, ParseAnimationController, GlyphcssAnimationTarget } from "@glyphcss/core";
+import { GlyphScene } from "../scene/GlyphScene";
+import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera";
+import { useGlyphAnimation } from "./useGlyphAnimation";
+import type { GlyphAnimationClip, ParseAnimationController, GlyphAnimationTarget } from "@glyphcss/core";
 
 // A minimal animation clip for testing
-const CLIP: GlyphcssAnimationClip = {
+const CLIP: GlyphAnimationClip = {
   name: "idle",
   duration: 1,
   tracks: [],
@@ -17,7 +18,7 @@ const CLIP: GlyphcssAnimationClip = {
 
 // A minimal parse animation controller stub
 const CONTROLLER: ParseAnimationController = {
-  parse: (_clip: GlyphcssAnimationClip, _target: GlyphcssAnimationTarget) => ({
+  parse: (_clip: GlyphAnimationClip, _target: GlyphAnimationTarget) => ({
     play: () => {},
     stop: () => {},
     reset: () => {},
@@ -26,17 +27,17 @@ const CONTROLLER: ParseAnimationController = {
 } as unknown as ParseAnimationController;
 
 /**
- * Consumer component that calls useGlyphcssAnimation and renders
+ * Consumer component that calls useGlyphAnimation and renders
  * observable state as data attributes.
  */
 function makeConsumer(
-  clips?: GlyphcssAnimationClip[],
+  clips?: GlyphAnimationClip[],
   controller?: ParseAnimationController,
 ) {
   return defineComponent({
     name: "AnimationConsumer",
     setup() {
-      const { names, clips: resolvedClips } = useGlyphcssAnimation(clips, controller);
+      const { names, clips: resolvedClips } = useGlyphAnimation(clips, controller);
       return () =>
         h("div", {
           class: "animation-consumer",
@@ -47,7 +48,23 @@ function makeConsumer(
   });
 }
 
-describe("useGlyphcssAnimation (Vue) — no clips", () => {
+function mountWithCamera(Consumer: ReturnType) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  const app = createApp({
+    setup() {
+      return () =>
+        h(GlyphPerspectiveCamera, {}, {
+          default: () =>
+            h(GlyphScene, {}, { default: () => h(Consumer) }),
+        });
+    },
+  });
+  app.mount(container);
+  return { container, app };
+}
+
+describe("useGlyphAnimation (Vue) — no clips", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -55,29 +72,13 @@ describe("useGlyphcssAnimation (Vue) — no clips", () => {
 
   it("renders consumer without throwing when no clips provided", () => {
     const Consumer = makeConsumer();
-    const container = document.createElement("div");
-    document.body.appendChild(container);
-    const app = createApp({
-      setup() {
-        return () =>
-          h(GlyphcssScene, {}, { default: () => h(Consumer) });
-      },
-    });
-    expect(() => app.mount(container)).not.toThrow();
+    const { app } = mountWithCamera(Consumer);
     app.unmount();
   });
 
   it("consumer is in the DOM when no clips provided", async () => {
     const Consumer = makeConsumer();
-    const container = document.createElement("div");
-    document.body.appendChild(container);
-    const app = createApp({
-      setup() {
-        return () =>
-          h(GlyphcssScene, {}, { default: () => h(Consumer) });
-      },
-    });
-    app.mount(container);
+    const { container, app } = mountWithCamera(Consumer);
     await nextTick();
     expect(container.querySelector(".animation-consumer")).toBeTruthy();
     app.unmount();
@@ -85,15 +86,7 @@ describe("useGlyphcssAnimation (Vue) — no clips", () => {
 
   it("clip count is 0 when no clips provided", async () => {
     const Consumer = makeConsumer();
-    const container = document.createElement("div");
-    document.body.appendChild(container);
-    const app = createApp({
-      setup() {
-        return () =>
-          h(GlyphcssScene, {}, { default: () => h(Consumer) });
-      },
-    });
-    app.mount(container);
+    const { container, app } = mountWithCamera(Consumer);
     await nextTick();
     const consumer = container.querySelector(".animation-consumer");
     expect(consumer?.getAttribute("data-clip-count")).toBe("0");
@@ -102,15 +95,7 @@ describe("useGlyphcssAnimation (Vue) — no clips", () => {
 
   it("names is empty string when no clips", async () => {
     const Consumer = makeConsumer();
-    const container = document.createElement("div");
-    document.body.appendChild(container);
-    const app = createApp({
-      setup() {
-        return () =>
-          h(GlyphcssScene, {}, { default: () => h(Consumer) });
-      },
-    });
-    app.mount(container);
+    const { container, app } = mountWithCamera(Consumer);
     await nextTick();
     const consumer = container.querySelector(".animation-consumer");
     expect(consumer?.getAttribute("data-names")).toBe("");
@@ -118,7 +103,7 @@ describe("useGlyphcssAnimation (Vue) — no clips", () => {
   });
 });
 
-describe("useGlyphcssAnimation (Vue) — with clips", () => {
+describe("useGlyphAnimation (Vue) — with clips", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -126,29 +111,13 @@ describe("useGlyphcssAnimation (Vue) — with clips", () => {
 
   it("renders consumer with clips without throwing", () => {
     const Consumer = makeConsumer([CLIP], CONTROLLER);
-    const container = document.createElement("div");
-    document.body.appendChild(container);
-    const app = createApp({
-      setup() {
-        return () =>
-          h(GlyphcssScene, {}, { default: () => h(Consumer) });
-      },
-    });
-    expect(() => app.mount(container)).not.toThrow();
+    const { app } = mountWithCamera(Consumer);
     app.unmount();
   });
 
   it("clip count reflects provided clips", async () => {
     const Consumer = makeConsumer([CLIP], CONTROLLER);
-    const container = document.createElement("div");
-    document.body.appendChild(container);
-    const app = createApp({
-      setup() {
-        return () =>
-          h(GlyphcssScene, {}, { default: () => h(Consumer) });
-      },
-    });
-    app.mount(container);
+    const { container, app } = mountWithCamera(Consumer);
     await nextTick();
     const consumer = container.querySelector(".animation-consumer");
     expect(consumer?.getAttribute("data-clip-count")).toBe("1");
@@ -157,15 +126,7 @@ describe("useGlyphcssAnimation (Vue) — with clips", () => {
 
   it("names reflects clip name", async () => {
     const Consumer = makeConsumer([CLIP], CONTROLLER);
-    const container = document.createElement("div");
-    document.body.appendChild(container);
-    const app = createApp({
-      setup() {
-        return () =>
-          h(GlyphcssScene, {}, { default: () => h(Consumer) });
-      },
-    });
-    app.mount(container);
+    const { container, app } = mountWithCamera(Consumer);
     await nextTick();
     const consumer = container.querySelector(".animation-consumer");
     expect(consumer?.getAttribute("data-names")).toBe("idle");
@@ -174,22 +135,14 @@ describe("useGlyphcssAnimation (Vue) — with clips", () => {
 
   it("unmounts cleanly", async () => {
     const Consumer = makeConsumer([CLIP], CONTROLLER);
-    const container = document.createElement("div");
-    document.body.appendChild(container);
-    const app = createApp({
-      setup() {
-        return () =>
-          h(GlyphcssScene, {}, { default: () => h(Consumer) });
-      },
-    });
-    app.mount(container);
+    const { container, app } = mountWithCamera(Consumer);
     await nextTick();
     app.unmount();
     expect(container.querySelector(".animation-consumer")).toBeFalsy();
   });
 });
 
-describe("useGlyphcssAnimation (Vue) — ref is exposed", () => {
+describe("useGlyphAnimation (Vue) — ref is exposed", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -199,7 +152,7 @@ describe("useGlyphcssAnimation (Vue) — ref is exposed", () => {
     const RefConsumer = defineComponent({
       name: "RefConsumer",
       setup() {
-        const { ref: animRef } = useGlyphcssAnimation(undefined, undefined);
+        const { ref: animRef } = useGlyphAnimation(undefined, undefined);
         return () =>
           h("div", {
             class: "ref-consumer",
@@ -208,15 +161,7 @@ describe("useGlyphcssAnimation (Vue) — ref is exposed", () => {
       },
     });
 
-    const container = document.createElement("div");
-    document.body.appendChild(container);
-    const app = createApp({
-      setup() {
-        return () =>
-          h(GlyphcssScene, {}, { default: () => h(RefConsumer) });
-      },
-    });
-    app.mount(container);
+    const { container, app } = mountWithCamera(RefConsumer);
     await nextTick();
     const consumer = container.querySelector(".ref-consumer");
     expect(consumer?.getAttribute("data-attached")).toBe("false");
diff --git a/packages/vue/src/glyphcss/animation/useGlyphAnimation.ts b/packages/vue/src/glyphcss/animation/useGlyphAnimation.ts
new file mode 100644
index 00000000..2ddc8f04
--- /dev/null
+++ b/packages/vue/src/glyphcss/animation/useGlyphAnimation.ts
@@ -0,0 +1,6 @@
+/**
+ * useGlyphAnimation — re-exports from the Vue animation module.
+ * The animation system is paint-backend-agnostic.
+ */
+export { useGlyphAnimation } from "../../animation/useGlyphAnimation";
+export type { UseGlyphAnimationResultVue } from "../../animation/useGlyphAnimation";
diff --git a/packages/vue/src/glyphcss/animation/useGlyphcssAnimation.ts b/packages/vue/src/glyphcss/animation/useGlyphcssAnimation.ts
deleted file mode 100644
index b2d10992..00000000
--- a/packages/vue/src/glyphcss/animation/useGlyphcssAnimation.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-/**
- * useGlyphcssAnimation — re-exports from the Vue animation module.
- * The animation system is paint-backend-agnostic.
- */
-export { useGlyphcssAnimation } from "../../animation/useGlyphcssAnimation";
-export type { UseGlyphcssAnimationResultVue } from "../../animation/useGlyphcssAnimation";
diff --git a/packages/vue/src/glyphcss/camera/GlyphcssCamera.test.ts b/packages/vue/src/glyphcss/camera/GlyphCamera.test.ts
similarity index 54%
rename from packages/vue/src/glyphcss/camera/GlyphcssCamera.test.ts
rename to packages/vue/src/glyphcss/camera/GlyphCamera.test.ts
index df4f6f43..b6ddecb9 100644
--- a/packages/vue/src/glyphcss/camera/GlyphcssCamera.test.ts
+++ b/packages/vue/src/glyphcss/camera/GlyphCamera.test.ts
@@ -1,7 +1,7 @@
 import { describe, it, expect, afterEach, vi } from "vitest";
 import { createApp, h, nextTick } from "vue";
-import { GlyphcssScene } from "../scene/GlyphcssScene";
-import { GlyphcssCamera } from "./GlyphcssCamera";
+import { GlyphScene } from "../scene/GlyphScene";
+import { GlyphCamera } from "./GlyphCamera";
 
 function renderScene(
   cameraProps: Record = {},
@@ -11,8 +11,8 @@ function renderScene(
   const app = createApp({
     setup() {
       return () =>
-        h(GlyphcssScene, {}, {
-          default: () => h(GlyphcssCamera, cameraProps),
+        h(GlyphCamera, cameraProps, {
+          default: () => h(GlyphScene, {}),
         });
     },
   });
@@ -20,7 +20,7 @@ function renderScene(
   return { container, app };
 }
 
-describe("GlyphcssCamera (Vue alias for Perspective) — mount inside scene", () => {
+describe("GlyphCamera (Vue alias for Orthographic) — wraps scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -30,14 +30,14 @@ describe("GlyphcssCamera (Vue alias for Perspective) — mount inside scene", ()
     expect(() => renderScene()).not.toThrow();
   });
 
-  it("scene host is present after mounting GlyphcssCamera", async () => {
-    const { container } = renderScene({ distance: 5 });
+  it("scene host is present after mounting GlyphCamera", async () => {
+    const { container } = renderScene({ zoom: 0.5 });
     await nextTick();
-    expect(container.querySelector(".glyphcss-host")).toBeTruthy();
+    expect(container.querySelector(".glyph-host")).toBeTruthy();
   });
 
-  it("accepts distance prop", () => {
-    expect(() => renderScene({ distance: 8 })).not.toThrow();
+  it("accepts zoom prop", () => {
+    expect(() => renderScene({ zoom: 0.8 })).not.toThrow();
   });
 
   it("accepts rotX/rotY props", () => {
@@ -45,26 +45,27 @@ describe("GlyphcssCamera (Vue alias for Perspective) — mount inside scene", ()
   });
 
   it("unmounts cleanly", async () => {
-    const { container, app } = renderScene({ distance: 4 });
+    const { container, app } = renderScene({ zoom: 0.4 });
     await nextTick();
     app.unmount();
-    expect(container.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(container.querySelector(".glyph-output")).toBeFalsy();
   });
 });
 
-describe("GlyphcssCamera (Vue) — outside scene", () => {
+describe("GlyphCamera (Vue) — standalone (no scene child)", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when mounted outside GlyphcssScene", () => {
+  it("mounts without throwing when used without a scene child", () => {
     const container = document.createElement("div");
     const app = createApp({
       setup() {
-        return () => h(GlyphcssCamera, {});
+        return () => h(GlyphCamera, {});
       },
     });
-    expect(() => app.mount(container)).toThrow();
+    expect(() => app.mount(container)).not.toThrow();
+    app.unmount();
   });
 });
diff --git a/packages/vue/src/glyphcss/camera/GlyphCamera.ts b/packages/vue/src/glyphcss/camera/GlyphCamera.ts
new file mode 100644
index 00000000..18b49a37
--- /dev/null
+++ b/packages/vue/src/glyphcss/camera/GlyphCamera.ts
@@ -0,0 +1,3 @@
+/** GlyphCamera — alias for GlyphOrthographicCamera (ergonomic default). */
+export { GlyphOrthographicCamera as GlyphCamera } from "./GlyphOrthographicCamera";
+export type { GlyphOrthographicCameraProps as GlyphCameraProps } from "./GlyphOrthographicCamera";
diff --git a/packages/vue/src/glyphcss/camera/GlyphcssOrthographicCamera.test.ts b/packages/vue/src/glyphcss/camera/GlyphOrthographicCamera.test.ts
similarity index 68%
rename from packages/vue/src/glyphcss/camera/GlyphcssOrthographicCamera.test.ts
rename to packages/vue/src/glyphcss/camera/GlyphOrthographicCamera.test.ts
index 0486209d..6c4931ff 100644
--- a/packages/vue/src/glyphcss/camera/GlyphcssOrthographicCamera.test.ts
+++ b/packages/vue/src/glyphcss/camera/GlyphOrthographicCamera.test.ts
@@ -1,7 +1,7 @@
 import { describe, it, expect, afterEach, vi } from "vitest";
 import { createApp, h, nextTick, ref } from "vue";
-import { GlyphcssScene } from "../scene/GlyphcssScene";
-import { GlyphcssOrthographicCamera } from "./GlyphcssOrthographicCamera";
+import { GlyphScene } from "../scene/GlyphScene";
+import { GlyphOrthographicCamera } from "./GlyphOrthographicCamera";
 
 type OrthoProps = {
   rotX?: number;
@@ -18,8 +18,8 @@ function renderScene(
   const app = createApp({
     setup() {
       return () =>
-        h(GlyphcssScene, {}, {
-          default: () => h(GlyphcssOrthographicCamera, cameraProps),
+        h(GlyphOrthographicCamera, cameraProps, {
+          default: () => h(GlyphScene, {}),
         });
     },
   });
@@ -27,7 +27,7 @@ function renderScene(
   return { container, app };
 }
 
-describe("GlyphcssOrthographicCamera (Vue) — mount inside scene", () => {
+describe("GlyphOrthographicCamera (Vue) — wraps scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -40,13 +40,13 @@ describe("GlyphcssOrthographicCamera (Vue) — mount inside scene", () => {
   it("scene host is present after mounting orthographic camera", async () => {
     const { container } = renderScene();
     await nextTick();
-    expect(container.querySelector(".glyphcss-host")).toBeTruthy();
+    expect(container.querySelector(".glyph-host")).toBeTruthy();
   });
 
   it("scene output 
 is present after mounting orthographic camera", async () => {
     const { container } = renderScene();
     await nextTick();
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
   });
 
   it("accepts zoom prop without throwing", () => {
@@ -68,18 +68,18 @@ describe("GlyphcssOrthographicCamera (Vue) — mount inside scene", () => {
     const app = createApp({
       setup() {
         return () =>
-          h(GlyphcssScene, {}, {
-            default: () => h(GlyphcssOrthographicCamera, { zoom: zoom.value }),
+          h(GlyphOrthographicCamera, { zoom: zoom.value }, {
+            default: () => h(GlyphScene, {}),
           });
       },
     });
     app.mount(container);
     await nextTick();
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
 
     zoom.value = 0.8;
     await nextTick();
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
     app.unmount();
   });
 
@@ -87,23 +87,24 @@ describe("GlyphcssOrthographicCamera (Vue) — mount inside scene", () => {
     const { container, app } = renderScene({ zoom: 0.5 });
     await nextTick();
     app.unmount();
-    expect(container.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(container.querySelector(".glyph-output")).toBeFalsy();
   });
 });
 
-describe("GlyphcssOrthographicCamera (Vue) — outside scene", () => {
+describe("GlyphOrthographicCamera (Vue) — standalone (no scene child)", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when mounted outside GlyphcssScene", () => {
+  it("mounts without throwing when used without a scene child", () => {
     const container = document.createElement("div");
     const app = createApp({
       setup() {
-        return () => h(GlyphcssOrthographicCamera, {});
+        return () => h(GlyphOrthographicCamera, {});
       },
     });
-    expect(() => app.mount(container)).toThrow();
+    expect(() => app.mount(container)).not.toThrow();
+    app.unmount();
   });
 });
diff --git a/packages/vue/src/glyphcss/camera/GlyphOrthographicCamera.ts b/packages/vue/src/glyphcss/camera/GlyphOrthographicCamera.ts
new file mode 100644
index 00000000..b220cbb4
--- /dev/null
+++ b/packages/vue/src/glyphcss/camera/GlyphOrthographicCamera.ts
@@ -0,0 +1,70 @@
+/**
+ * GlyphOrthographicCamera — outer wrapper that creates an orthographic camera
+ * handle and provides it via GlyphCameraContextKey.  must be
+ * placed inside this component.
+ */
+import { defineComponent, h, provide, shallowRef, watch } from "vue";
+import type { PropType, ShallowRef, CSSProperties } from "vue";
+import type { GlyphCamera, GlyphOrthographicCameraOptions } from "glyphcss";
+import { createGlyphOrthographicCamera } from "glyphcss";
+import { GlyphCameraContextKey } from "./context";
+
+export interface GlyphOrthographicCameraProps {
+  rotX?: number;
+  rotY?: number;
+  zoom?: number;
+  center?: [number, number];
+  class?: string;
+  style?: CSSProperties | string;
+}
+
+export const GlyphOrthographicCamera = defineComponent({
+  name: "GlyphOrthographicCamera",
+  props: {
+    rotX: { type: Number, default: undefined },
+    rotY: { type: Number, default: undefined },
+    zoom: { type: Number, default: undefined },
+    center: { type: Array as unknown as PropType<[number, number]>, default: undefined },
+    class: { type: String, default: undefined },
+    style: { type: [Object, String] as unknown as PropType, default: undefined },
+  },
+  setup(props, { slots }) {
+    const opts: GlyphOrthographicCameraOptions = {};
+    if (props.rotX !== undefined) opts.rotX = props.rotX;
+    if (props.rotY !== undefined) opts.rotY = props.rotY;
+    if (props.zoom !== undefined) opts.zoom = props.zoom;
+    if (props.center !== undefined) opts.center = props.center;
+
+    const cameraRef = shallowRef(createGlyphOrthographicCamera(opts));
+    // The child GlyphScene will set this to trigger rerenders when camera props change.
+    const sceneRerenderRef: ShallowRef<(() => void) | null> = shallowRef(null);
+
+    function rerender(): void {
+      sceneRerenderRef.value?.();
+    }
+
+    provide(GlyphCameraContextKey, { cameraRef, rerender, sceneRerenderRef });
+
+    watch(
+      () => ({ rotX: props.rotX, rotY: props.rotY, zoom: props.zoom }),
+      (next) => {
+        const camera = cameraRef.value;
+        if (!camera) return;
+        let dirty = false;
+        if (next.rotX !== undefined && camera.rotX !== next.rotX) { camera.rotX = next.rotX; dirty = true; }
+        if (next.rotY !== undefined && camera.rotY !== next.rotY) { camera.rotY = next.rotY; dirty = true; }
+        if (next.zoom !== undefined && camera.zoom !== next.zoom) { camera.zoom = next.zoom; dirty = true; }
+        if (dirty) sceneRerenderRef.value?.();
+      },
+    );
+
+    return () => h(
+      "div",
+      {
+        class: props.class,
+        style: props.style,
+      },
+      slots.default?.() ?? [],
+    );
+  },
+});
diff --git a/packages/vue/src/glyphcss/camera/GlyphcssPerspectiveCamera.test.ts b/packages/vue/src/glyphcss/camera/GlyphPerspectiveCamera.test.ts
similarity index 71%
rename from packages/vue/src/glyphcss/camera/GlyphcssPerspectiveCamera.test.ts
rename to packages/vue/src/glyphcss/camera/GlyphPerspectiveCamera.test.ts
index 53b70dbf..4cdcf4d7 100644
--- a/packages/vue/src/glyphcss/camera/GlyphcssPerspectiveCamera.test.ts
+++ b/packages/vue/src/glyphcss/camera/GlyphPerspectiveCamera.test.ts
@@ -1,7 +1,7 @@
 import { describe, it, expect, afterEach, vi } from "vitest";
 import { createApp, h, nextTick, ref } from "vue";
-import { GlyphcssScene } from "../scene/GlyphcssScene";
-import { GlyphcssPerspectiveCamera } from "./GlyphcssPerspectiveCamera";
+import { GlyphScene } from "../scene/GlyphScene";
+import { GlyphPerspectiveCamera } from "./GlyphPerspectiveCamera";
 
 type CameraProps = {
   rotX?: number;
@@ -20,8 +20,8 @@ function renderScene(
   const app = createApp({
     setup() {
       return () =>
-        h(GlyphcssScene, {}, {
-          default: () => h(GlyphcssPerspectiveCamera, cameraProps),
+        h(GlyphPerspectiveCamera, cameraProps, {
+          default: () => h(GlyphScene, {}),
         });
     },
   });
@@ -29,7 +29,7 @@ function renderScene(
   return { container, app };
 }
 
-describe("GlyphcssPerspectiveCamera (Vue) — mount inside scene", () => {
+describe("GlyphPerspectiveCamera (Vue) — wraps scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -42,13 +42,13 @@ describe("GlyphcssPerspectiveCamera (Vue) — mount inside scene", () => {
   it("scene host is present after mounting camera", async () => {
     const { container } = renderScene({ distance: 5 });
     await nextTick();
-    expect(container.querySelector(".glyphcss-host")).toBeTruthy();
+    expect(container.querySelector(".glyph-host")).toBeTruthy();
   });
 
   it("scene output 
 is present after mounting camera", async () => {
     const { container } = renderScene({ distance: 5 });
     await nextTick();
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
   });
 
   it("accepts distance prop without throwing", () => {
@@ -78,18 +78,18 @@ describe("GlyphcssPerspectiveCamera (Vue) — mount inside scene", () => {
     const app = createApp({
       setup() {
         return () =>
-          h(GlyphcssScene, {}, {
-            default: () => h(GlyphcssPerspectiveCamera, { distance: distance.value }),
+          h(GlyphPerspectiveCamera, { distance: distance.value }, {
+            default: () => h(GlyphScene, {}),
           });
       },
     });
     app.mount(container);
     await nextTick();
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
 
     distance.value = 7;
     await nextTick();
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
     app.unmount();
   });
 
@@ -97,23 +97,24 @@ describe("GlyphcssPerspectiveCamera (Vue) — mount inside scene", () => {
     const { container, app } = renderScene({ distance: 3 });
     await nextTick();
     app.unmount();
-    expect(container.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(container.querySelector(".glyph-output")).toBeFalsy();
   });
 });
 
-describe("GlyphcssPerspectiveCamera (Vue) — outside scene", () => {
+describe("GlyphPerspectiveCamera (Vue) — standalone (no scene child)", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when mounted outside GlyphcssScene", () => {
+  it("mounts without throwing when used without a scene child", () => {
     const container = document.createElement("div");
     const app = createApp({
       setup() {
-        return () => h(GlyphcssPerspectiveCamera, {});
+        return () => h(GlyphPerspectiveCamera, {});
       },
     });
-    expect(() => app.mount(container)).toThrow();
+    expect(() => app.mount(container)).not.toThrow();
+    app.unmount();
   });
 });
diff --git a/packages/vue/src/glyphcss/camera/GlyphPerspectiveCamera.ts b/packages/vue/src/glyphcss/camera/GlyphPerspectiveCamera.ts
new file mode 100644
index 00000000..54396076
--- /dev/null
+++ b/packages/vue/src/glyphcss/camera/GlyphPerspectiveCamera.ts
@@ -0,0 +1,79 @@
+/**
+ * GlyphPerspectiveCamera — outer wrapper that creates a perspective camera
+ * handle and provides it via GlyphCameraContextKey.  must be
+ * placed inside this component.
+ */
+import { defineComponent, h, provide, shallowRef, watch } from "vue";
+import type { PropType, ShallowRef, CSSProperties } from "vue";
+import type { GlyphCamera, GlyphPerspectiveCameraOptions } from "glyphcss";
+import { createGlyphPerspectiveCamera } from "glyphcss";
+import { GlyphCameraContextKey } from "./context";
+
+export interface GlyphPerspectiveCameraProps {
+  rotX?: number;
+  rotY?: number;
+  distance?: number;
+  zoom?: number;
+  stretch?: number;
+  center?: [number, number];
+  class?: string;
+  style?: CSSProperties | string;
+}
+
+export const GlyphPerspectiveCamera = defineComponent({
+  name: "GlyphPerspectiveCamera",
+  props: {
+    rotX: { type: Number, default: undefined },
+    rotY: { type: Number, default: undefined },
+    distance: { type: Number, default: undefined },
+    zoom: { type: Number, default: undefined },
+    stretch: { type: Number, default: undefined },
+    center: { type: Array as unknown as PropType<[number, number]>, default: undefined },
+    class: { type: String, default: undefined },
+    style: { type: [Object, String] as unknown as PropType, default: undefined },
+  },
+  setup(props, { slots }) {
+    const opts: GlyphPerspectiveCameraOptions = {};
+    if (props.rotX !== undefined) opts.rotX = props.rotX;
+    if (props.rotY !== undefined) opts.rotY = props.rotY;
+    if (props.distance !== undefined) opts.distance = props.distance;
+    if (props.zoom !== undefined) opts.zoom = props.zoom;
+    if (props.stretch !== undefined) opts.stretch = props.stretch;
+    if (props.center !== undefined) opts.center = props.center;
+
+    const cameraRef = shallowRef(createGlyphPerspectiveCamera(opts));
+    // The child GlyphScene will set this to trigger rerenders when camera props change.
+    const sceneRerenderRef: ShallowRef<(() => void) | null> = shallowRef(null);
+
+    function rerender(): void {
+      sceneRerenderRef.value?.();
+    }
+
+    provide(GlyphCameraContextKey, { cameraRef, rerender, sceneRerenderRef });
+
+    // Sync prop changes
+    watch(
+      () => ({ rotX: props.rotX, rotY: props.rotY, distance: props.distance, zoom: props.zoom, stretch: props.stretch }),
+      (next) => {
+        const camera = cameraRef.value;
+        if (!camera) return;
+        let dirty = false;
+        if (next.rotX !== undefined && camera.rotX !== next.rotX) { camera.rotX = next.rotX; dirty = true; }
+        if (next.rotY !== undefined && camera.rotY !== next.rotY) { camera.rotY = next.rotY; dirty = true; }
+        if (next.distance !== undefined && camera.distance !== next.distance) { camera.distance = next.distance; dirty = true; }
+        if (next.zoom !== undefined && camera.zoom !== next.zoom) { camera.zoom = next.zoom; dirty = true; }
+        if (next.stretch !== undefined && camera.stretch !== next.stretch) { camera.stretch = next.stretch; dirty = true; }
+        if (dirty) sceneRerenderRef.value?.();
+      },
+    );
+
+    return () => h(
+      "div",
+      {
+        class: props.class,
+        style: props.style,
+      },
+      slots.default?.() ?? [],
+    );
+  },
+});
diff --git a/packages/vue/src/glyphcss/camera/GlyphcssCamera.ts b/packages/vue/src/glyphcss/camera/GlyphcssCamera.ts
deleted file mode 100644
index 3322994a..00000000
--- a/packages/vue/src/glyphcss/camera/GlyphcssCamera.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-/** GlyphcssCamera — alias for GlyphcssPerspectiveCamera (ergonomic default). */
-export { GlyphcssPerspectiveCamera as GlyphcssCamera } from "./GlyphcssPerspectiveCamera";
-export type { GlyphcssPerspectiveCameraProps as GlyphcssCameraProps } from "./GlyphcssPerspectiveCamera";
diff --git a/packages/vue/src/glyphcss/camera/GlyphcssOrthographicCamera.ts b/packages/vue/src/glyphcss/camera/GlyphcssOrthographicCamera.ts
deleted file mode 100644
index 763af51e..00000000
--- a/packages/vue/src/glyphcss/camera/GlyphcssOrthographicCamera.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-/**
- * GlyphcssOrthographicCamera — Vue 3 orthographic camera for the ASCII backend.
- */
-import { defineComponent, inject, provide, shallowRef, watch, onMounted } from "vue";
-import type { PropType } from "vue";
-import type { GlyphcssCamera, GlyphcssOrthographicCameraOptions } from "glyphcss";
-import { createGlyphcssOrthographicCamera } from "glyphcss";
-import { GlyphcssSceneContextKey } from "../scene/context";
-import { GlyphcssCameraContextKey } from "./context";
-
-export interface GlyphcssOrthographicCameraProps {
-  rotX?: number;
-  rotY?: number;
-  zoom?: number;
-  center?: [number, number];
-}
-
-export const GlyphcssOrthographicCamera = defineComponent({
-  name: "GlyphcssOrthographicCamera",
-  props: {
-    rotX: { type: Number, default: undefined },
-    rotY: { type: Number, default: undefined },
-    zoom: { type: Number, default: undefined },
-    center: { type: Array as unknown as PropType<[number, number]>, default: undefined },
-  },
-  setup(props, { slots }) {
-    const sceneCtx = inject(GlyphcssSceneContextKey);
-    if (!sceneCtx) {
-      throw new Error("glyphcss: GlyphcssOrthographicCamera must be used inside a GlyphcssScene.");
-    }
-    const { sceneRef } = sceneCtx;
-    const cameraRef = shallowRef(null);
-
-    function rerender(): void {
-      sceneRef.value?.rerender();
-    }
-
-    provide(GlyphcssCameraContextKey, { cameraRef, rerender });
-
-    onMounted(() => {
-      const opts: GlyphcssOrthographicCameraOptions = {};
-      if (props.rotX !== undefined) opts.rotX = props.rotX;
-      if (props.rotY !== undefined) opts.rotY = props.rotY;
-      if (props.zoom !== undefined) opts.zoom = props.zoom;
-      if (props.center !== undefined) opts.center = props.center;
-      const camera = createGlyphcssOrthographicCamera(opts);
-      cameraRef.value = camera;
-      const scene = sceneRef.value;
-      if (scene) {
-        scene.setOptions({ camera });
-        scene.rerender();
-      }
-    });
-
-    watch(
-      () => ({ rotX: props.rotX, rotY: props.rotY, zoom: props.zoom }),
-      (next) => {
-        const camera = cameraRef.value;
-        if (!camera) return;
-        let dirty = false;
-        if (next.rotX !== undefined && camera.rotX !== next.rotX) { camera.rotX = next.rotX; dirty = true; }
-        if (next.rotY !== undefined && camera.rotY !== next.rotY) { camera.rotY = next.rotY; dirty = true; }
-        if (next.zoom !== undefined && camera.zoom !== next.zoom) { camera.zoom = next.zoom; dirty = true; }
-        if (dirty) sceneRef.value?.rerender();
-      },
-    );
-
-    return () => slots.default?.() ?? null;
-  },
-});
diff --git a/packages/vue/src/glyphcss/camera/GlyphcssPerspectiveCamera.ts b/packages/vue/src/glyphcss/camera/GlyphcssPerspectiveCamera.ts
deleted file mode 100644
index 9d546f23..00000000
--- a/packages/vue/src/glyphcss/camera/GlyphcssPerspectiveCamera.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * GlyphcssPerspectiveCamera — Vue 3 camera component for the ASCII backend.
- * Must be placed inside .
- */
-import { defineComponent, inject, provide, shallowRef, watch, onMounted } from "vue";
-import type { PropType } from "vue";
-import type { GlyphcssCamera, GlyphcssPerspectiveCameraOptions } from "glyphcss";
-import { createGlyphcssPerspectiveCamera } from "glyphcss";
-import { GlyphcssSceneContextKey } from "../scene/context";
-import { GlyphcssCameraContextKey } from "./context";
-
-export interface GlyphcssPerspectiveCameraProps {
-  rotX?: number;
-  rotY?: number;
-  distance?: number;
-  zoom?: number;
-  stretch?: number;
-  center?: [number, number];
-}
-
-export const GlyphcssPerspectiveCamera = defineComponent({
-  name: "GlyphcssPerspectiveCamera",
-  props: {
-    rotX: { type: Number, default: undefined },
-    rotY: { type: Number, default: undefined },
-    distance: { type: Number, default: undefined },
-    zoom: { type: Number, default: undefined },
-    stretch: { type: Number, default: undefined },
-    center: { type: Array as unknown as PropType<[number, number]>, default: undefined },
-  },
-  setup(props, { slots }) {
-    const sceneCtx = inject(GlyphcssSceneContextKey);
-    if (!sceneCtx) {
-      throw new Error("glyphcss: GlyphcssPerspectiveCamera must be used inside a GlyphcssScene.");
-    }
-    const { sceneRef } = sceneCtx;
-
-    const cameraRef = shallowRef(null);
-
-    function rerender(): void {
-      sceneRef.value?.rerender();
-    }
-
-    provide(GlyphcssCameraContextKey, { cameraRef, rerender });
-
-    onMounted(() => {
-      const opts: GlyphcssPerspectiveCameraOptions = {};
-      if (props.rotX !== undefined) opts.rotX = props.rotX;
-      if (props.rotY !== undefined) opts.rotY = props.rotY;
-      if (props.distance !== undefined) opts.distance = props.distance;
-      if (props.zoom !== undefined) opts.zoom = props.zoom;
-      if (props.stretch !== undefined) opts.stretch = props.stretch;
-      if (props.center !== undefined) opts.center = props.center;
-      const camera = createGlyphcssPerspectiveCamera(opts);
-      cameraRef.value = camera;
-      const scene = sceneRef.value;
-      if (scene) {
-        scene.setOptions({ camera });
-        scene.rerender();
-      }
-    });
-
-    // Sync prop changes
-    watch(
-      () => ({ rotX: props.rotX, rotY: props.rotY, distance: props.distance, zoom: props.zoom, stretch: props.stretch }),
-      (next) => {
-        const camera = cameraRef.value;
-        if (!camera) return;
-        let dirty = false;
-        if (next.rotX !== undefined && camera.rotX !== next.rotX) { camera.rotX = next.rotX; dirty = true; }
-        if (next.rotY !== undefined && camera.rotY !== next.rotY) { camera.rotY = next.rotY; dirty = true; }
-        if (next.distance !== undefined && camera.distance !== next.distance) { camera.distance = next.distance; dirty = true; }
-        if (next.zoom !== undefined && camera.zoom !== next.zoom) { camera.zoom = next.zoom; dirty = true; }
-        if (next.stretch !== undefined && camera.stretch !== next.stretch) { camera.stretch = next.stretch; dirty = true; }
-        if (dirty) sceneRef.value?.rerender();
-      },
-    );
-
-    return () => slots.default?.() ?? null;
-  },
-});
diff --git a/packages/vue/src/glyphcss/camera/context.ts b/packages/vue/src/glyphcss/camera/context.ts
index 82d46abd..b6cc9f50 100644
--- a/packages/vue/src/glyphcss/camera/context.ts
+++ b/packages/vue/src/glyphcss/camera/context.ts
@@ -1,12 +1,28 @@
+import { inject } from "vue";
 import type { InjectionKey, ShallowRef } from "vue";
-import type { GlyphcssCamera } from "glyphcss";
+import type { GlyphCamera } from "glyphcss";
 
-export interface GlyphcssCameraContextValue {
-  cameraRef: ShallowRef;
+export interface GlyphCameraContextValue {
+  cameraRef: ShallowRef;
   rerender: () => void;
+  /**
+   * Set by the child GlyphScene so the camera can trigger rerenders when
+   * props change after the scene is mounted.
+   */
+  sceneRerenderRef: ShallowRef<(() => void) | null>;
 }
 
-export const GlyphcssCameraContextKey: InjectionKey =
-  Symbol("glyphcss-camera");
+export const GlyphCameraContextKey: InjectionKey =
+  Symbol("glyph-camera");
 
-export type { GlyphcssCamera };
+export function useGlyphCameraContext(): GlyphCameraContextValue {
+  const ctx = inject(GlyphCameraContextKey);
+  if (!ctx) {
+    throw new Error(
+      "glyphcss: GlyphScene must be placed inside a GlyphPerspectiveCamera or GlyphOrthographicCamera.",
+    );
+  }
+  return ctx;
+}
+
+export type { GlyphCamera };
diff --git a/packages/vue/src/glyphcss/camera/index.ts b/packages/vue/src/glyphcss/camera/index.ts
index f329ccec..a75f7a28 100644
--- a/packages/vue/src/glyphcss/camera/index.ts
+++ b/packages/vue/src/glyphcss/camera/index.ts
@@ -1,9 +1,9 @@
-export { GlyphcssCamera } from "./GlyphcssCamera";
-export type { GlyphcssCameraProps } from "./GlyphcssCamera";
-export { GlyphcssPerspectiveCamera } from "./GlyphcssPerspectiveCamera";
-export type { GlyphcssPerspectiveCameraProps } from "./GlyphcssPerspectiveCamera";
-export { GlyphcssOrthographicCamera } from "./GlyphcssOrthographicCamera";
-export type { GlyphcssOrthographicCameraProps } from "./GlyphcssOrthographicCamera";
-export { GlyphcssCameraContextKey } from "./context";
-export type { GlyphcssCameraContextValue } from "./context";
-export { useGlyphcssCamera } from "./useGlyphcssCamera";
+export { GlyphCamera } from "./GlyphCamera";
+export type { GlyphCameraProps } from "./GlyphCamera";
+export { GlyphPerspectiveCamera } from "./GlyphPerspectiveCamera";
+export type { GlyphPerspectiveCameraProps } from "./GlyphPerspectiveCamera";
+export { GlyphOrthographicCamera } from "./GlyphOrthographicCamera";
+export type { GlyphOrthographicCameraProps } from "./GlyphOrthographicCamera";
+export { GlyphCameraContextKey, useGlyphCameraContext } from "./context";
+export type { GlyphCameraContextValue } from "./context";
+export { useGlyphCamera } from "./useGlyphCamera";
diff --git a/packages/vue/src/glyphcss/camera/useGlyphcssCamera.test.ts b/packages/vue/src/glyphcss/camera/useGlyphCamera.test.ts
similarity index 66%
rename from packages/vue/src/glyphcss/camera/useGlyphcssCamera.test.ts
rename to packages/vue/src/glyphcss/camera/useGlyphCamera.test.ts
index 4751ef53..26285a70 100644
--- a/packages/vue/src/glyphcss/camera/useGlyphcssCamera.test.ts
+++ b/packages/vue/src/glyphcss/camera/useGlyphCamera.test.ts
@@ -1,23 +1,23 @@
 /**
- * Tests for useGlyphcssCamera via a thin consumer component.
+ * Tests for useGlyphCamera via a thin consumer component.
  * Tests observable rendering behavior, not internal implementation.
  */
 import { describe, it, expect, afterEach, vi } from "vitest";
 import { createApp, defineComponent, h, inject, nextTick } from "vue";
-import { GlyphcssScene } from "../scene/GlyphcssScene";
-import { GlyphcssPerspectiveCamera } from "./GlyphcssPerspectiveCamera";
-import { GlyphcssCameraContextKey } from "./context";
+import { GlyphScene } from "../scene/GlyphScene";
+import { GlyphPerspectiveCamera } from "./GlyphPerspectiveCamera";
+import { GlyphCameraContextKey } from "./context";
 
 /**
- * Consumer component that reads from GlyphcssCameraContext and renders
+ * Consumer component that reads from GlyphCameraContext and renders
  * presence as a data attribute.
  */
 const CameraConsumer = defineComponent({
   name: "CameraConsumer",
   setup() {
-    const ctx = inject(GlyphcssCameraContextKey);
+    const ctx = inject(GlyphCameraContextKey);
     if (!ctx) {
-      throw new Error("glyphcss: useGlyphcssCamera must be used inside a GlyphcssCamera component.");
+      throw new Error("glyphcss: useGlyphCamera must be used inside a GlyphCamera component.");
     }
     const hasCameraRef = ctx.cameraRef !== null;
     return () =>
@@ -36,9 +36,9 @@ function renderWithCamera(
   const app = createApp({
     setup() {
       return () =>
-        h(GlyphcssScene, {}, {
+        h(GlyphPerspectiveCamera, cameraProps, {
           default: () =>
-            h(GlyphcssPerspectiveCamera, cameraProps, {
+            h(GlyphScene, {}, {
               default: () => h(CameraConsumer),
             }),
         });
@@ -48,7 +48,7 @@ function renderWithCamera(
   return { container, app };
 }
 
-describe("useGlyphcssCamera (Vue) — via consumer inside camera context", () => {
+describe("useGlyphCamera (Vue) — via consumer inside camera context", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -63,7 +63,7 @@ describe("useGlyphcssCamera (Vue) — via consumer inside camera context", () =>
   it("scene output is still rendered when camera consumer is present", async () => {
     const { container } = renderWithCamera({ distance: 5 });
     await nextTick();
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
   });
 
   it("camera consumer mounts without throwing", () => {
@@ -74,24 +74,21 @@ describe("useGlyphcssCamera (Vue) — via consumer inside camera context", () =>
     const { container, app } = renderWithCamera({ distance: 3 });
     await nextTick();
     app.unmount();
-    expect(container.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(container.querySelector(".glyph-output")).toBeFalsy();
   });
 });
 
-describe("useGlyphcssCamera (Vue) — error when outside camera context", () => {
+describe("useGlyphCamera (Vue) — error when outside camera context", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when used outside GlyphcssCamera", () => {
+  it("throws when used outside GlyphCamera", () => {
     const container = document.createElement("div");
     const app = createApp({
       setup() {
-        return () =>
-          h(GlyphcssScene, {}, {
-            default: () => h(CameraConsumer),
-          });
+        return () => h(CameraConsumer);
       },
     });
     expect(() => app.mount(container)).toThrow();
diff --git a/packages/vue/src/glyphcss/camera/useGlyphCamera.ts b/packages/vue/src/glyphcss/camera/useGlyphCamera.ts
new file mode 100644
index 00000000..59bc314e
--- /dev/null
+++ b/packages/vue/src/glyphcss/camera/useGlyphCamera.ts
@@ -0,0 +1,14 @@
+import { inject } from "vue";
+import { GlyphCameraContextKey } from "./context";
+import type { GlyphCameraContextValue } from "./context";
+
+export function useGlyphCamera(): GlyphCameraContextValue {
+  const ctx = inject(GlyphCameraContextKey);
+  if (!ctx) {
+    throw new Error("glyphcss: useGlyphCamera must be used inside a GlyphCamera component.");
+  }
+  return ctx;
+}
+
+export type { GlyphCameraContextValue };
+export { useGlyphCameraContext } from "./context";
diff --git a/packages/vue/src/glyphcss/camera/useGlyphcssCamera.ts b/packages/vue/src/glyphcss/camera/useGlyphcssCamera.ts
deleted file mode 100644
index d99fa3ed..00000000
--- a/packages/vue/src/glyphcss/camera/useGlyphcssCamera.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { inject } from "vue";
-import { GlyphcssCameraContextKey } from "./context";
-import type { GlyphcssCameraContextValue } from "./context";
-
-export function useGlyphcssCamera(): GlyphcssCameraContextValue {
-  const ctx = inject(GlyphcssCameraContextKey);
-  if (!ctx) {
-    throw new Error("glyphcss: useGlyphcssCamera must be used inside a GlyphcssCamera component.");
-  }
-  return ctx;
-}
-
-export type { GlyphcssCameraContextValue };
diff --git a/packages/vue/src/glyphcss/controls/GlyphcssFirstPersonControls.test.ts b/packages/vue/src/glyphcss/controls/GlyphFirstPersonControls.test.ts
similarity index 67%
rename from packages/vue/src/glyphcss/controls/GlyphcssFirstPersonControls.test.ts
rename to packages/vue/src/glyphcss/controls/GlyphFirstPersonControls.test.ts
index 2c8c9be6..e53d6270 100644
--- a/packages/vue/src/glyphcss/controls/GlyphcssFirstPersonControls.test.ts
+++ b/packages/vue/src/glyphcss/controls/GlyphFirstPersonControls.test.ts
@@ -1,7 +1,8 @@
 import { describe, it, expect, afterEach, vi } from "vitest";
 import { createApp, h, nextTick, ref } from "vue";
-import { GlyphcssScene } from "../scene/GlyphcssScene";
-import { GlyphcssFirstPersonControls } from "./GlyphcssFirstPersonControls";
+import { GlyphScene } from "../scene/GlyphScene";
+import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera";
+import { GlyphFirstPersonControls } from "./GlyphFirstPersonControls";
 
 type FPControlsProps = {
   drag?: boolean;
@@ -19,8 +20,11 @@ function renderScene(
   const app = createApp({
     setup() {
       return () =>
-        h(GlyphcssScene, {}, {
-          default: () => h(GlyphcssFirstPersonControls, controlsProps),
+        h(GlyphPerspectiveCamera, {}, {
+          default: () =>
+            h(GlyphScene, {}, {
+              default: () => h(GlyphFirstPersonControls, controlsProps),
+            }),
         });
     },
   });
@@ -28,7 +32,7 @@ function renderScene(
   return { container, app };
 }
 
-describe("GlyphcssFirstPersonControls (Vue) — mount inside scene", () => {
+describe("GlyphFirstPersonControls (Vue) — mount inside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -41,7 +45,7 @@ describe("GlyphcssFirstPersonControls (Vue) — mount inside scene", () => {
   it("scene host is present after mounting first-person controls", async () => {
     const { container } = renderScene();
     await nextTick();
-    expect(container.querySelector(".glyphcss-host")).toBeTruthy();
+    expect(container.querySelector(".glyph-host")).toBeTruthy();
   });
 
   it("accepts drag=false without throwing", () => {
@@ -67,18 +71,21 @@ describe("GlyphcssFirstPersonControls (Vue) — mount inside scene", () => {
     const app = createApp({
       setup() {
         return () =>
-          h(GlyphcssScene, {}, {
-            default: () => h(GlyphcssFirstPersonControls, { drag: drag.value }),
+          h(GlyphPerspectiveCamera, {}, {
+            default: () =>
+              h(GlyphScene, {}, {
+                default: () => h(GlyphFirstPersonControls, { drag: drag.value }),
+              }),
           });
       },
     });
     app.mount(container);
     await nextTick();
-    expect(container.querySelector(".glyphcss-scene")).toBeTruthy();
+    expect(container.querySelector(".glyph-scene")).toBeTruthy();
 
     drag.value = false;
     await nextTick();
-    expect(container.querySelector(".glyphcss-scene")).toBeTruthy();
+    expect(container.querySelector(".glyph-scene")).toBeTruthy();
     app.unmount();
   });
 
@@ -86,21 +93,21 @@ describe("GlyphcssFirstPersonControls (Vue) — mount inside scene", () => {
     const { container, app } = renderScene();
     await nextTick();
     app.unmount();
-    expect(container.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(container.querySelector(".glyph-output")).toBeFalsy();
   });
 });
 
-describe("GlyphcssFirstPersonControls (Vue) — outside scene", () => {
+describe("GlyphFirstPersonControls (Vue) — outside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when mounted outside GlyphcssScene", () => {
+  it("throws when mounted outside GlyphScene", () => {
     const container = document.createElement("div");
     const app = createApp({
       setup() {
-        return () => h(GlyphcssFirstPersonControls, {});
+        return () => h(GlyphFirstPersonControls, {});
       },
     });
     expect(() => app.mount(container)).toThrow();
diff --git a/packages/vue/src/glyphcss/controls/GlyphFirstPersonControls.ts b/packages/vue/src/glyphcss/controls/GlyphFirstPersonControls.ts
new file mode 100644
index 00000000..3c383672
--- /dev/null
+++ b/packages/vue/src/glyphcss/controls/GlyphFirstPersonControls.ts
@@ -0,0 +1,64 @@
+/**
+ * GlyphFirstPersonControls — Vue 3 first-person controls for GlyphScene.
+ */
+import { defineComponent, inject, onBeforeUnmount, watch, shallowRef, watchEffect } from "vue";
+import type { GlyphFirstPersonControlsHandle, GlyphFirstPersonControlsOptions } from "glyphcss";
+import { createGlyphFirstPersonControls } from "glyphcss";
+import { GlyphSceneContextKey } from "../scene/context";
+
+export interface GlyphFirstPersonControlsProps {
+  drag?: boolean;
+  keyboard?: boolean;
+  moveSpeed?: number;
+  lookSpeed?: number;
+  invert?: boolean | number;
+}
+
+export const GlyphFirstPersonControls = defineComponent({
+  name: "GlyphFirstPersonControls",
+  props: {
+    drag: { type: Boolean, default: true },
+    keyboard: { type: Boolean, default: true },
+    moveSpeed: { type: Number, default: 0.05 },
+    lookSpeed: { type: Number, default: 0.004 },
+    invert: { type: [Boolean, Number] as unknown as () => boolean | number, default: false },
+  },
+  setup(props) {
+    const sceneCtx = inject(GlyphSceneContextKey);
+    if (!sceneCtx) {
+      throw new Error("glyphcss: GlyphFirstPersonControls must be used inside a GlyphScene.");
+    }
+    const { sceneRef } = sceneCtx;
+    const controlsRef = shallowRef(null);
+
+    // In Vue 3, child onMounted hooks fire before parent onMounted, so
+    // sceneRef.value is null when this runs. Watch for the scene to appear.
+    const stopWatch = watchEffect(() => {
+      const scene = sceneRef.value;
+      if (!scene || controlsRef.value) return;
+      const opts: GlyphFirstPersonControlsOptions = {
+        drag: props.drag,
+        keyboard: props.keyboard,
+        moveSpeed: props.moveSpeed,
+        lookSpeed: props.lookSpeed,
+        invert: props.invert,
+      };
+      controlsRef.value = createGlyphFirstPersonControls(scene, opts);
+    });
+
+    onBeforeUnmount(() => {
+      stopWatch();
+      controlsRef.value?.destroy();
+      controlsRef.value = null;
+    });
+
+    watch(
+      () => ({ drag: props.drag, keyboard: props.keyboard, moveSpeed: props.moveSpeed, lookSpeed: props.lookSpeed, invert: props.invert }),
+      (next) => {
+        controlsRef.value?.update(next);
+      },
+    );
+
+    return () => null;
+  },
+});
diff --git a/packages/vue/src/glyphcss/controls/GlyphcssMapControls.test.ts b/packages/vue/src/glyphcss/controls/GlyphMapControls.test.ts
similarity index 68%
rename from packages/vue/src/glyphcss/controls/GlyphcssMapControls.test.ts
rename to packages/vue/src/glyphcss/controls/GlyphMapControls.test.ts
index 88a5526d..f305b7a1 100644
--- a/packages/vue/src/glyphcss/controls/GlyphcssMapControls.test.ts
+++ b/packages/vue/src/glyphcss/controls/GlyphMapControls.test.ts
@@ -1,7 +1,8 @@
 import { describe, it, expect, afterEach, vi } from "vitest";
 import { createApp, h, nextTick, ref } from "vue";
-import { GlyphcssScene } from "../scene/GlyphcssScene";
-import { GlyphcssMapControls } from "./GlyphcssMapControls";
+import { GlyphScene } from "../scene/GlyphScene";
+import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera";
+import { GlyphMapControls } from "./GlyphMapControls";
 
 type MapControlsProps = {
   drag?: boolean;
@@ -18,8 +19,11 @@ function renderScene(
   const app = createApp({
     setup() {
       return () =>
-        h(GlyphcssScene, {}, {
-          default: () => h(GlyphcssMapControls, controlsProps),
+        h(GlyphPerspectiveCamera, {}, {
+          default: () =>
+            h(GlyphScene, {}, {
+              default: () => h(GlyphMapControls, controlsProps),
+            }),
         });
     },
   });
@@ -27,7 +31,7 @@ function renderScene(
   return { container, app };
 }
 
-describe("GlyphcssMapControls (Vue) — mount inside scene", () => {
+describe("GlyphMapControls (Vue) — mount inside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -40,7 +44,7 @@ describe("GlyphcssMapControls (Vue) — mount inside scene", () => {
   it("scene host is present after mounting map controls", async () => {
     const { container } = renderScene();
     await nextTick();
-    expect(container.querySelector(".glyphcss-host")).toBeTruthy();
+    expect(container.querySelector(".glyph-host")).toBeTruthy();
   });
 
   it("mounts with drag=false", () => {
@@ -68,18 +72,21 @@ describe("GlyphcssMapControls (Vue) — mount inside scene", () => {
     const app = createApp({
       setup() {
         return () =>
-          h(GlyphcssScene, {}, {
-            default: () => h(GlyphcssMapControls, { wheel: wheel.value }),
+          h(GlyphPerspectiveCamera, {}, {
+            default: () =>
+              h(GlyphScene, {}, {
+                default: () => h(GlyphMapControls, { wheel: wheel.value }),
+              }),
           });
       },
     });
     app.mount(container);
     await nextTick();
-    expect(container.querySelector(".glyphcss-scene")).toBeTruthy();
+    expect(container.querySelector(".glyph-scene")).toBeTruthy();
 
     wheel.value = false;
     await nextTick();
-    expect(container.querySelector(".glyphcss-scene")).toBeTruthy();
+    expect(container.querySelector(".glyph-scene")).toBeTruthy();
     app.unmount();
   });
 
@@ -87,21 +94,21 @@ describe("GlyphcssMapControls (Vue) — mount inside scene", () => {
     const { container, app } = renderScene();
     await nextTick();
     app.unmount();
-    expect(container.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(container.querySelector(".glyph-output")).toBeFalsy();
   });
 });
 
-describe("GlyphcssMapControls (Vue) — outside scene", () => {
+describe("GlyphMapControls (Vue) — outside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when mounted outside GlyphcssScene", () => {
+  it("throws when mounted outside GlyphScene", () => {
     const container = document.createElement("div");
     const app = createApp({
       setup() {
-        return () => h(GlyphcssMapControls, {});
+        return () => h(GlyphMapControls, {});
       },
     });
     expect(() => app.mount(container)).toThrow();
diff --git a/packages/vue/src/glyphcss/controls/GlyphcssMapControls.ts b/packages/vue/src/glyphcss/controls/GlyphMapControls.ts
similarity index 55%
rename from packages/vue/src/glyphcss/controls/GlyphcssMapControls.ts
rename to packages/vue/src/glyphcss/controls/GlyphMapControls.ts
index 141bb18e..04b49497 100644
--- a/packages/vue/src/glyphcss/controls/GlyphcssMapControls.ts
+++ b/packages/vue/src/glyphcss/controls/GlyphMapControls.ts
@@ -1,20 +1,20 @@
 /**
- * GlyphcssMapControls — Vue 3 map/pan controls for GlyphcssScene.
+ * GlyphMapControls — Vue 3 map/pan controls for GlyphScene.
  */
-import { defineComponent, inject, onMounted, onBeforeUnmount, watch, shallowRef } from "vue";
-import type { GlyphcssMapControlsHandle, GlyphcssMapControlsOptions } from "glyphcss";
-import { createGlyphcssMapControls } from "glyphcss";
-import { GlyphcssSceneContextKey } from "../scene/context";
+import { defineComponent, inject, onBeforeUnmount, watch, shallowRef, watchEffect } from "vue";
+import type { GlyphMapControlsHandle, GlyphMapControlsOptions } from "glyphcss";
+import { createGlyphMapControls } from "glyphcss";
+import { GlyphSceneContextKey } from "../scene/context";
 
-export interface GlyphcssMapControlsProps {
+export interface GlyphMapControlsProps {
   drag?: boolean;
   wheel?: boolean;
   invert?: boolean | number;
   animate?: false | { speed?: number; axis?: "x" | "y"; pauseOnInteraction?: boolean };
 }
 
-export const GlyphcssMapControls = defineComponent({
-  name: "GlyphcssMapControls",
+export const GlyphMapControls = defineComponent({
+  name: "GlyphMapControls",
   props: {
     drag: { type: Boolean, default: true },
     wheel: { type: Boolean, default: true },
@@ -22,26 +22,29 @@ export const GlyphcssMapControls = defineComponent({
     animate: { type: [Boolean, Object] as unknown as () => false | { speed?: number; axis?: "x" | "y"; pauseOnInteraction?: boolean }, default: false },
   },
   setup(props) {
-    const sceneCtx = inject(GlyphcssSceneContextKey);
+    const sceneCtx = inject(GlyphSceneContextKey);
     if (!sceneCtx) {
-      throw new Error("glyphcss: GlyphcssMapControls must be used inside a GlyphcssScene.");
+      throw new Error("glyphcss: GlyphMapControls must be used inside a GlyphScene.");
     }
     const { sceneRef } = sceneCtx;
-    const controlsRef = shallowRef(null);
+    const controlsRef = shallowRef(null);
 
-    onMounted(() => {
+    // In Vue 3, child onMounted hooks fire before parent onMounted, so
+    // sceneRef.value is null when this runs. Watch for the scene to appear.
+    const stopWatch = watchEffect(() => {
       const scene = sceneRef.value;
-      if (!scene) return;
-      const opts: GlyphcssMapControlsOptions = {
+      if (!scene || controlsRef.value) return;
+      const opts: GlyphMapControlsOptions = {
         drag: props.drag,
         wheel: props.wheel,
         invert: props.invert,
         animate: props.animate === false ? false : props.animate,
       };
-      controlsRef.value = createGlyphcssMapControls(scene, opts);
+      controlsRef.value = createGlyphMapControls(scene, opts);
     });
 
     onBeforeUnmount(() => {
+      stopWatch();
       controlsRef.value?.destroy();
       controlsRef.value = null;
     });
diff --git a/packages/vue/src/glyphcss/controls/GlyphcssOrbitControls.test.ts b/packages/vue/src/glyphcss/controls/GlyphOrbitControls.test.ts
similarity index 63%
rename from packages/vue/src/glyphcss/controls/GlyphcssOrbitControls.test.ts
rename to packages/vue/src/glyphcss/controls/GlyphOrbitControls.test.ts
index 7468b358..1d4f794f 100644
--- a/packages/vue/src/glyphcss/controls/GlyphcssOrbitControls.test.ts
+++ b/packages/vue/src/glyphcss/controls/GlyphOrbitControls.test.ts
@@ -1,7 +1,8 @@
 import { describe, it, expect, afterEach, vi } from "vitest";
 import { createApp, h, nextTick, ref } from "vue";
-import { GlyphcssScene } from "../scene/GlyphcssScene";
-import { GlyphcssOrbitControls } from "./GlyphcssOrbitControls";
+import { GlyphScene } from "../scene/GlyphScene";
+import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera";
+import { GlyphOrbitControls } from "./GlyphOrbitControls";
 
 type OrbitProps = {
   drag?: boolean;
@@ -18,8 +19,11 @@ function renderScene(
   const app = createApp({
     setup() {
       return () =>
-        h(GlyphcssScene, {}, {
-          default: () => h(GlyphcssOrbitControls, controlsProps),
+        h(GlyphPerspectiveCamera, {}, {
+          default: () =>
+            h(GlyphScene, {}, {
+              default: () => h(GlyphOrbitControls, controlsProps),
+            }),
         });
     },
   });
@@ -27,7 +31,7 @@ function renderScene(
   return { container, app };
 }
 
-describe("GlyphcssOrbitControls (Vue) — mount inside scene", () => {
+describe("GlyphOrbitControls (Vue) — mount inside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -40,7 +44,7 @@ describe("GlyphcssOrbitControls (Vue) — mount inside scene", () => {
   it("scene host is present after mounting controls", async () => {
     const { container } = renderScene();
     await nextTick();
-    expect(container.querySelector(".glyphcss-host")).toBeTruthy();
+    expect(container.querySelector(".glyph-host")).toBeTruthy();
   });
 
   it("mounts with drag=false", () => {
@@ -68,18 +72,21 @@ describe("GlyphcssOrbitControls (Vue) — mount inside scene", () => {
     const app = createApp({
       setup() {
         return () =>
-          h(GlyphcssScene, {}, {
-            default: () => h(GlyphcssOrbitControls, { drag: drag.value }),
+          h(GlyphPerspectiveCamera, {}, {
+            default: () =>
+              h(GlyphScene, {}, {
+                default: () => h(GlyphOrbitControls, { drag: drag.value }),
+              }),
           });
       },
     });
     app.mount(container);
     await nextTick();
-    expect(container.querySelector(".glyphcss-scene")).toBeTruthy();
+    expect(container.querySelector(".glyph-scene")).toBeTruthy();
 
     drag.value = false;
     await nextTick();
-    expect(container.querySelector(".glyphcss-scene")).toBeTruthy();
+    expect(container.querySelector(".glyph-scene")).toBeTruthy();
     app.unmount();
   });
 
@@ -87,7 +94,7 @@ describe("GlyphcssOrbitControls (Vue) — mount inside scene", () => {
     const { container, app } = renderScene();
     await nextTick();
     app.unmount();
-    expect(container.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(container.querySelector(".glyph-output")).toBeFalsy();
   });
 
   it("can be mounted and remounted without leaks", async () => {
@@ -95,39 +102,47 @@ describe("GlyphcssOrbitControls (Vue) — mount inside scene", () => {
     document.body.appendChild(c1);
     const a1 = createApp({
       setup() {
-        return () => h(GlyphcssScene, {}, { default: () => h(GlyphcssOrbitControls, {}) });
+        return () =>
+          h(GlyphPerspectiveCamera, {}, {
+            default: () =>
+              h(GlyphScene, {}, { default: () => h(GlyphOrbitControls, {}) }),
+          });
       },
     });
     a1.mount(c1);
     await nextTick();
     a1.unmount();
-    expect(c1.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(c1.querySelector(".glyph-output")).toBeFalsy();
 
     const c2 = document.createElement("div");
     document.body.appendChild(c2);
     const a2 = createApp({
       setup() {
-        return () => h(GlyphcssScene, {}, { default: () => h(GlyphcssOrbitControls, {}) });
+        return () =>
+          h(GlyphPerspectiveCamera, {}, {
+            default: () =>
+              h(GlyphScene, {}, { default: () => h(GlyphOrbitControls, {}) }),
+          });
       },
     });
     a2.mount(c2);
     await nextTick();
-    expect(c2.querySelector(".glyphcss-host")).toBeTruthy();
+    expect(c2.querySelector(".glyph-host")).toBeTruthy();
     a2.unmount();
   });
 });
 
-describe("GlyphcssOrbitControls (Vue) — outside scene", () => {
+describe("GlyphOrbitControls (Vue) — outside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when mounted outside GlyphcssScene", () => {
+  it("throws when mounted outside GlyphScene", () => {
     const container = document.createElement("div");
     const app = createApp({
       setup() {
-        return () => h(GlyphcssOrbitControls, {});
+        return () => h(GlyphOrbitControls, {});
       },
     });
     expect(() => app.mount(container)).toThrow();
diff --git a/packages/vue/src/glyphcss/controls/GlyphcssOrbitControls.ts b/packages/vue/src/glyphcss/controls/GlyphOrbitControls.ts
similarity index 54%
rename from packages/vue/src/glyphcss/controls/GlyphcssOrbitControls.ts
rename to packages/vue/src/glyphcss/controls/GlyphOrbitControls.ts
index bac5f8b9..f99bcaa9 100644
--- a/packages/vue/src/glyphcss/controls/GlyphcssOrbitControls.ts
+++ b/packages/vue/src/glyphcss/controls/GlyphOrbitControls.ts
@@ -1,20 +1,20 @@
 /**
- * GlyphcssOrbitControls — Vue 3 orbit controls for GlyphcssScene.
+ * GlyphOrbitControls — Vue 3 orbit controls for GlyphScene.
  */
-import { defineComponent, inject, onMounted, onBeforeUnmount, watch, shallowRef } from "vue";
-import type { GlyphcssOrbitControlsHandle, GlyphcssOrbitControlsOptions } from "glyphcss";
-import { createGlyphcssOrbitControls } from "glyphcss";
-import { GlyphcssSceneContextKey } from "../scene/context";
+import { defineComponent, inject, onBeforeUnmount, watch, shallowRef, watchEffect } from "vue";
+import type { GlyphOrbitControlsHandle, GlyphOrbitControlsOptions } from "glyphcss";
+import { createGlyphOrbitControls } from "glyphcss";
+import { GlyphSceneContextKey } from "../scene/context";
 
-export interface GlyphcssOrbitControlsProps {
+export interface GlyphOrbitControlsProps {
   drag?: boolean;
   wheel?: boolean;
   invert?: boolean | number;
   animate?: false | { speed?: number; axis?: "x" | "y"; pauseOnInteraction?: boolean };
 }
 
-export const GlyphcssOrbitControls = defineComponent({
-  name: "GlyphcssOrbitControls",
+export const GlyphOrbitControls = defineComponent({
+  name: "GlyphOrbitControls",
   props: {
     drag: { type: Boolean, default: true },
     wheel: { type: Boolean, default: true },
@@ -22,26 +22,29 @@ export const GlyphcssOrbitControls = defineComponent({
     animate: { type: [Boolean, Object] as unknown as () => false | { speed?: number; axis?: "x" | "y"; pauseOnInteraction?: boolean }, default: false },
   },
   setup(props) {
-    const sceneCtx = inject(GlyphcssSceneContextKey);
+    const sceneCtx = inject(GlyphSceneContextKey);
     if (!sceneCtx) {
-      throw new Error("glyphcss: GlyphcssOrbitControls must be used inside a GlyphcssScene.");
+      throw new Error("glyphcss: GlyphOrbitControls must be used inside a GlyphScene.");
     }
     const { sceneRef } = sceneCtx;
-    const controlsRef = shallowRef(null);
+    const controlsRef = shallowRef(null);
 
-    onMounted(() => {
+    // In Vue 3, child onMounted hooks fire before parent onMounted, so
+    // sceneRef.value is null when this runs. Watch for the scene to appear.
+    const stopWatch = watchEffect(() => {
       const scene = sceneRef.value;
-      if (!scene) return;
-      const opts: GlyphcssOrbitControlsOptions = {
+      if (!scene || controlsRef.value) return;
+      const opts: GlyphOrbitControlsOptions = {
         drag: props.drag,
         wheel: props.wheel,
         invert: props.invert,
         animate: props.animate === false ? false : props.animate,
       };
-      controlsRef.value = createGlyphcssOrbitControls(scene, opts);
+      controlsRef.value = createGlyphOrbitControls(scene, opts);
     });
 
     onBeforeUnmount(() => {
+      stopWatch();
       controlsRef.value?.destroy();
       controlsRef.value = null;
     });
diff --git a/packages/vue/src/glyphcss/controls/GlyphcssFirstPersonControls.ts b/packages/vue/src/glyphcss/controls/GlyphcssFirstPersonControls.ts
deleted file mode 100644
index 41705af8..00000000
--- a/packages/vue/src/glyphcss/controls/GlyphcssFirstPersonControls.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * GlyphcssFirstPersonControls — Vue 3 first-person controls for GlyphcssScene.
- */
-import { defineComponent, inject, onMounted, onBeforeUnmount, watch, shallowRef } from "vue";
-import type { GlyphcssFirstPersonControlsHandle, GlyphcssFirstPersonControlsOptions } from "glyphcss";
-import { createGlyphcssFirstPersonControls } from "glyphcss";
-import { GlyphcssSceneContextKey } from "../scene/context";
-
-export interface GlyphcssFirstPersonControlsProps {
-  drag?: boolean;
-  keyboard?: boolean;
-  moveSpeed?: number;
-  lookSpeed?: number;
-  invert?: boolean | number;
-}
-
-export const GlyphcssFirstPersonControls = defineComponent({
-  name: "GlyphcssFirstPersonControls",
-  props: {
-    drag: { type: Boolean, default: true },
-    keyboard: { type: Boolean, default: true },
-    moveSpeed: { type: Number, default: 0.05 },
-    lookSpeed: { type: Number, default: 0.004 },
-    invert: { type: [Boolean, Number] as unknown as () => boolean | number, default: false },
-  },
-  setup(props) {
-    const sceneCtx = inject(GlyphcssSceneContextKey);
-    if (!sceneCtx) {
-      throw new Error("glyphcss: GlyphcssFirstPersonControls must be used inside a GlyphcssScene.");
-    }
-    const { sceneRef } = sceneCtx;
-    const controlsRef = shallowRef(null);
-
-    onMounted(() => {
-      const scene = sceneRef.value;
-      if (!scene) return;
-      const opts: GlyphcssFirstPersonControlsOptions = {
-        drag: props.drag,
-        keyboard: props.keyboard,
-        moveSpeed: props.moveSpeed,
-        lookSpeed: props.lookSpeed,
-        invert: props.invert,
-      };
-      controlsRef.value = createGlyphcssFirstPersonControls(scene, opts);
-    });
-
-    onBeforeUnmount(() => {
-      controlsRef.value?.destroy();
-      controlsRef.value = null;
-    });
-
-    watch(
-      () => ({ drag: props.drag, keyboard: props.keyboard, moveSpeed: props.moveSpeed, lookSpeed: props.lookSpeed, invert: props.invert }),
-      (next) => {
-        controlsRef.value?.update(next);
-      },
-    );
-
-    return () => null;
-  },
-});
diff --git a/packages/vue/src/glyphcss/controls/index.ts b/packages/vue/src/glyphcss/controls/index.ts
index 34008e2e..9d249651 100644
--- a/packages/vue/src/glyphcss/controls/index.ts
+++ b/packages/vue/src/glyphcss/controls/index.ts
@@ -1,6 +1,6 @@
-export { GlyphcssOrbitControls } from "./GlyphcssOrbitControls";
-export type { GlyphcssOrbitControlsProps } from "./GlyphcssOrbitControls";
-export { GlyphcssMapControls } from "./GlyphcssMapControls";
-export type { GlyphcssMapControlsProps } from "./GlyphcssMapControls";
-export { GlyphcssFirstPersonControls } from "./GlyphcssFirstPersonControls";
-export type { GlyphcssFirstPersonControlsProps } from "./GlyphcssFirstPersonControls";
+export { GlyphOrbitControls } from "./GlyphOrbitControls";
+export type { GlyphOrbitControlsProps } from "./GlyphOrbitControls";
+export { GlyphMapControls } from "./GlyphMapControls";
+export type { GlyphMapControlsProps } from "./GlyphMapControls";
+export { GlyphFirstPersonControls } from "./GlyphFirstPersonControls";
+export type { GlyphFirstPersonControlsProps } from "./GlyphFirstPersonControls";
diff --git a/packages/vue/src/glyphcss/helpers/GlyphcssAxesHelper.test.ts b/packages/vue/src/glyphcss/helpers/GlyphAxesHelper.test.ts
similarity index 61%
rename from packages/vue/src/glyphcss/helpers/GlyphcssAxesHelper.test.ts
rename to packages/vue/src/glyphcss/helpers/GlyphAxesHelper.test.ts
index c7801020..4d4eadd1 100644
--- a/packages/vue/src/glyphcss/helpers/GlyphcssAxesHelper.test.ts
+++ b/packages/vue/src/glyphcss/helpers/GlyphAxesHelper.test.ts
@@ -1,7 +1,8 @@
 import { describe, it, expect, afterEach, vi } from "vitest";
 import { createApp, h, nextTick, ref } from "vue";
-import { GlyphcssScene } from "../scene/GlyphcssScene";
-import { GlyphcssAxesHelper } from "./GlyphcssAxesHelper";
+import { GlyphScene } from "../scene/GlyphScene";
+import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera";
+import { GlyphAxesHelper } from "./GlyphAxesHelper";
 
 function renderScene(
   helperProps: { size?: number } = {},
@@ -11,8 +12,11 @@ function renderScene(
   const app = createApp({
     setup() {
       return () =>
-        h(GlyphcssScene, {}, {
-          default: () => h(GlyphcssAxesHelper, helperProps),
+        h(GlyphPerspectiveCamera, {}, {
+          default: () =>
+            h(GlyphScene, {}, {
+              default: () => h(GlyphAxesHelper, helperProps),
+            }),
         });
     },
   });
@@ -20,7 +24,7 @@ function renderScene(
   return { container, app };
 }
 
-describe("GlyphcssAxesHelper (Vue) — mount inside scene", () => {
+describe("GlyphAxesHelper (Vue) — mount inside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -33,13 +37,13 @@ describe("GlyphcssAxesHelper (Vue) — mount inside scene", () => {
   it("scene host is present after mounting axes helper", async () => {
     const { container } = renderScene();
     await nextTick();
-    expect(container.querySelector(".glyphcss-host")).toBeTruthy();
+    expect(container.querySelector(".glyph-host")).toBeTruthy();
   });
 
   it("scene output 
 is present after mounting axes helper", async () => {
     const { container } = renderScene();
     await nextTick();
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
   });
 
   it("accepts size=2 without throwing", () => {
@@ -61,18 +65,21 @@ describe("GlyphcssAxesHelper (Vue) — mount inside scene", () => {
     const app = createApp({
       setup() {
         return () =>
-          h(GlyphcssScene, {}, {
-            default: () => h(GlyphcssAxesHelper, { size: size.value }),
+          h(GlyphPerspectiveCamera, {}, {
+            default: () =>
+              h(GlyphScene, {}, {
+                default: () => h(GlyphAxesHelper, { size: size.value }),
+              }),
           });
       },
     });
     app.mount(container);
     await nextTick();
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
 
     size.value = 3;
     await nextTick();
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
     app.unmount();
   });
 
@@ -80,7 +87,7 @@ describe("GlyphcssAxesHelper (Vue) — mount inside scene", () => {
     const { container, app } = renderScene({ size: 1 });
     await nextTick();
     app.unmount();
-    expect(container.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(container.querySelector(".glyph-output")).toBeFalsy();
   });
 
   it("can be mounted and remounted without leaks", async () => {
@@ -88,39 +95,47 @@ describe("GlyphcssAxesHelper (Vue) — mount inside scene", () => {
     document.body.appendChild(c1);
     const a1 = createApp({
       setup() {
-        return () => h(GlyphcssScene, {}, { default: () => h(GlyphcssAxesHelper, {}) });
+        return () =>
+          h(GlyphPerspectiveCamera, {}, {
+            default: () =>
+              h(GlyphScene, {}, { default: () => h(GlyphAxesHelper, {}) }),
+          });
       },
     });
     a1.mount(c1);
     await nextTick();
     a1.unmount();
-    expect(c1.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(c1.querySelector(".glyph-output")).toBeFalsy();
 
     const c2 = document.createElement("div");
     document.body.appendChild(c2);
     const a2 = createApp({
       setup() {
-        return () => h(GlyphcssScene, {}, { default: () => h(GlyphcssAxesHelper, { size: 2 }) });
+        return () =>
+          h(GlyphPerspectiveCamera, {}, {
+            default: () =>
+              h(GlyphScene, {}, { default: () => h(GlyphAxesHelper, { size: 2 }) }),
+          });
       },
     });
     a2.mount(c2);
     await nextTick();
-    expect(c2.querySelector(".glyphcss-host")).toBeTruthy();
+    expect(c2.querySelector(".glyph-host")).toBeTruthy();
     a2.unmount();
   });
 });
 
-describe("GlyphcssAxesHelper (Vue) — outside scene", () => {
+describe("GlyphAxesHelper (Vue) — outside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when mounted outside GlyphcssScene", () => {
+  it("throws when mounted outside GlyphScene", () => {
     const container = document.createElement("div");
     const app = createApp({
       setup() {
-        return () => h(GlyphcssAxesHelper, {});
+        return () => h(GlyphAxesHelper, {});
       },
     });
     expect(() => app.mount(container)).toThrow();
diff --git a/packages/vue/src/glyphcss/helpers/GlyphcssAxesHelper.ts b/packages/vue/src/glyphcss/helpers/GlyphAxesHelper.ts
similarity index 74%
rename from packages/vue/src/glyphcss/helpers/GlyphcssAxesHelper.ts
rename to packages/vue/src/glyphcss/helpers/GlyphAxesHelper.ts
index b16c40e1..69a6e6a1 100644
--- a/packages/vue/src/glyphcss/helpers/GlyphcssAxesHelper.ts
+++ b/packages/vue/src/glyphcss/helpers/GlyphAxesHelper.ts
@@ -1,12 +1,12 @@
 /**
- * GlyphcssAxesHelper — Vue 3 ASCII-mode axes helper.
+ * GlyphAxesHelper — Vue 3 ASCII-mode axes helper.
  */
 import { defineComponent, inject, onMounted, onBeforeUnmount, watch, computed, shallowRef } from "vue";
-import type { GlyphcssMeshHandle } from "glyphcss";
+import type { GlyphMeshHandle } from "glyphcss";
 import type { Vec3, Polygon } from "@glyphcss/core";
-import { GlyphcssSceneContextKey } from "../scene/context";
+import { GlyphSceneContextKey } from "../scene/context";
 
-export interface GlyphcssAxesHelperProps {
+export interface GlyphAxesHelperProps {
   size?: number;
 }
 
@@ -28,18 +28,18 @@ function axisPolygons(size: number): Polygon[] {
   return polygons;
 }
 
-export const GlyphcssAxesHelper = defineComponent({
-  name: "GlyphcssAxesHelper",
+export const GlyphAxesHelper = defineComponent({
+  name: "GlyphAxesHelper",
   props: {
     size: { type: Number, default: 1 },
   },
   setup(props) {
-    const sceneCtx = inject(GlyphcssSceneContextKey);
+    const sceneCtx = inject(GlyphSceneContextKey);
     if (!sceneCtx) {
-      throw new Error("glyphcss: GlyphcssAxesHelper must be used inside a GlyphcssScene.");
+      throw new Error("glyphcss: GlyphAxesHelper must be used inside a GlyphScene.");
     }
     const { sceneRef } = sceneCtx;
-    const meshRef = shallowRef(null);
+    const meshRef = shallowRef(null);
 
     const polygons = computed(() => axisPolygons(props.size ?? 1));
 
diff --git a/packages/vue/src/glyphcss/helpers/GlyphcssDirectionalLightHelper.test.ts b/packages/vue/src/glyphcss/helpers/GlyphDirectionalLightHelper.test.ts
similarity index 67%
rename from packages/vue/src/glyphcss/helpers/GlyphcssDirectionalLightHelper.test.ts
rename to packages/vue/src/glyphcss/helpers/GlyphDirectionalLightHelper.test.ts
index b2db2e4a..2dd23d8f 100644
--- a/packages/vue/src/glyphcss/helpers/GlyphcssDirectionalLightHelper.test.ts
+++ b/packages/vue/src/glyphcss/helpers/GlyphDirectionalLightHelper.test.ts
@@ -1,7 +1,8 @@
 import { describe, it, expect, afterEach, vi } from "vitest";
 import { createApp, h, nextTick, ref } from "vue";
-import { GlyphcssScene } from "../scene/GlyphcssScene";
-import { GlyphcssDirectionalLightHelper } from "./GlyphcssDirectionalLightHelper";
+import { GlyphScene } from "../scene/GlyphScene";
+import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera";
+import { GlyphDirectionalLightHelper } from "./GlyphDirectionalLightHelper";
 import type { Vec3 } from "@glyphcss/core";
 
 type LightHelperProps = {
@@ -18,8 +19,11 @@ function renderScene(
   const app = createApp({
     setup() {
       return () =>
-        h(GlyphcssScene, {}, {
-          default: () => h(GlyphcssDirectionalLightHelper, helperProps),
+        h(GlyphPerspectiveCamera, {}, {
+          default: () =>
+            h(GlyphScene, {}, {
+              default: () => h(GlyphDirectionalLightHelper, helperProps),
+            }),
         });
     },
   });
@@ -27,7 +31,7 @@ function renderScene(
   return { container, app };
 }
 
-describe("GlyphcssDirectionalLightHelper (Vue) — mount inside scene", () => {
+describe("GlyphDirectionalLightHelper (Vue) — mount inside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -40,13 +44,13 @@ describe("GlyphcssDirectionalLightHelper (Vue) — mount inside scene", () => {
   it("scene host is present after mounting light helper", async () => {
     const { container } = renderScene();
     await nextTick();
-    expect(container.querySelector(".glyphcss-host")).toBeTruthy();
+    expect(container.querySelector(".glyph-host")).toBeTruthy();
   });
 
   it("scene output 
 is present after mounting light helper", async () => {
     const { container } = renderScene();
     await nextTick();
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
   });
 
   it("accepts custom position", () => {
@@ -74,19 +78,22 @@ describe("GlyphcssDirectionalLightHelper (Vue) — mount inside scene", () => {
     const app = createApp({
       setup() {
         return () =>
-          h(GlyphcssScene, {}, {
+          h(GlyphPerspectiveCamera, {}, {
             default: () =>
-              h(GlyphcssDirectionalLightHelper, { position: position.value }),
+              h(GlyphScene, {}, {
+                default: () =>
+                  h(GlyphDirectionalLightHelper, { position: position.value }),
+              }),
           });
       },
     });
     app.mount(container);
     await nextTick();
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
 
     position.value = [2, 2, 2];
     await nextTick();
-    expect(container.querySelector(".glyphcss-output")).toBeTruthy();
+    expect(container.querySelector(".glyph-output")).toBeTruthy();
     app.unmount();
   });
 
@@ -94,21 +101,21 @@ describe("GlyphcssDirectionalLightHelper (Vue) — mount inside scene", () => {
     const { container, app } = renderScene({ position: [1, 1, 1] });
     await nextTick();
     app.unmount();
-    expect(container.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(container.querySelector(".glyph-output")).toBeFalsy();
   });
 });
 
-describe("GlyphcssDirectionalLightHelper (Vue) — outside scene", () => {
+describe("GlyphDirectionalLightHelper (Vue) — outside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when mounted outside GlyphcssScene", () => {
+  it("throws when mounted outside GlyphScene", () => {
     const container = document.createElement("div");
     const app = createApp({
       setup() {
-        return () => h(GlyphcssDirectionalLightHelper, {});
+        return () => h(GlyphDirectionalLightHelper, {});
       },
     });
     expect(() => app.mount(container)).toThrow();
diff --git a/packages/vue/src/glyphcss/helpers/GlyphcssDirectionalLightHelper.ts b/packages/vue/src/glyphcss/helpers/GlyphDirectionalLightHelper.ts
similarity index 75%
rename from packages/vue/src/glyphcss/helpers/GlyphcssDirectionalLightHelper.ts
rename to packages/vue/src/glyphcss/helpers/GlyphDirectionalLightHelper.ts
index 33eaedd4..e9ce1f5a 100644
--- a/packages/vue/src/glyphcss/helpers/GlyphcssDirectionalLightHelper.ts
+++ b/packages/vue/src/glyphcss/helpers/GlyphDirectionalLightHelper.ts
@@ -1,13 +1,13 @@
 /**
- * GlyphcssDirectionalLightHelper — Vue 3 directional light helper for the ASCII backend.
+ * GlyphDirectionalLightHelper — Vue 3 directional light helper for the ASCII backend.
  */
 import { defineComponent, inject, onMounted, onBeforeUnmount, watch, computed, shallowRef } from "vue";
 import type { PropType } from "vue";
 import type { Vec3, Polygon } from "@glyphcss/core";
-import type { GlyphcssMeshHandle } from "glyphcss";
-import { GlyphcssSceneContextKey } from "../scene/context";
+import type { GlyphMeshHandle } from "glyphcss";
+import { GlyphSceneContextKey } from "../scene/context";
 
-export interface GlyphcssDirectionalLightHelperProps {
+export interface GlyphDirectionalLightHelperProps {
   position?: Vec3;
   color?: string;
   size?: number;
@@ -34,20 +34,20 @@ function lightMarkerPolygons(position: Vec3, color: string, size: number): Polyg
   ];
 }
 
-export const GlyphcssDirectionalLightHelper = defineComponent({
-  name: "GlyphcssDirectionalLightHelper",
+export const GlyphDirectionalLightHelper = defineComponent({
+  name: "GlyphDirectionalLightHelper",
   props: {
     position: { type: Array as unknown as PropType, default: () => [1, 1, 1] },
     color: { type: String, default: "#ffff00" },
     size: { type: Number, default: 0.1 },
   },
   setup(props) {
-    const sceneCtx = inject(GlyphcssSceneContextKey);
+    const sceneCtx = inject(GlyphSceneContextKey);
     if (!sceneCtx) {
-      throw new Error("glyphcss: GlyphcssDirectionalLightHelper must be used inside a GlyphcssScene.");
+      throw new Error("glyphcss: GlyphDirectionalLightHelper must be used inside a GlyphScene.");
     }
     const { sceneRef } = sceneCtx;
-    const meshRef = shallowRef(null);
+    const meshRef = shallowRef(null);
 
     const polygons = computed(() =>
       lightMarkerPolygons(props.position ?? [1, 1, 1], props.color ?? "#ffff00", props.size ?? 0.1),
diff --git a/packages/vue/src/glyphcss/helpers/index.ts b/packages/vue/src/glyphcss/helpers/index.ts
index b8e244cf..91ae8517 100644
--- a/packages/vue/src/glyphcss/helpers/index.ts
+++ b/packages/vue/src/glyphcss/helpers/index.ts
@@ -1,4 +1,4 @@
-export { GlyphcssAxesHelper } from "./GlyphcssAxesHelper";
-export type { GlyphcssAxesHelperProps } from "./GlyphcssAxesHelper";
-export { GlyphcssDirectionalLightHelper } from "./GlyphcssDirectionalLightHelper";
-export type { GlyphcssDirectionalLightHelperProps } from "./GlyphcssDirectionalLightHelper";
+export { GlyphAxesHelper } from "./GlyphAxesHelper";
+export type { GlyphAxesHelperProps } from "./GlyphAxesHelper";
+export { GlyphDirectionalLightHelper } from "./GlyphDirectionalLightHelper";
+export type { GlyphDirectionalLightHelperProps } from "./GlyphDirectionalLightHelper";
diff --git a/packages/vue/src/glyphcss/index.ts b/packages/vue/src/glyphcss/index.ts
index b1298d11..ae200b66 100644
--- a/packages/vue/src/glyphcss/index.ts
+++ b/packages/vue/src/glyphcss/index.ts
@@ -1,40 +1,40 @@
 // ── Scene ───────────────────────────────────────────────────────────────────
-export { GlyphcssScene, GlyphcssMesh, GlyphcssGround, GlyphcssHotspot, GlyphcssSceneContextKey, useGlyphcssSceneContext, findGlyphcssMeshHandle, pointInMeshElement, findMeshUnderPoint } from "./scene";
+export { GlyphScene, GlyphMesh, GlyphGround, GlyphHotspot, GlyphSceneContextKey, useGlyphSceneContext, findGlyphMeshHandle, pointInMeshElement, findMeshUnderPoint } from "./scene";
 export type {
-  GlyphcssSceneProps,
-  GlyphcssMeshProps,
-  GlyphcssGroundProps,
-  GlyphcssHotspotProps,
-  GlyphcssSceneContextValue,
+  GlyphSceneProps,
+  GlyphMeshProps,
+  GlyphGroundProps,
+  GlyphHotspotProps,
+  GlyphSceneContextValue,
 } from "./scene";
 
 // ── Camera ──────────────────────────────────────────────────────────────────
-export { GlyphcssCamera, GlyphcssPerspectiveCamera, GlyphcssOrthographicCamera, GlyphcssCameraContextKey, useGlyphcssCamera } from "./camera";
+export { GlyphCamera, GlyphPerspectiveCamera, GlyphOrthographicCamera, GlyphCameraContextKey, useGlyphCamera } from "./camera";
 export type {
-  GlyphcssCameraProps,
-  GlyphcssPerspectiveCameraProps,
-  GlyphcssOrthographicCameraProps,
-  GlyphcssCameraContextValue,
+  GlyphCameraProps,
+  GlyphPerspectiveCameraProps,
+  GlyphOrthographicCameraProps,
+  GlyphCameraContextValue,
 } from "./camera";
 
 // ── Controls ────────────────────────────────────────────────────────────────
-export { GlyphcssOrbitControls, GlyphcssMapControls, GlyphcssFirstPersonControls } from "./controls";
+export { GlyphOrbitControls, GlyphMapControls, GlyphFirstPersonControls } from "./controls";
 export type {
-  GlyphcssOrbitControlsProps,
-  GlyphcssMapControlsProps,
-  GlyphcssFirstPersonControlsProps,
+  GlyphOrbitControlsProps,
+  GlyphMapControlsProps,
+  GlyphFirstPersonControlsProps,
 } from "./controls";
 
 // ── Helpers ─────────────────────────────────────────────────────────────────
-export { GlyphcssAxesHelper, GlyphcssDirectionalLightHelper } from "./helpers";
+export { GlyphAxesHelper, GlyphDirectionalLightHelper } from "./helpers";
 export type {
-  GlyphcssAxesHelperProps,
-  GlyphcssDirectionalLightHelperProps,
+  GlyphAxesHelperProps,
+  GlyphDirectionalLightHelperProps,
 } from "./helpers";
 
 // ── Styles ──────────────────────────────────────────────────────────────────
-export { injectGlyphcssBaseStyles } from "./styles";
+export { injectGlyphBaseStyles } from "./styles";
 
 // ── Animation ───────────────────────────────────────────────────────────────
-export { useGlyphcssAnimation } from "./animation/useGlyphcssAnimation";
-export type { UseGlyphcssAnimationResultVue } from "./animation/useGlyphcssAnimation";
+export { useGlyphAnimation } from "./animation/useGlyphAnimation";
+export type { UseGlyphAnimationResultVue } from "./animation/useGlyphAnimation";
diff --git a/packages/vue/src/glyphcss/scene/GlyphcssGround.test.ts b/packages/vue/src/glyphcss/scene/GlyphGround.test.ts
similarity index 68%
rename from packages/vue/src/glyphcss/scene/GlyphcssGround.test.ts
rename to packages/vue/src/glyphcss/scene/GlyphGround.test.ts
index 9e9b3215..38e4ffae 100644
--- a/packages/vue/src/glyphcss/scene/GlyphcssGround.test.ts
+++ b/packages/vue/src/glyphcss/scene/GlyphGround.test.ts
@@ -1,7 +1,8 @@
 import { describe, it, expect, afterEach, vi } from "vitest";
 import { createApp, h, nextTick } from "vue";
-import { GlyphcssScene } from "./GlyphcssScene";
-import { GlyphcssGround } from "./GlyphcssGround";
+import { GlyphScene } from "./GlyphScene";
+import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera";
+import { GlyphGround } from "./GlyphGround";
 
 function renderInScene(
   groundProps: Record = {},
@@ -11,14 +12,17 @@ function renderInScene(
   const app = createApp({
     setup() {
       return () =>
-        h(GlyphcssScene, {}, { default: () => h(GlyphcssGround, groundProps) });
+        h(GlyphPerspectiveCamera, {}, {
+          default: () =>
+            h(GlyphScene, {}, { default: () => h(GlyphGround, groundProps) }),
+        });
     },
   });
   app.mount(container);
   return { container, app };
 }
 
-describe("GlyphcssGround (Vue) — mounts inside scene", () => {
+describe("GlyphGround (Vue) — mounts inside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -29,10 +33,10 @@ describe("GlyphcssGround (Vue) — mounts inside scene", () => {
     await nextTick();
   });
 
-  it("renders a .glyphcss-mesh wrapper inside the scene", async () => {
+  it("renders a .glyph-mesh wrapper inside the scene", async () => {
     const { container } = renderInScene();
     await nextTick();
-    expect(container.querySelector(".glyphcss-mesh")).toBeTruthy();
+    expect(container.querySelector(".glyph-mesh")).toBeTruthy();
   });
 
   it("accepts size prop without throwing", async () => {
@@ -55,25 +59,25 @@ describe("GlyphcssGround (Vue) — mounts inside scene", () => {
     await nextTick();
   });
 
-  it("sets data-glyphcss-mesh-id when id is provided", async () => {
+  it("sets data-glyph-mesh-id when id is provided", async () => {
     const { container } = renderInScene({ id: "ground-plane" });
     await nextTick();
-    const mesh = container.querySelector("[data-glyphcss-mesh-id='ground-plane']");
+    const mesh = container.querySelector("[data-glyph-mesh-id='ground-plane']");
     expect(mesh).toBeTruthy();
   });
 });
 
-describe("GlyphcssGround (Vue) — throws outside scene", () => {
+describe("GlyphGround (Vue) — throws outside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when mounted outside GlyphcssScene", () => {
+  it("throws when mounted outside GlyphScene", () => {
     const container = document.createElement("div");
     const app = createApp({
       setup() {
-        return () => h(GlyphcssGround, {});
+        return () => h(GlyphGround, {});
       },
     });
     expect(() => app.mount(container)).toThrow();
diff --git a/packages/vue/src/glyphcss/scene/GlyphcssGround.ts b/packages/vue/src/glyphcss/scene/GlyphGround.ts
similarity index 82%
rename from packages/vue/src/glyphcss/scene/GlyphcssGround.ts
rename to packages/vue/src/glyphcss/scene/GlyphGround.ts
index 8d51ece9..a84230fa 100644
--- a/packages/vue/src/glyphcss/scene/GlyphcssGround.ts
+++ b/packages/vue/src/glyphcss/scene/GlyphGround.ts
@@ -1,6 +1,6 @@
 /**
- * GlyphcssGround — Vue 3 convenience wrapper around `planePolygons` that
- * registers a horizontal ground plane with the parent GlyphcssScene.
+ * GlyphGround — Vue 3 convenience wrapper around `planePolygons` that
+ * registers a horizontal ground plane with the parent GlyphScene.
  *
  * Mirrors voxcss's `` component prop surface.
  */
@@ -8,9 +8,9 @@ import { defineComponent, h, computed } from "vue";
 import type { PropType } from "vue";
 import type { Vec3 } from "@glyphcss/core";
 import { planePolygons } from "@glyphcss/core";
-import { GlyphcssMesh } from "./GlyphcssMesh";
+import { GlyphMesh } from "./GlyphMesh";
 
-export interface GlyphcssGroundProps {
+export interface GlyphGroundProps {
   /** Half-extent of the ground plane in world units. Default 5. */
   size?: number;
   /** Fill color. Default "#444444". */
@@ -24,8 +24,8 @@ export interface GlyphcssGroundProps {
   class?: string;
 }
 
-export const GlyphcssGround = defineComponent({
-  name: "GlyphcssGround",
+export const GlyphGround = defineComponent({
+  name: "GlyphGround",
   props: {
     size: { type: Number, default: 5 },
     color: { type: String, default: "#444444" },
@@ -47,7 +47,7 @@ export const GlyphcssGround = defineComponent({
 
     return () =>
       h(
-        GlyphcssMesh,
+        GlyphMesh,
         {
           id: props.id,
           polygons: polygons.value,
diff --git a/packages/vue/src/glyphcss/scene/GlyphcssHotspot.test.ts b/packages/vue/src/glyphcss/scene/GlyphHotspot.test.ts
similarity index 58%
rename from packages/vue/src/glyphcss/scene/GlyphcssHotspot.test.ts
rename to packages/vue/src/glyphcss/scene/GlyphHotspot.test.ts
index a8003dfc..4adc6883 100644
--- a/packages/vue/src/glyphcss/scene/GlyphcssHotspot.test.ts
+++ b/packages/vue/src/glyphcss/scene/GlyphHotspot.test.ts
@@ -1,8 +1,9 @@
 import { describe, it, expect, afterEach, vi } from "vitest";
 import { createApp, h, nextTick } from "vue";
 import type { VNode } from "vue";
-import { GlyphcssScene } from "./GlyphcssScene";
-import { GlyphcssHotspot } from "./GlyphcssHotspot";
+import { GlyphScene } from "./GlyphScene";
+import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera";
+import { GlyphHotspot } from "./GlyphHotspot";
 
 type HotspotProps = {
   id: string;
@@ -19,9 +20,12 @@ function renderScene(
   const app = createApp({
     setup() {
       return () =>
-        h(GlyphcssScene, {}, {
+        h(GlyphPerspectiveCamera, {}, {
           default: () =>
-            h(GlyphcssHotspot, hotspotProps, slotChildren ? { default: slotChildren } : undefined),
+            h(GlyphScene, {}, {
+              default: () =>
+                h(GlyphHotspot, hotspotProps, slotChildren ? { default: slotChildren } : undefined),
+            }),
         });
     },
   });
@@ -29,7 +33,7 @@ function renderScene(
   return { container, app };
 }
 
-describe("GlyphcssHotspot (Vue) — mount inside scene (no children)", () => {
+describe("GlyphHotspot (Vue) — mount inside scene (no children)", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -42,16 +46,16 @@ describe("GlyphcssHotspot (Vue) — mount inside scene (no children)", () => {
   it("scene host is present after mounting hotspot", async () => {
     const { container } = renderScene({ id: "hs1", at: [0, 0, 0] });
     await nextTick();
-    expect(container.querySelector(".glyphcss-host")).toBeTruthy();
+    expect(container.querySelector(".glyph-host")).toBeTruthy();
   });
 
   it("renders null (no DOM node) when no children", async () => {
     const { container } = renderScene({ id: "hs1", at: [0, 0, 0] });
     await nextTick();
-    // GlyphcssHotspot returns null in Vue when it has no slot children
+    // GlyphHotspot returns null in Vue when it has no slot children
     // (nothing is rendered into the component's own slot area)
     // The test ensures it doesn't crash and the scene is still functional
-    expect(container.querySelector(".glyphcss-scene")).toBeTruthy();
+    expect(container.querySelector(".glyph-scene")).toBeTruthy();
   });
 
   it("accepts a size prop without throwing", () => {
@@ -64,37 +68,37 @@ describe("GlyphcssHotspot (Vue) — mount inside scene (no children)", () => {
     const { container, app } = renderScene({ id: "hs1", at: [0, 0, 0] });
     await nextTick();
     app.unmount();
-    expect(container.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(container.querySelector(".glyph-output")).toBeFalsy();
   });
 });
 
-describe("GlyphcssHotspot (Vue) — mount inside scene (with slot children)", () => {
+describe("GlyphHotspot (Vue) — mount inside scene (with slot children)", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("mounts with slot children without throwing", () => {
-    // GlyphcssHotspot in Vue returns null from its render function — it
-    // registers with the scene imperatively and does not render slot children
-    // into its own DOM subtree. This is the expected Vue idiom.
-    expect(() =>
-      renderScene(
-        { id: "hs-slot", at: [0, 1, 0] },
-        () => h("span", { class: "tooltip" }, "hello"),
-      ),
-    ).not.toThrow();
+  it("teleports slot children into the hotspot overlay element", async () => {
+    const { container } = renderScene(
+      { id: "hs-slot", at: [0, 1, 0] },
+      () => h("span", { class: "tooltip" }, "hello"),
+    );
+    await nextTick();
+    // Children are teleported into the div.glyph-hotspot[data-hotspot-id] overlay.
+    const overlay = container.querySelector("[data-hotspot-id='hs-slot']");
+    expect(overlay).toBeTruthy();
+    expect(overlay?.querySelector(".tooltip")).toBeTruthy();
   });
 
-  it("scene is still rendered when slot children are provided", async () => {
+  it("renders slot content inside the hotspot overlay", async () => {
     const { container } = renderScene(
       { id: "hs-slot2", at: [0, 1, 0] },
       () => h("span", { class: "tooltip-inner" }, "world"),
     );
     await nextTick();
-    // GlyphcssHotspot renders null — slot content is not projected into its DOM.
-    // The scene itself must still be functional.
-    expect(container.querySelector(".glyphcss-scene")).toBeTruthy();
+    const tooltip = container.querySelector(".tooltip-inner");
+    expect(tooltip).toBeTruthy();
+    expect(tooltip?.textContent).toBe("world");
   });
 
   it("unmounts cleanly when slot children are provided", async () => {
@@ -104,21 +108,21 @@ describe("GlyphcssHotspot (Vue) — mount inside scene (with slot children)", ()
     );
     await nextTick();
     app.unmount();
-    expect(container.querySelector(".glyphcss-output")).toBeFalsy();
+    expect(container.querySelector(".glyph-output")).toBeFalsy();
   });
 });
 
-describe("GlyphcssHotspot (Vue) — outside scene", () => {
+describe("GlyphHotspot (Vue) — outside scene", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("throws when mounted outside GlyphcssScene", () => {
+  it("throws when mounted outside GlyphScene", () => {
     const container = document.createElement("div");
     const app = createApp({
       setup() {
-        return () => h(GlyphcssHotspot, { id: "err", at: [0, 0, 0] });
+        return () => h(GlyphHotspot, { id: "err", at: [0, 0, 0] });
       },
     });
     expect(() => app.mount(container)).toThrow();
diff --git a/packages/vue/src/glyphcss/scene/GlyphHotspot.ts b/packages/vue/src/glyphcss/scene/GlyphHotspot.ts
new file mode 100644
index 00000000..9a017148
--- /dev/null
+++ b/packages/vue/src/glyphcss/scene/GlyphHotspot.ts
@@ -0,0 +1,82 @@
+/**
+ * GlyphHotspot — Vue 3 wrapper for scene.addHotspot().
+ * Mirrors React's GlyphHotspot.
+ *
+ * Children are rendered via Vue Teleport into the absolutely-positioned
+ * overlay div so they track the hotspot as the camera moves.
+ */
+import { defineComponent, inject, onBeforeUnmount, watch, shallowRef, watchEffect, h, Teleport } from "vue";
+import type { PropType } from "vue";
+import type { Vec3 } from "@glyphcss/core";
+import type { GlyphHotspotHandle } from "glyphcss";
+import { GlyphSceneContextKey } from "./context";
+
+export interface GlyphHotspotProps {
+  id: string;
+  at: Vec3;
+  size?: [number, number];
+}
+
+export const GlyphHotspot = defineComponent({
+  name: "GlyphHotspot",
+  props: {
+    id: { type: String, required: true },
+    at: { type: Array as unknown as PropType, required: true },
+    size: { type: Array as unknown as PropType<[number, number]>, default: undefined },
+  },
+  emits: ["click"],
+  setup(props, { emit, slots }) {
+    const ctx = inject(GlyphSceneContextKey);
+    if (!ctx) {
+      throw new Error("glyphcss: GlyphHotspot must be used inside a GlyphScene.");
+    }
+    const { sceneRef } = ctx;
+    const hotspotRef = shallowRef(null);
+    // Track the overlay DOM element so we can teleport children into it.
+    const overlayEl = shallowRef(null);
+
+    function register(): void {
+      const scene = sceneRef.value;
+      if (!scene) return;
+      const handle = scene.addHotspot(
+        { id: props.id, at: props.at, size: props.size },
+        () => emit("click"),
+      );
+      hotspotRef.value = handle;
+      overlayEl.value = handle.el;
+    }
+
+    function unregister(): void {
+      hotspotRef.value?.remove();
+      hotspotRef.value = null;
+      overlayEl.value = null;
+    }
+
+    // In Vue 3, child onMounted fires before parent onMounted, so sceneRef.value
+    // is null at mount time. Watch for the scene to become available.
+    const stopWatch = watchEffect(() => {
+      if (!sceneRef.value || hotspotRef.value) return;
+      register();
+    });
+
+    onBeforeUnmount(() => {
+      stopWatch();
+      unregister();
+    });
+
+    watch(() => ({ id: props.id, at: props.at, size: props.size }), () => {
+      unregister();
+      register();
+    }, { deep: false });
+
+    return () => {
+      const el = overlayEl.value;
+      const slotContent = slots.default?.();
+      // Teleport children into the positioned overlay div when it exists.
+      if (el && slotContent) {
+        return h(Teleport, { to: el }, slotContent);
+      }
+      return null;
+    };
+  },
+});
diff --git a/packages/vue/src/glyphcss/scene/GlyphcssMesh.test.ts b/packages/vue/src/glyphcss/scene/GlyphMesh.test.ts
similarity index 79%
rename from packages/vue/src/glyphcss/scene/GlyphcssMesh.test.ts
rename to packages/vue/src/glyphcss/scene/GlyphMesh.test.ts
index 9fe269f7..00f769e8 100644
--- a/packages/vue/src/glyphcss/scene/GlyphcssMesh.test.ts
+++ b/packages/vue/src/glyphcss/scene/GlyphMesh.test.ts
@@ -1,7 +1,8 @@
 import { describe, it, expect, afterEach, vi } from "vitest";
 import { createApp, h, nextTick } from "vue";
-import { GlyphcssScene } from "./GlyphcssScene";
-import { GlyphcssMesh } from "./GlyphcssMesh";
+import { GlyphScene } from "./GlyphScene";
+import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera";
+import { GlyphMesh } from "./GlyphMesh";
 import type { Polygon } from "@glyphcss/core";
 
 const POLYGON: Polygon = {
@@ -21,28 +22,31 @@ function renderMesh(
   const app = createApp({
     setup() {
       return () =>
-        h(GlyphcssScene, {}, { default: () => h(GlyphcssMesh, meshProps) });
+        h(GlyphPerspectiveCamera, {}, {
+          default: () =>
+            h(GlyphScene, {}, { default: () => h(GlyphMesh, meshProps) }),
+        });
     },
   });
   app.mount(container);
   return { container, app };
 }
 
-describe("GlyphcssMesh (Vue) — id prop wiring", () => {
+describe("GlyphMesh (Vue) — id prop wiring", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("sets data-glyphcss-mesh-id on the wrapper div when id is given", async () => {
+  it("sets data-glyph-mesh-id on the wrapper div when id is given", async () => {
     const { container } = renderMesh({ id: "my-mesh", polygons: [POLYGON] });
     await nextTick();
-    const el = container.querySelector("[data-glyphcss-mesh-id='my-mesh']");
+    const el = container.querySelector("[data-glyph-mesh-id='my-mesh']");
     expect(el).toBeTruthy();
   });
 });
 
-describe("GlyphcssMesh (Vue) — event props accepted", () => {
+describe("GlyphMesh (Vue) — event props accepted", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
diff --git a/packages/vue/src/glyphcss/scene/GlyphMesh.ts b/packages/vue/src/glyphcss/scene/GlyphMesh.ts
new file mode 100644
index 00000000..f1813e8b
--- /dev/null
+++ b/packages/vue/src/glyphcss/scene/GlyphMesh.ts
@@ -0,0 +1,144 @@
+/**
+ * GlyphMesh — Vue 3 component to register a polygon list with the parent
+ * GlyphScene. Mirrors PolyMesh's prop surface for the ASCII backend.
+ */
+import { defineComponent, h, inject, onBeforeUnmount, watch, shallowRef, computed, watchEffect } from "vue";
+import type { PropType } from "vue";
+import { resolveGeometry } from "@glyphcss/core";
+import type { Vec3, Polygon, GlyphGeometryName } from "@glyphcss/core";
+import type { GlyphMeshHandle, GlyphMeshTransform, GlyphPointerEvent, GlyphMouseEvent, GlyphWheelEvent } from "glyphcss";
+import { GlyphSceneContextKey } from "./context";
+
+export interface GlyphMeshProps {
+  id?: string;
+  polygons?: Polygon[];
+  /**
+   * Built-in geometry name. Resolved via `resolveGeometry` when neither
+   * `polygons` nor `src` is provided.
+   *
+   * Precedence: explicit `polygons` > `geometry`.
+   */
+  geometry?: GlyphGeometryName;
+  /** Uniform size passed to `resolveGeometry` when `geometry` is set. Defaults to 1. */
+  size?: number;
+  /** Fill color passed to `resolveGeometry` when `geometry` is set. */
+  color?: string;
+  position?: Vec3;
+  scale?: number | Vec3;
+  rotation?: Vec3;
+  class?: string;
+  // Pointer/mouse interaction — type surface matches voxcss PolyMesh.
+  // TODO(hit-layer): wire these to the hit layer raycasting once the
+  // rasterizer hit-map is wired to the hit-layer dispatch.
+  onPointerDown?: (event: GlyphPointerEvent) => void;
+  onPointerUp?: (event: GlyphPointerEvent) => void;
+  onPointerMove?: (event: GlyphPointerEvent) => void;
+  onPointerEnter?: (event: GlyphPointerEvent) => void;
+  onPointerLeave?: (event: GlyphPointerEvent) => void;
+  onClick?: (event: GlyphMouseEvent) => void;
+  onWheel?: (event: GlyphWheelEvent) => void;
+}
+
+export const GlyphMesh = defineComponent({
+  name: "GlyphMesh",
+  props: {
+    id: { type: String, default: undefined },
+    polygons: { type: Array as PropType, default: undefined },
+    geometry: { type: String as PropType, default: undefined },
+    size: { type: Number, default: 1 },
+    color: { type: String, default: undefined },
+    position: { type: Array as unknown as PropType, default: undefined },
+    scale: { type: [Number, Array] as unknown as PropType, default: undefined },
+    rotation: { type: Array as unknown as PropType, default: undefined },
+    class: { type: String, default: undefined },
+    // TODO(hit-layer): wire these to the hit layer raycasting once the
+    // rasterizer hit-map is wired to the hit-layer dispatch.
+    onPointerDown: { type: Function as PropType<(e: GlyphPointerEvent) => void>, default: undefined },
+    onPointerUp: { type: Function as PropType<(e: GlyphPointerEvent) => void>, default: undefined },
+    onPointerMove: { type: Function as PropType<(e: GlyphPointerEvent) => void>, default: undefined },
+    onPointerEnter: { type: Function as PropType<(e: GlyphPointerEvent) => void>, default: undefined },
+    onPointerLeave: { type: Function as PropType<(e: GlyphPointerEvent) => void>, default: undefined },
+    onClick: { type: Function as PropType<(e: GlyphMouseEvent) => void>, default: undefined },
+    onWheel: { type: Function as PropType<(e: GlyphWheelEvent) => void>, default: undefined },
+  },
+  setup(props, { slots }) {
+    const ctx = inject(GlyphSceneContextKey);
+    if (!ctx) {
+      throw new Error("glyphcss: GlyphMesh must be used inside a GlyphScene.");
+    }
+    const { sceneRef } = ctx;
+    const meshRef = shallowRef(null);
+
+    // Precedence: explicit polygons > geometry shortcut
+    const resolvedPolygons = computed(() => {
+      if (props.polygons !== undefined) return props.polygons;
+      if (props.geometry !== undefined) {
+        return resolveGeometry(props.geometry, { size: props.size, color: props.color });
+      }
+      return [];
+    });
+
+    function buildTransform(): GlyphMeshTransform {
+      const t: GlyphMeshTransform = {};
+      if (props.id) t.id = props.id;
+      if (props.position) t.position = props.position;
+      if (props.scale !== undefined) t.scale = props.scale;
+      if (props.rotation) t.rotation = props.rotation;
+      return t;
+    }
+
+    function register(): void {
+      const scene = sceneRef.value;
+      if (!scene) return;
+      const handle = scene.add(resolvedPolygons.value, buildTransform());
+      meshRef.value = handle;
+    }
+
+    function unregister(): void {
+      meshRef.value?.dispose();
+      meshRef.value = null;
+    }
+
+    // In Vue 3, child onMounted fires before parent onMounted, so sceneRef.value
+    // is null at mount time. Watch for the scene to become available, then register.
+    const stopWatch = watchEffect(() => {
+      if (!sceneRef.value || meshRef.value) return;
+      register();
+    });
+
+    onBeforeUnmount(() => {
+      stopWatch();
+      unregister();
+    });
+
+    // Re-register when resolved polygons change (covers polygons, geometry, size, color)
+    watch(resolvedPolygons, () => {
+      unregister();
+      register();
+    });
+
+    // Update transform on id/position/scale/rotation changes
+    watch(
+      () => ({ id: props.id, position: props.position, scale: props.scale, rotation: props.rotation }),
+      () => {
+        const mesh = meshRef.value;
+        if (!mesh) return;
+        mesh.setTransform(buildTransform());
+        sceneRef.value?.rerender();
+      },
+      { deep: false },
+    );
+
+    return () => {
+      const computedClass = `glyph-mesh${props.class ? ` ${props.class}` : ""}`;
+      return h(
+        "div",
+        {
+          "data-glyph-mesh-id": props.id,
+          class: computedClass,
+        },
+        slots.default?.(),
+      );
+    };
+  },
+});
diff --git a/packages/vue/src/glyphcss/scene/GlyphcssScene.test.ts b/packages/vue/src/glyphcss/scene/GlyphScene.test.ts
similarity index 59%
rename from packages/vue/src/glyphcss/scene/GlyphcssScene.test.ts
rename to packages/vue/src/glyphcss/scene/GlyphScene.test.ts
index 4cd639e4..5b9d28b9 100644
--- a/packages/vue/src/glyphcss/scene/GlyphcssScene.test.ts
+++ b/packages/vue/src/glyphcss/scene/GlyphScene.test.ts
@@ -1,9 +1,10 @@
 import { describe, it, expect, afterEach, vi } from "vitest";
 import { createApp, h, nextTick } from "vue";
 import type { VNode } from "vue";
-import { GlyphcssScene } from "./GlyphcssScene";
-import { GlyphcssMesh } from "./GlyphcssMesh";
-import { GlyphcssOrbitControls } from "../controls/GlyphcssOrbitControls";
+import { GlyphScene } from "./GlyphScene";
+import { GlyphPerspectiveCamera } from "../camera/GlyphPerspectiveCamera";
+import { GlyphMesh } from "./GlyphMesh";
+import { GlyphOrbitControls } from "../controls/GlyphOrbitControls";
 import type { Polygon } from "@glyphcss/core";
 
 const POLYGON: Polygon = {
@@ -24,43 +25,46 @@ function renderScene(
   const app = createApp({
     setup() {
       return () =>
-        h(GlyphcssScene, sceneProps, slotChildren ? { default: slotChildren } : undefined);
+        h(GlyphPerspectiveCamera, {}, {
+          default: () =>
+            h(GlyphScene, sceneProps, slotChildren ? { default: slotChildren } : undefined),
+        });
     },
   });
   app.mount(container);
   return { container, app };
 }
 
-describe("GlyphcssScene (Vue) — basic rendering", () => {
+describe("GlyphScene (Vue) — basic rendering", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("renders a .glyphcss-host element", () => {
+  it("renders a .glyph-host element", () => {
     const { container } = renderScene();
-    const host = container.querySelector(".glyphcss-host");
+    const host = container.querySelector(".glyph-host");
     expect(host).toBeTruthy();
   });
 
-  it("renders a .glyphcss-scene element inside the host", async () => {
+  it("renders a .glyph-scene element inside the host", async () => {
     const { container } = renderScene();
     await nextTick();
-    const scene = container.querySelector(".glyphcss-scene");
+    const scene = container.querySelector(".glyph-scene");
     expect(scene).toBeTruthy();
   });
 
-  it("renders a .glyphcss-output 
 inside the scene", async () => {
+  it("renders a .glyph-output 
 inside the scene", async () => {
     const { container } = renderScene();
     await nextTick();
-    const pre = container.querySelector(".glyphcss-output");
+    const pre = container.querySelector(".glyph-output");
     expect(pre).toBeTruthy();
     expect(pre?.tagName.toLowerCase()).toBe("pre");
   });
 
   it("applies custom class to the host element", () => {
     const { container } = renderScene({ class: "my-scene" });
-    const host = container.querySelector(".glyphcss-host");
+    const host = container.querySelector(".glyph-host");
     expect(host?.classList.contains("my-scene")).toBe(true);
   });
 
@@ -75,7 +79,7 @@ describe("GlyphcssScene (Vue) — basic rendering", () => {
   });
 });
 
-describe("GlyphcssScene (Vue) — options forwarding", () => {
+describe("GlyphScene (Vue) — options forwarding", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
@@ -84,7 +88,7 @@ describe("GlyphcssScene (Vue) — options forwarding", () => {
   it("renders with custom cols/rows", async () => {
     const { container } = renderScene({ cols: 40, rows: 12 });
     await nextTick();
-    const scene = container.querySelector(".glyphcss-scene");
+    const scene = container.querySelector(".glyph-scene");
     expect(scene).toBeTruthy();
   });
 
@@ -97,61 +101,71 @@ describe("GlyphcssScene (Vue) — options forwarding", () => {
   });
 });
 
-describe("GlyphcssScene (Vue) — GlyphcssMesh child", () => {
+describe("GlyphScene (Vue) — GlyphMesh child", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("mounts a GlyphcssMesh without throwing", async () => {
+  it("mounts a GlyphMesh without throwing", async () => {
     expect(() =>
       renderScene(
         {},
-        () => h(GlyphcssMesh, { polygons: [POLYGON] }),
+        () => h(GlyphMesh, { polygons: [POLYGON] }),
       ),
     ).not.toThrow();
     await nextTick();
   });
 
-  it("GlyphcssMesh renders a wrapper div", async () => {
+  it("GlyphMesh renders a wrapper div", async () => {
     const { container } = renderScene(
       {},
-      () => h(GlyphcssMesh, { id: "test-mesh", polygons: [POLYGON] }),
+      () => h(GlyphMesh, { id: "test-mesh", polygons: [POLYGON] }),
     );
     await nextTick();
-    const mesh = container.querySelector(".glyphcss-mesh");
+    const mesh = container.querySelector(".glyph-mesh");
     expect(mesh).toBeTruthy();
   });
 });
 
-describe("GlyphcssScene (Vue) — controls", () => {
+describe("GlyphScene (Vue) — controls", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("GlyphcssOrbitControls mounts without throwing", async () => {
+  it("GlyphOrbitControls mounts without throwing", async () => {
     expect(() =>
       renderScene(
         {},
-        () => h(GlyphcssOrbitControls, { drag: false, wheel: false }),
+        () => h(GlyphOrbitControls, { drag: false, wheel: false }),
       ),
     ).not.toThrow();
     await nextTick();
   });
 });
 
-describe("GlyphcssScene (Vue) — error (no context)", () => {
+describe("GlyphScene (Vue) — error (no context)", () => {
   afterEach(() => {
     vi.restoreAllMocks();
     document.body.innerHTML = "";
   });
 
-  it("GlyphcssMesh throws when used outside GlyphcssScene", () => {
+  it("GlyphMesh throws when used outside GlyphScene", () => {
     const container = document.createElement("div");
     const app = createApp({
       setup() {
-        return () => h(GlyphcssMesh, { polygons: [] });
+        return () => h(GlyphMesh, { polygons: [] });
+      },
+    });
+    expect(() => app.mount(container)).toThrow();
+  });
+
+  it("GlyphScene throws when used without a camera ancestor", () => {
+    const container = document.createElement("div");
+    const app = createApp({
+      setup() {
+        return () => h(GlyphScene, {});
       },
     });
     expect(() => app.mount(container)).toThrow();
diff --git a/packages/vue/src/glyphcss/scene/GlyphcssScene.ts b/packages/vue/src/glyphcss/scene/GlyphScene.ts
similarity index 60%
rename from packages/vue/src/glyphcss/scene/GlyphcssScene.ts
rename to packages/vue/src/glyphcss/scene/GlyphScene.ts
index e9f5be9c..356e7475 100644
--- a/packages/vue/src/glyphcss/scene/GlyphcssScene.ts
+++ b/packages/vue/src/glyphcss/scene/GlyphScene.ts
@@ -1,30 +1,34 @@
 /**
- * GlyphcssScene — Vue 3 wrapper for the ASCII paint backend.
+ * GlyphScene — Vue 3 wrapper for the ASCII paint backend.
  *
- * Mounts a `createGlyphcssScene` handle in a host div and provides the scene
- * handle via GlyphcssSceneContextKey for child components to register with.
+ * Must be placed inside a  or .
+ * Reads the camera handle from GlyphCameraContextKey, mounts a `createGlyphScene`
+ * handle in a host div, and provides the scene handle via GlyphSceneContextKey
+ * for child components to register with.
  */
 import { defineComponent, h, provide, shallowRef, onMounted, onBeforeUnmount, watch } from "vue";
 import type { PropType } from "vue";
 import type { RenderMode } from "@glyphcss/core";
-import type { GlyphcssSceneOptions, GlyphcssDirectionalLight, GlyphcssAmbientLight } from "glyphcss";
-import { createGlyphcssScene, injectGlyphcssBaseStyles } from "glyphcss";
-import { GlyphcssSceneContextKey } from "./context";
+import type { GlyphSceneOptions, GlyphDirectionalLight, GlyphAmbientLight } from "glyphcss";
+import { createGlyphScene, injectGlyphBaseStyles } from "glyphcss";
+import { useGlyphCameraContext } from "../camera/context";
+import { GlyphSceneContextKey } from "./context";
 
-export interface GlyphcssSceneProps {
+export interface GlyphSceneProps {
   mode?: RenderMode;
   glyphPalette?: string;
   useColors?: boolean;
   cols?: number;
   rows?: number;
   cellAspect?: number;
-  directionalLight?: GlyphcssDirectionalLight;
-  ambientLight?: GlyphcssAmbientLight;
+  directionalLight?: GlyphDirectionalLight;
+  ambientLight?: GlyphAmbientLight;
+  autoSize?: boolean;
   class?: string;
 }
 
-export const GlyphcssScene = defineComponent({
-  name: "GlyphcssScene",
+export const GlyphScene = defineComponent({
+  name: "GlyphScene",
   inheritAttrs: false,
   props: {
     mode: { type: String as PropType, default: undefined },
@@ -33,21 +37,24 @@ export const GlyphcssScene = defineComponent({
     cols: { type: Number, default: undefined },
     rows: { type: Number, default: undefined },
     cellAspect: { type: Number, default: undefined },
-    directionalLight: { type: Object as PropType, default: undefined },
-    ambientLight: { type: Object as PropType, default: undefined },
+    directionalLight: { type: Object as PropType, default: undefined },
+    ambientLight: { type: Object as PropType, default: undefined },
+    autoSize: { type: Boolean, default: undefined },
     class: { type: String, default: undefined },
   },
   setup(props, { slots, attrs }) {
+    const { cameraRef, sceneRerenderRef } = useGlyphCameraContext();
+
     const hostRef = shallowRef(null);
-    const sceneRef = shallowRef | null>(null);
+    const sceneRef = shallowRef | null>(null);
 
-    provide(GlyphcssSceneContextKey, { sceneRef });
+    provide(GlyphSceneContextKey, { sceneRef });
 
     onMounted(() => {
       const el = hostRef.value;
       if (!el) return;
-      injectGlyphcssBaseStyles(el.ownerDocument ?? undefined);
-      const opts: GlyphcssSceneOptions = {};
+      injectGlyphBaseStyles(el.ownerDocument ?? undefined);
+      const opts: GlyphSceneOptions = {};
       if (props.mode !== undefined) opts.mode = props.mode;
       if (props.glyphPalette !== undefined) opts.glyphPalette = props.glyphPalette;
       if (props.useColors !== undefined) opts.useColors = props.useColors;
@@ -56,12 +63,18 @@ export const GlyphcssScene = defineComponent({
       if (props.cellAspect !== undefined) opts.cellAspect = props.cellAspect;
       if (props.directionalLight !== undefined) opts.directionalLight = props.directionalLight;
       if (props.ambientLight !== undefined) opts.ambientLight = props.ambientLight;
-      sceneRef.value = createGlyphcssScene(el, opts);
+      if (props.autoSize !== undefined) opts.autoSize = props.autoSize;
+      if (cameraRef.value !== null) opts.camera = cameraRef.value;
+      sceneRef.value = createGlyphScene(el, opts);
+      // Register the rerender callback with the camera context so prop changes
+      // on the camera component trigger rerenders on this scene.
+      sceneRerenderRef.value = () => sceneRef.value?.rerender();
     });
 
     onBeforeUnmount(() => {
       sceneRef.value?.destroy();
       sceneRef.value = null;
+      sceneRerenderRef.value = null;
     });
 
     // Sync option prop changes to the live scene handle
@@ -75,11 +88,12 @@ export const GlyphcssScene = defineComponent({
         cellAspect: props.cellAspect,
         directionalLight: props.directionalLight,
         ambientLight: props.ambientLight,
+        autoSize: props.autoSize,
       }),
       (next) => {
         const scene = sceneRef.value;
         if (!scene) return;
-        const partial: Partial = {};
+        const partial: Partial = {};
         if (next.mode !== undefined) partial.mode = next.mode;
         if (next.glyphPalette !== undefined) partial.glyphPalette = next.glyphPalette;
         if (next.useColors !== undefined) partial.useColors = next.useColors;
@@ -88,13 +102,14 @@ export const GlyphcssScene = defineComponent({
         if (next.cellAspect !== undefined) partial.cellAspect = next.cellAspect;
         if (next.directionalLight !== undefined) partial.directionalLight = next.directionalLight;
         if (next.ambientLight !== undefined) partial.ambientLight = next.ambientLight;
+        if (next.autoSize !== undefined) partial.autoSize = next.autoSize;
         if (Object.keys(partial).length > 0) scene.setOptions(partial);
       },
       { deep: false },
     );
 
     return () => {
-      const computedClass = `glyphcss-host${props.class ? ` ${props.class}` : ""}`;
+      const computedClass = `glyph-host${props.class ? ` ${props.class}` : ""}`;
       return h(
         "div",
         {
diff --git a/packages/vue/src/glyphcss/scene/GlyphcssHotspot.ts b/packages/vue/src/glyphcss/scene/GlyphcssHotspot.ts
deleted file mode 100644
index d83b652a..00000000
--- a/packages/vue/src/glyphcss/scene/GlyphcssHotspot.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * GlyphcssHotspot — Vue 3 wrapper for scene.addHotspot().
- * Mirrors React's GlyphcssHotspot.
- */
-import { defineComponent, inject, onMounted, onBeforeUnmount, watch, shallowRef } from "vue";
-import type { PropType } from "vue";
-import type { Vec3 } from "@glyphcss/core";
-import type { GlyphcssHotspotHandle } from "glyphcss";
-import { GlyphcssSceneContextKey } from "./context";
-
-export interface GlyphcssHotspotProps {
-  id: string;
-  at: Vec3;
-  size?: [number, number];
-}
-
-export const GlyphcssHotspot = defineComponent({
-  name: "GlyphcssHotspot",
-  props: {
-    id: { type: String, required: true },
-    at: { type: Array as unknown as PropType, required: true },
-    size: { type: Array as unknown as PropType<[number, number]>, default: undefined },
-  },
-  emits: ["click"],
-  setup(props, { emit }) {
-    const ctx = inject(GlyphcssSceneContextKey);
-    if (!ctx) {
-      throw new Error("glyphcss: GlyphcssHotspot must be used inside a GlyphcssScene.");
-    }
-    const { sceneRef } = ctx;
-    const hotspotRef = shallowRef(null);
-
-    function register(): void {
-      const scene = sceneRef.value;
-      if (!scene) return;
-      const handle = scene.addHotspot(
-        { id: props.id, at: props.at, size: props.size },
-        () => emit("click"),
-      );
-      hotspotRef.value = handle;
-    }
-
-    function unregister(): void {
-      hotspotRef.value?.remove();
-      hotspotRef.value = null;
-    }
-
-    onMounted(register);
-    onBeforeUnmount(unregister);
-
-    watch(() => ({ id: props.id, at: props.at, size: props.size }), () => {
-      unregister();
-      register();
-    }, { deep: false });
-
-    return () => null;
-  },
-});
diff --git a/packages/vue/src/glyphcss/scene/GlyphcssMesh.ts b/packages/vue/src/glyphcss/scene/GlyphcssMesh.ts
deleted file mode 100644
index 71ce1895..00000000
--- a/packages/vue/src/glyphcss/scene/GlyphcssMesh.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * GlyphcssMesh — Vue 3 component to register a polygon list with the parent
- * GlyphcssScene. Mirrors PolyMesh's prop surface for the ASCII backend.
- */
-import { defineComponent, h, inject, onMounted, onBeforeUnmount, watch, shallowRef } from "vue";
-import type { PropType } from "vue";
-import type { Vec3, Polygon } from "@glyphcss/core";
-import type { GlyphcssMeshHandle, GlyphcssMeshTransform, GlyphcssPointerEvent, GlyphcssMouseEvent, GlyphcssWheelEvent } from "glyphcss";
-import { GlyphcssSceneContextKey } from "./context";
-
-export interface GlyphcssMeshProps {
-  id?: string;
-  polygons?: Polygon[];
-  position?: Vec3;
-  scale?: number | Vec3;
-  rotation?: Vec3;
-  class?: string;
-  // Pointer/mouse interaction — type surface matches voxcss PolyMesh.
-  // TODO(hit-layer): wire these to the hit layer raycasting once the
-  // rasterizer hit-map is wired to the hit-layer dispatch.
-  onPointerDown?: (event: GlyphcssPointerEvent) => void;
-  onPointerUp?: (event: GlyphcssPointerEvent) => void;
-  onPointerMove?: (event: GlyphcssPointerEvent) => void;
-  onPointerEnter?: (event: GlyphcssPointerEvent) => void;
-  onPointerLeave?: (event: GlyphcssPointerEvent) => void;
-  onClick?: (event: GlyphcssMouseEvent) => void;
-  onWheel?: (event: GlyphcssWheelEvent) => void;
-}
-
-export const GlyphcssMesh = defineComponent({
-  name: "GlyphcssMesh",
-  props: {
-    id: { type: String, default: undefined },
-    polygons: { type: Array as PropType, default: () => [] },
-    position: { type: Array as unknown as PropType, default: undefined },
-    scale: { type: [Number, Array] as unknown as PropType, default: undefined },
-    rotation: { type: Array as unknown as PropType, default: undefined },
-    class: { type: String, default: undefined },
-    // TODO(hit-layer): wire these to the hit layer raycasting once the
-    // rasterizer hit-map is wired to the hit-layer dispatch.
-    onPointerDown: { type: Function as PropType<(e: GlyphcssPointerEvent) => void>, default: undefined },
-    onPointerUp: { type: Function as PropType<(e: GlyphcssPointerEvent) => void>, default: undefined },
-    onPointerMove: { type: Function as PropType<(e: GlyphcssPointerEvent) => void>, default: undefined },
-    onPointerEnter: { type: Function as PropType<(e: GlyphcssPointerEvent) => void>, default: undefined },
-    onPointerLeave: { type: Function as PropType<(e: GlyphcssPointerEvent) => void>, default: undefined },
-    onClick: { type: Function as PropType<(e: GlyphcssMouseEvent) => void>, default: undefined },
-    onWheel: { type: Function as PropType<(e: GlyphcssWheelEvent) => void>, default: undefined },
-  },
-  setup(props, { slots }) {
-    const ctx = inject(GlyphcssSceneContextKey);
-    if (!ctx) {
-      throw new Error("glyphcss: GlyphcssMesh must be used inside a GlyphcssScene.");
-    }
-    const { sceneRef } = ctx;
-    const meshRef = shallowRef(null);
-
-    function buildTransform(): GlyphcssMeshTransform {
-      const t: GlyphcssMeshTransform = {};
-      if (props.id) t.id = props.id;
-      if (props.position) t.position = props.position;
-      if (props.scale !== undefined) t.scale = props.scale;
-      if (props.rotation) t.rotation = props.rotation;
-      return t;
-    }
-
-    function register(): void {
-      const scene = sceneRef.value;
-      if (!scene) return;
-      const handle = scene.add(props.polygons ?? [], buildTransform());
-      meshRef.value = handle;
-    }
-
-    function unregister(): void {
-      meshRef.value?.dispose();
-      meshRef.value = null;
-    }
-
-    onMounted(register);
-    onBeforeUnmount(unregister);
-
-    // Re-register when polygons array identity changes
-    watch(() => props.polygons, () => {
-      unregister();
-      register();
-    });
-
-    // Update transform on id/position/scale/rotation changes
-    watch(
-      () => ({ id: props.id, position: props.position, scale: props.scale, rotation: props.rotation }),
-      () => {
-        const mesh = meshRef.value;
-        if (!mesh) return;
-        mesh.setTransform(buildTransform());
-        sceneRef.value?.rerender();
-      },
-      { deep: false },
-    );
-
-    return () => {
-      const computedClass = `glyphcss-mesh${props.class ? ` ${props.class}` : ""}`;
-      return h(
-        "div",
-        {
-          "data-glyphcss-mesh-id": props.id,
-          class: computedClass,
-        },
-        slots.default?.(),
-      );
-    };
-  },
-});
diff --git a/packages/vue/src/glyphcss/scene/context.ts b/packages/vue/src/glyphcss/scene/context.ts
index edd200ed..cfc5a394 100644
--- a/packages/vue/src/glyphcss/scene/context.ts
+++ b/packages/vue/src/glyphcss/scene/context.ts
@@ -1,11 +1,11 @@
 import type { InjectionKey, ShallowRef } from "vue";
-import type { GlyphcssSceneHandle } from "glyphcss";
+import type { GlyphSceneHandle } from "glyphcss";
 
-export interface GlyphcssSceneContextValue {
-  sceneRef: ShallowRef;
+export interface GlyphSceneContextValue {
+  sceneRef: ShallowRef;
 }
 
-export const GlyphcssSceneContextKey: InjectionKey =
-  Symbol("glyphcss-scene");
+export const GlyphSceneContextKey: InjectionKey =
+  Symbol("glyph-scene");
 
-export type { GlyphcssSceneHandle };
+export type { GlyphSceneHandle };
diff --git a/packages/vue/src/glyphcss/scene/index.ts b/packages/vue/src/glyphcss/scene/index.ts
index d1e5979f..060d86e7 100644
--- a/packages/vue/src/glyphcss/scene/index.ts
+++ b/packages/vue/src/glyphcss/scene/index.ts
@@ -1,12 +1,12 @@
-export { GlyphcssScene } from "./GlyphcssScene";
-export type { GlyphcssSceneProps } from "./GlyphcssScene";
-export { GlyphcssMesh } from "./GlyphcssMesh";
-export type { GlyphcssMeshProps } from "./GlyphcssMesh";
-export { GlyphcssGround } from "./GlyphcssGround";
-export type { GlyphcssGroundProps } from "./GlyphcssGround";
-export { GlyphcssHotspot } from "./GlyphcssHotspot";
-export type { GlyphcssHotspotProps } from "./GlyphcssHotspot";
-export { GlyphcssSceneContextKey } from "./context";
-export type { GlyphcssSceneContextValue } from "./context";
-export { useGlyphcssSceneContext } from "./useGlyphcssSceneContext";
-export { findGlyphcssMeshHandle, pointInMeshElement, findMeshUnderPoint } from "glyphcss";
+export { GlyphScene } from "./GlyphScene";
+export type { GlyphSceneProps } from "./GlyphScene";
+export { GlyphMesh } from "./GlyphMesh";
+export type { GlyphMeshProps } from "./GlyphMesh";
+export { GlyphGround } from "./GlyphGround";
+export type { GlyphGroundProps } from "./GlyphGround";
+export { GlyphHotspot } from "./GlyphHotspot";
+export type { GlyphHotspotProps } from "./GlyphHotspot";
+export { GlyphSceneContextKey } from "./context";
+export type { GlyphSceneContextValue } from "./context";
+export { useGlyphSceneContext } from "./useGlyphSceneContext";
+export { findGlyphMeshHandle, pointInMeshElement, findMeshUnderPoint } from "glyphcss";
diff --git a/packages/vue/src/glyphcss/scene/useGlyphSceneContext.ts b/packages/vue/src/glyphcss/scene/useGlyphSceneContext.ts
new file mode 100644
index 00000000..169989c6
--- /dev/null
+++ b/packages/vue/src/glyphcss/scene/useGlyphSceneContext.ts
@@ -0,0 +1,13 @@
+import { inject } from "vue";
+import { GlyphSceneContextKey } from "./context";
+import type { GlyphSceneContextValue } from "./context";
+
+export function useGlyphSceneContext(): GlyphSceneContextValue {
+  const ctx = inject(GlyphSceneContextKey);
+  if (!ctx) {
+    throw new Error("glyphcss: must be used inside a GlyphScene.");
+  }
+  return ctx;
+}
+
+export type { GlyphSceneContextValue };
diff --git a/packages/vue/src/glyphcss/scene/useGlyphcssSceneContext.ts b/packages/vue/src/glyphcss/scene/useGlyphcssSceneContext.ts
deleted file mode 100644
index 69941ab6..00000000
--- a/packages/vue/src/glyphcss/scene/useGlyphcssSceneContext.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { inject } from "vue";
-import { GlyphcssSceneContextKey } from "./context";
-import type { GlyphcssSceneContextValue } from "./context";
-
-export function useGlyphcssSceneContext(): GlyphcssSceneContextValue {
-  const ctx = inject(GlyphcssSceneContextKey);
-  if (!ctx) {
-    throw new Error("glyphcss: must be used inside a GlyphcssScene.");
-  }
-  return ctx;
-}
-
-export type { GlyphcssSceneContextValue };
diff --git a/packages/vue/src/glyphcss/styles/index.ts b/packages/vue/src/glyphcss/styles/index.ts
index 0ecc82c9..295d5909 100644
--- a/packages/vue/src/glyphcss/styles/index.ts
+++ b/packages/vue/src/glyphcss/styles/index.ts
@@ -1 +1 @@
-export { injectGlyphcssBaseStyles } from "glyphcss";
+export { injectGlyphBaseStyles } from "glyphcss";
diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts
index d7136e39..57fc739d 100644
--- a/packages/vue/src/index.ts
+++ b/packages/vue/src/index.ts
@@ -3,15 +3,15 @@ export type {
   Polygon,
   Vec2,
   Vec3,
-  GlyphcssDirectionalLight,
-  GlyphcssAmbientLight,
+  GlyphDirectionalLight,
+  GlyphAmbientLight,
   ParseResult,
   ParseAnimationClip,
   ParseAnimationController,
-  GlyphcssAnimationClip,
-  GlyphcssAnimationAction,
-  GlyphcssAnimationMixer,
-  GlyphcssAnimationTarget,
+  GlyphAnimationClip,
+  GlyphAnimationAction,
+  GlyphAnimationMixer,
+  GlyphAnimationTarget,
   LoopMode,
   ObjParseOptions,
   GltfParseOptions,
@@ -42,6 +42,8 @@ export type {
   CameraCullRotation,
   ApproximateMergeOptions,
   OptimizeMeshPolygonsOptions,
+  GlyphGeometryName,
+  GlyphGeometryOptions,
 } from "@glyphcss/core";
 export {
   CAMERA_BACKFACE_CULL_EPS,
@@ -81,17 +83,63 @@ export {
   shadeColor,
   rotateVec3,
   inverseRotateVec3,
-  axesHelperPolygons,
-  arrowPolygons,
-  ringPolygons,
+  resolveGeometry,
+  tetrahedronPolygons,
+  cubePolygons,
   octahedronPolygons,
+  dodecahedronPolygons,
+  icosahedronPolygons,
+  smallStellatedDodecahedronPolygons,
+  greatDodecahedronPolygons,
+  greatStellatedDodecahedronPolygons,
+  greatIcosahedronPolygons,
+  cuboctahedronPolygons,
+  icosidodecahedronPolygons,
+  truncatedTetrahedronPolygons,
+  truncatedCubePolygons,
+  truncatedOctahedronPolygons,
+  truncatedDodecahedronPolygons,
+  truncatedIcosahedronPolygons,
+  truncatedCuboctahedronPolygons,
+  truncatedIcosidodecahedronPolygons,
+  rhombicuboctahedronPolygons,
+  rhombicosidodecahedronPolygons,
+  snubCubePolygons,
+  snubDodecahedronPolygons,
+  rhombicDodecahedronPolygons,
+  rhombicTriacontahedronPolygons,
+  triakisTetrahedronPolygons,
+  triakisOctahedronPolygons,
+  triakisIcosahedronPolygons,
+  tetrakisHexahedronPolygons,
+  pentakisDodecahedronPolygons,
+  disdyakisDodecahedronPolygons,
+  disdyakisTriacontahedronPolygons,
+  deltoidalIcositetrahedronPolygons,
+  deltoidalHexecontahedronPolygons,
+  pentagonalIcositetrahedronPolygons,
+  pentagonalHexecontahedronPolygons,
+  prismPolygons,
+  antiprismPolygons,
+  bipyramidPolygons,
+  trapezohedronPolygons,
+  spherePolygons,
+  cylinderPolygons,
+  conePolygons,
+  torusPolygons,
+  pyramidPolygons,
+  planePolygons,
+  ringPolygons,
+  ringQuadPolygons,
+  arrowPolygons,
+  axesHelperPolygons,
   buildSceneContext,
   computeSceneBbox,
   BASE_TILE,
   DEFAULT_CAMERA_STATE,
   DEFAULT_PROJECTION,
   normalizeInvertMultiplier,
-  createGlyphcssAnimationMixer,
+  createGlyphAnimationMixer,
   LoopOnce,
   LoopRepeat,
   LoopPingPong,
@@ -99,53 +147,53 @@ export {
 
 // ── Glyphcss (ASCII paint backend) bindings ─────────────────────────────────
 export {
-  GlyphcssScene,
-  GlyphcssMesh,
-  GlyphcssGround,
-  GlyphcssHotspot,
-  GlyphcssSceneContextKey,
-  useGlyphcssSceneContext,
-  findGlyphcssMeshHandle,
+  GlyphScene,
+  GlyphMesh,
+  GlyphGround,
+  GlyphHotspot,
+  GlyphSceneContextKey,
+  useGlyphSceneContext,
+  findGlyphMeshHandle,
   pointInMeshElement,
   findMeshUnderPoint,
-  GlyphcssCamera,
-  GlyphcssPerspectiveCamera,
-  GlyphcssOrthographicCamera,
-  GlyphcssCameraContextKey,
-  useGlyphcssCamera,
-  GlyphcssOrbitControls,
-  GlyphcssMapControls,
-  GlyphcssFirstPersonControls,
-  GlyphcssAxesHelper,
-  GlyphcssDirectionalLightHelper,
-  injectGlyphcssBaseStyles,
-  useGlyphcssAnimation,
+  GlyphCamera,
+  GlyphPerspectiveCamera,
+  GlyphOrthographicCamera,
+  GlyphCameraContextKey,
+  useGlyphCamera,
+  GlyphOrbitControls,
+  GlyphMapControls,
+  GlyphFirstPersonControls,
+  GlyphAxesHelper,
+  GlyphDirectionalLightHelper,
+  injectGlyphBaseStyles,
+  useGlyphAnimation,
 } from "./glyphcss";
 export type {
-  GlyphcssSceneProps,
-  GlyphcssMeshProps,
-  GlyphcssGroundProps,
-  GlyphcssHotspotProps,
-  GlyphcssSceneContextValue,
-  GlyphcssCameraProps,
-  GlyphcssPerspectiveCameraProps,
-  GlyphcssOrthographicCameraProps,
-  GlyphcssCameraContextValue,
-  GlyphcssOrbitControlsProps,
-  GlyphcssMapControlsProps,
-  GlyphcssFirstPersonControlsProps,
-  GlyphcssAxesHelperProps,
-  GlyphcssDirectionalLightHelperProps,
-  UseGlyphcssAnimationResultVue,
+  GlyphSceneProps,
+  GlyphMeshProps,
+  GlyphGroundProps,
+  GlyphHotspotProps,
+  GlyphSceneContextValue,
+  GlyphCameraProps,
+  GlyphPerspectiveCameraProps,
+  GlyphOrthographicCameraProps,
+  GlyphCameraContextValue,
+  GlyphOrbitControlsProps,
+  GlyphMapControlsProps,
+  GlyphFirstPersonControlsProps,
+  GlyphAxesHelperProps,
+  GlyphDirectionalLightHelperProps,
+  UseGlyphAnimationResultVue,
 } from "./glyphcss";
 
 // ── Mesh handle type ──────────────────────────────────────────────────────────
-export type { GlyphcssMeshHandle } from "glyphcss";
+export type { GlyphMeshHandle } from "glyphcss";
 
 // ── Event types ───────────────────────────────────────────────────────────────
 export type {
-  GlyphcssPointerEvent,
-  GlyphcssMouseEvent,
-  GlyphcssWheelEvent,
-  GlyphcssEventHandler,
+  GlyphPointerEvent,
+  GlyphMouseEvent,
+  GlyphWheelEvent,
+  GlyphEventHandler,
 } from "glyphcss";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3e4fb54a..3960b528 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -30,6 +30,88 @@ importers:
         specifier: ^3.5.30
         version: 3.5.30(typescript@5.9.3)
 
+  examples/html:
+    dependencies:
+      glyphcss:
+        specifier: workspace:*
+        version: link:../../packages/glyphcss
+    devDependencies:
+      typescript:
+        specifier: ^5.3.3
+        version: 5.9.3
+      vite:
+        specifier: ^6.0.0
+        version: 6.4.1(@types/node@25.5.0)
+
+  examples/react:
+    dependencies:
+      '@glyphcss/core':
+        specifier: workspace:*
+        version: link:../../packages/core
+      '@glyphcss/react':
+        specifier: workspace:*
+        version: link:../../packages/react
+      react:
+        specifier: ^19.0.0
+        version: 19.2.6
+      react-dom:
+        specifier: ^19.0.0
+        version: 19.2.6(react@19.2.6)
+    devDependencies:
+      '@types/react':
+        specifier: ^19.0.0
+        version: 19.2.14
+      '@types/react-dom':
+        specifier: ^19.0.0
+        version: 19.2.3(@types/react@19.2.14)
+      '@vitejs/plugin-react':
+        specifier: ^4.0.0
+        version: 4.7.0(vite@6.4.1(@types/node@25.5.0))
+      typescript:
+        specifier: ^5.3.3
+        version: 5.9.3
+      vite:
+        specifier: ^6.0.0
+        version: 6.4.1(@types/node@25.5.0)
+
+  examples/vanilla:
+    dependencies:
+      '@glyphcss/core':
+        specifier: workspace:*
+        version: link:../../packages/core
+      glyphcss:
+        specifier: workspace:*
+        version: link:../../packages/glyphcss
+    devDependencies:
+      typescript:
+        specifier: ^5.3.3
+        version: 5.9.3
+      vite:
+        specifier: ^6.0.0
+        version: 6.4.1(@types/node@25.5.0)
+
+  examples/vue:
+    dependencies:
+      '@glyphcss/core':
+        specifier: workspace:*
+        version: link:../../packages/core
+      '@glyphcss/vue':
+        specifier: workspace:*
+        version: link:../../packages/vue
+      vue:
+        specifier: ^3.5.12
+        version: 3.5.30(typescript@5.9.3)
+    devDependencies:
+      '@vitejs/plugin-vue':
+        specifier: ^5.0.0
+        version: 5.2.4(vite@6.4.1(@types/node@25.5.0))(vue@3.5.30(typescript@5.9.3))
+      typescript:
+        specifier: ^5.3.3
+        version: 5.9.3
+      vite:
+        specifier: ^6.0.0
+        version: 6.4.1(@types/node@25.5.0)
+
   packages/core:
     devDependencies:
       '@vitest/coverage-v8':
@@ -1034,6 +1116,9 @@ packages:
     resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
     engines: {node: '>=14'}
 
+  '@rolldown/pluginutils@1.0.0-beta.27':
+    resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
+
   '@rolldown/pluginutils@1.0.0-rc.3':
     resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
 
@@ -1310,12 +1395,25 @@ packages:
   '@ungap/structured-clone@1.3.0':
     resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
 
+  '@vitejs/plugin-react@4.7.0':
+    resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
+    engines: {node: ^14.18.0 || >=16.0.0}
+    peerDependencies:
+      vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
   '@vitejs/plugin-react@5.2.0':
     resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==}
     engines: {node: ^20.19.0 || >=22.12.0}
     peerDependencies:
       vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
 
+  '@vitejs/plugin-vue@5.2.4':
+    resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
+    engines: {node: ^18.0.0 || >=20.0.0}
+    peerDependencies:
+      vite: ^5.0.0 || ^6.0.0
+      vue: ^3.2.25
+
   '@vitest/coverage-v8@3.2.4':
     resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==}
     peerDependencies:
@@ -2439,6 +2537,10 @@ packages:
     peerDependencies:
       react: ^19.2.6
 
+  react-refresh@0.17.0:
+    resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
+    engines: {node: '>=0.10.0'}
+
   react-refresh@0.18.0:
     resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
     engines: {node: '>=0.10.0'}
@@ -3799,6 +3901,8 @@ snapshots:
   '@pkgjs/parseargs@0.11.0':
     optional: true
 
+  '@rolldown/pluginutils@1.0.0-beta.27': {}
+
   '@rolldown/pluginutils@1.0.0-rc.3': {}
 
   '@rollup/pluginutils@5.3.0(rollup@4.60.0)':
@@ -4043,6 +4147,18 @@ snapshots:
 
   '@ungap/structured-clone@1.3.0': {}
 
+  '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.5.0))':
+    dependencies:
+      '@babel/core': 7.29.0
+      '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
+      '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0)
+      '@rolldown/pluginutils': 1.0.0-beta.27
+      '@types/babel__core': 7.20.5
+      react-refresh: 0.17.0
+      vite: 6.4.1(@types/node@25.5.0)
+    transitivePeerDependencies:
+      - supports-color
+
   '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.5.0))':
     dependencies:
       '@babel/core': 7.29.0
@@ -4055,6 +4171,11 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  '@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@25.5.0))(vue@3.5.30(typescript@5.9.3))':
+    dependencies:
+      vite: 6.4.1(@types/node@25.5.0)
+      vue: 3.5.30(typescript@5.9.3)
+
   '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(happy-dom@17.6.3))':
     dependencies:
       '@ampproject/remapping': 2.3.0
@@ -5741,6 +5862,8 @@ snapshots:
       react: 19.2.6
       scheduler: 0.27.0
 
+  react-refresh@0.17.0: {}
+
   react-refresh@0.18.0: {}
 
   react@19.2.4: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 862ad8c0..c9cfc164 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,3 +1,4 @@
 packages:
   - 'packages/*'
   - 'website'
+  - 'examples/*'
diff --git a/screenshots/html-baked-shapes.png b/screenshots/html-baked-shapes.png
new file mode 100644
index 00000000..13011104
Binary files /dev/null and b/screenshots/html-baked-shapes.png differ
diff --git a/screenshots/html-hotspot.png b/screenshots/html-hotspot.png
new file mode 100644
index 00000000..3378ad35
Binary files /dev/null and b/screenshots/html-hotspot.png differ
diff --git a/screenshots/html-solid-mesh.png b/screenshots/html-solid-mesh.png
new file mode 100644
index 00000000..66a4aa74
Binary files /dev/null and b/screenshots/html-solid-mesh.png differ
diff --git a/screenshots/react-baked-shapes.png b/screenshots/react-baked-shapes.png
new file mode 100644
index 00000000..13011104
Binary files /dev/null and b/screenshots/react-baked-shapes.png differ
diff --git a/screenshots/react-hotspot.png b/screenshots/react-hotspot.png
new file mode 100644
index 00000000..45e02fce
Binary files /dev/null and b/screenshots/react-hotspot.png differ
diff --git a/screenshots/react-solid-mesh.png b/screenshots/react-solid-mesh.png
new file mode 100644
index 00000000..66a4aa74
Binary files /dev/null and b/screenshots/react-solid-mesh.png differ
diff --git a/screenshots/vanilla-baked-shapes.png b/screenshots/vanilla-baked-shapes.png
new file mode 100644
index 00000000..13011104
Binary files /dev/null and b/screenshots/vanilla-baked-shapes.png differ
diff --git a/screenshots/vanilla-hotspot.png b/screenshots/vanilla-hotspot.png
new file mode 100644
index 00000000..36b69224
Binary files /dev/null and b/screenshots/vanilla-hotspot.png differ
diff --git a/screenshots/vanilla-solid-mesh.png b/screenshots/vanilla-solid-mesh.png
new file mode 100644
index 00000000..66a4aa74
Binary files /dev/null and b/screenshots/vanilla-solid-mesh.png differ
diff --git a/screenshots/vue-baked-shapes.png b/screenshots/vue-baked-shapes.png
new file mode 100644
index 00000000..13011104
Binary files /dev/null and b/screenshots/vue-baked-shapes.png differ
diff --git a/screenshots/vue-hotspot.png b/screenshots/vue-hotspot.png
new file mode 100644
index 00000000..45e02fce
Binary files /dev/null and b/screenshots/vue-hotspot.png differ
diff --git a/screenshots/vue-solid-mesh.png b/screenshots/vue-solid-mesh.png
new file mode 100644
index 00000000..66a4aa74
Binary files /dev/null and b/screenshots/vue-solid-mesh.png differ
diff --git a/website/astro.config.mjs b/website/astro.config.mjs
index 56738955..cf3209da 100644
--- a/website/astro.config.mjs
+++ b/website/astro.config.mjs
@@ -65,10 +65,10 @@ export default defineConfig({
         {
           label: 'Components',
           items: [
-            { label: 'GlyphcssScene', slug: 'components/glyphcss-scene' },
-            { label: 'GlyphcssCamera', slug: 'components/glyphcss-camera' },
-            { label: 'GlyphcssOrbitControls', slug: 'components/glyphcss-controls' },
-            { label: 'GlyphcssHotspot', slug: 'components/glyphcss-hotspot' },
+            { label: 'GlyphScene', slug: 'components/glyph-scene' },
+            { label: 'GlyphCamera', slug: 'components/glyph-camera' },
+            { label: 'GlyphOrbitControls', slug: 'components/glyph-controls' },
+            { label: 'GlyphHotspot', slug: 'components/glyph-hotspot' },
           ],
         },
         {
@@ -83,6 +83,9 @@ export default defineConfig({
         {
           label: 'API Reference',
           items: [
+            { label: 'React API', slug: 'api/react' },
+            { label: 'Vue API', slug: 'api/vue' },
+            { label: 'HTML API', slug: 'api/html' },
             { label: 'Headless API', slug: 'api/headless' },
             { label: 'Core Types', slug: 'api/types' },
           ],
diff --git a/website/src/components/Dock/folders/useCameraFolder.ts b/website/src/components/Dock/folders/useCameraFolder.ts
index 1bbe8c9d..d29ef9e7 100644
--- a/website/src/components/Dock/folders/useCameraFolder.ts
+++ b/website/src/components/Dock/folders/useCameraFolder.ts
@@ -22,7 +22,7 @@ interface PresetModelMinimal {
 
 export interface CameraFolderInputs {
   autoCenter: boolean;
-  showAxes: boolean;
+  autoRotate: boolean;
   interactive: boolean;
   dragMode: DragMode;
   fpvLook: boolean;
@@ -72,7 +72,7 @@ const PERSPECTIVE_PX_OPTIONS: Record = {
 export function useCameraFolder(parent: GUI | null, inputs: CameraFolderInputs): void {
   const {
     autoCenter,
-    showAxes,
+    autoRotate,
     interactive,
     dragMode,
     fpvLook,
@@ -121,7 +121,7 @@ export function useCameraFolder(parent: GUI | null, inputs: CameraFolderInputs):
   });
 
   useToggle(folder, "Auto center", autoCenter, (value) => onUpdateScene({ autoCenter: value }));
-  useToggle(folder, "Axes", showAxes, (value) => onUpdateScene({ showAxes: value }));
+  useToggle(folder, "Auto rotate", autoRotate, (value) => onUpdateScene({ autoRotate: value }));
   useToggle(folder, "Interactive", interactive, (value) => onUpdateScene({ interactive: value }));
   useOption(folder, "Drag mode", DRAG_MODE_OPTIONS, dragMode, (value) =>
     onUpdateScene({ dragMode: value }),
diff --git a/website/src/components/Dock/folders/useLightingFolder.ts b/website/src/components/Dock/folders/useLightingFolder.ts
index 9fc4bab0..09c90880 100644
--- a/website/src/components/Dock/folders/useLightingFolder.ts
+++ b/website/src/components/Dock/folders/useLightingFolder.ts
@@ -1,14 +1,12 @@
 /**
- * Lighting folder — show-ground, light-helper toggles, directional key-light
- * (azimuth / elevation / intensity / color), and ambient (intensity / color).
+ * Lighting folder — directional key-light (azimuth / elevation / intensity /
+ * color) and ambient (intensity / color).
  */
 import type { GUI } from "lil-gui";
 import type { SceneOptionsState } from "../../GalleryWorkbench/types";
-import { useColor, useFolder, useSlider, useToggle } from "../primitives";
+import { useColor, useFolder, useSlider } from "../primitives";
 
 export interface LightingFolderInputs {
-  showGround: boolean;
-  showLight: boolean;
   lightAzimuth: number;
   lightElevation: number;
   lightIntensity: number;
@@ -16,8 +14,6 @@ export interface LightingFolderInputs {
   ambientIntensity: number;
   ambientColor: string;
   onUpdateScene: (partial: Partial onUpdateScene({ showGround: value }));
-  useToggle(folder, "Light helper", showLight, (value) => onUpdateScene({ showLight: value }));
-
   useSlider(folder, "Azimuth", { min: 0, max: 360, step: 1 }, lightAzimuth, (value) =>
     onUpdateScene({ lightAzimuth: value }),
   );
diff --git a/website/src/components/Dock/folders/useModelFolder.ts b/website/src/components/Dock/folders/useModelFolder.ts
index f03a037f..bbe6f520 100644
--- a/website/src/components/Dock/folders/useModelFolder.ts
+++ b/website/src/components/Dock/folders/useModelFolder.ts
@@ -4,11 +4,11 @@
  * displays; no user input originates here.
  */
 import type { GUI } from "lil-gui";
-import type { GlyphcssMetrics } from "../../GalleryWorkbench/types";
+import type { GlyphMetrics } from "../../GalleryWorkbench/types";
 import { useFolder, useReadonlyNumber } from "../primitives";
 
 export interface ModelFolderInputs {
-  metrics: GlyphcssMetrics;
+  metrics: GlyphMetrics;
 }
 
 export function useModelFolder(parent: GUI | null, inputs: ModelFolderInputs): void {
diff --git a/website/src/components/Dock/folders/useRenderingFolder.ts b/website/src/components/Dock/folders/useRenderingFolder.ts
index 67896938..1b324918 100644
--- a/website/src/components/Dock/folders/useRenderingFolder.ts
+++ b/website/src/components/Dock/folders/useRenderingFolder.ts
@@ -12,7 +12,9 @@ export interface RenderingFolderInputs {
   glyphPalette: SceneOptionsState["glyphPalette"];
   lineHeight: number;
   useColors: boolean;
-  onUpdateScene: (partial: Partial>) => void;
+  smoothShading: boolean;
+  creaseAngle: number;
+  onUpdateScene: (partial: Partial>) => void;
 }
 
 
@@ -20,24 +22,21 @@ const RENDER_MODE_OPTIONS: Record = {
   Wireframe: "wireframe",
   Solid: "solid",
 };
-type GlyphPaletteId = "default" | "ascii" | "dots" | "lines" | "blocks" | "stars" | "arrows" | "braille" | "runes" | "math" | "binary" | "hex";
+type GlyphPaletteId = "default" | "ascii" | "lines" | "blocks" | "stars" | "arrows" | "math" | "binary" | "hex";
 const GLYPH_PALETTE_OPTIONS: Record = {
   Default: "default",
   ASCII: "ascii",
-  Dots: "dots",
   Lines: "lines",
   Blocks: "blocks",
   Stars: "stars",
   Arrows: "arrows",
-  Braille: "braille",
-  Runes: "runes",
   Math: "math",
   Binary: "binary",
   Hex: "hex",
 };
 
 export function useRenderingFolder(parent: GUI | null, inputs: RenderingFolderInputs): void {
-  const { renderMode, featureEdges, glyphPalette, lineHeight, useColors, onUpdateScene } = inputs;
+  const { renderMode, featureEdges, glyphPalette, lineHeight, useColors, smoothShading, creaseAngle, onUpdateScene } = inputs;
   const folder = useFolder(parent, "Rendering", { open: true });
 
   useOption<"wireframe" | "solid">(folder, "Render mode", RENDER_MODE_OPTIONS, renderMode, (value) =>
@@ -52,6 +51,12 @@ export function useRenderingFolder(parent: GUI | null, inputs: RenderingFolderIn
   useToggle(folder, "Colors", useColors, (value) =>
     onUpdateScene({ useColors: value }),
   );
+  useToggle(folder, "Smooth shading", smoothShading, (value) =>
+    onUpdateScene({ smoothShading: value }),
+  );
+  useSlider(folder, "Crease angle °", { min: 0, max: 180, step: 1 }, creaseAngle, (value) =>
+    onUpdateScene({ creaseAngle: value }),
+  );
   useSlider(folder, "Line-height ×", { min: 0.5, max: 1.2, step: 0.01 }, lineHeight, (value) =>
     onUpdateScene({ lineHeight: value }),
   );
diff --git a/website/src/components/Dock/primitives.tsx b/website/src/components/Dock/primitives.tsx
index 17750a29..0b56493a 100644
--- a/website/src/components/Dock/primitives.tsx
+++ b/website/src/components/Dock/primitives.tsx
@@ -7,7 +7,7 @@
  * just pass the current state value and the controller mirrors it without
  * extra `setValue(...)` boilerplate per slot.
  *
- * Ported from glyphcss primitives.tsx — glyphcss-specific metric labels and
+ * Ported from glyphcss primitives.tsx — glyph-specific metric labels and
  * ranges are the only divergence.
  */
 import { useEffect, useRef, useState } from "react";
diff --git a/website/src/components/GalleryWorkbench/CodePanel.tsx b/website/src/components/GalleryWorkbench/CodePanel.tsx
new file mode 100644
index 00000000..1b047284
--- /dev/null
+++ b/website/src/components/GalleryWorkbench/CodePanel.tsx
@@ -0,0 +1,329 @@
+import { useCallback, useMemo, useState } from "react";
+import type { PresetModel, SceneOptionsState } from "./types";
+
+type Tab = "html" | "vanilla" | "react" | "vue";
+
+interface CodePanelProps {
+  meshUrl: string;
+  options: SceneOptionsState;
+  selectedPreset: PresetModel;
+}
+
+// Primitive presets that need a +90° X rotation so their natural Y-up axis maps
+// to the Z-up screen convention (cylinder/cone/pyramid/prism families build
+// along +Y). Mirrors `uprightAlongZ` in presetList.ts.
+const UPRIGHT_PRIMITIVES = new Set([
+  "primitive-cylinder",
+  "primitive-cone",
+  "primitive-pyramid",
+  "primitive-prism",
+  "primitive-antiprism",
+  "primitive-bipyramid",
+  "primitive-trapezohedron",
+]);
+
+/** `primitive-truncated-cube` → `truncatedCube`. */
+function primitiveGeometryName(id: string): string {
+  return id.replace(/^primitive-/, "").replace(/-([a-z])/g, (_, c) => c.toUpperCase());
+}
+
+const SITE_URL = "https://glyphcss.com";
+
+/** Build the absolute mesh URL the snippet should reference. */
+function absoluteMeshUrl(rel: string): string {
+  if (!rel) return "";
+  if (/^https?:\/\//.test(rel)) return rel;
+  return `${SITE_URL}${rel.startsWith("/") ? "" : "/"}${rel}`;
+}
+
+/** Two-decimal-place stringification for snippet numbers. */
+function fmt(n: number): string {
+  if (!Number.isFinite(n)) return "0";
+  // Drop trailing zeros to keep snippets terse but cap precision at 2.
+  return String(Number(n.toFixed(2)));
+}
+
+/** Spherical (azimuth/elevation in degrees) → cartesian direction Vec3. */
+function dirFromSpherical(azimuthDeg: number, elevationDeg: number): [number, number, number] {
+  const az = (azimuthDeg * Math.PI) / 180;
+  const el = (elevationDeg * Math.PI) / 180;
+  return [Math.cos(el) * Math.cos(az), Math.sin(el), Math.cos(el) * Math.sin(az)];
+}
+
+function vec3(v: [number, number, number]): string {
+  return `[${fmt(v[0])}, ${fmt(v[1])}, ${fmt(v[2])}]`;
+}
+
+function generateSnippets({ meshUrl, options, selectedPreset }: CodePanelProps): Record {
+  const url = absoluteMeshUrl(meshUrl);
+  const isPrimitive = selectedPreset.kind === "primitive";
+  const geometryName = isPrimitive ? primitiveGeometryName(selectedPreset.id) : "";
+  const needsUpright = isPrimitive && UPRIGHT_PRIMITIVES.has(selectedPreset.id);
+  // 1.5708 ≈ π/2. Kept literal so the snippet stays paste-able.
+  const uprightRotation: [number, number, number] = [1.5708, 0, 0];
+  const mode = options.renderMode ?? "solid";
+  const palette = options.glyphPalette ?? "default";
+  const useColors = options.useColors !== false;
+  const autoCenter = options.autoCenter !== false;
+  const lineHeight = options.lineHeight ?? 1;
+  const featureEdges = options.featureEdges ?? 0;
+  const rotX = options.rotX ?? 0;
+  const rotY = options.rotY ?? 0;
+  const zoom = options.zoom ?? 0.4;
+  const perspective = options.perspective;
+  const isOrtho = perspective === false;
+  const distance = typeof perspective === "number" ? perspective : 3;
+  const target = options.target ?? [0, 0, 0];
+  const hasTarget = target[0] !== 0 || target[1] !== 0 || target[2] !== 0;
+
+  const lightDir = dirFromSpherical(options.lightAzimuth ?? 50, options.lightElevation ?? 45);
+  const lightIntensity = options.lightIntensity ?? 1;
+  const lightColor = options.lightColor ?? "#ffffff";
+  const ambientIntensity = options.ambientIntensity ?? 0.4;
+  const ambientColor = options.ambientColor ?? "#ffffff";
+
+  // ── React ────────────────────────────────────────────────────────────
+  const cameraComponentName = isOrtho ? "GlyphOrthographicCamera" : "GlyphPerspectiveCamera";
+  const cameraOpenTag = isOrtho
+    ? ``
+    : ``;
+  const cameraCloseTag = isOrtho ? `` : ``;
+  const featureEdgesProp = mode === "wireframe" ? ` featureEdges={${fmt(featureEdges)}}` : "";
+  const targetReact = hasTarget ? `\n      target={${vec3(target)}}` : "";
+  const meshTagReact = isPrimitive
+    ? ``
+    : ``;
+
+  const react = `import {
+  ${cameraComponentName},
+  GlyphScene,
+  GlyphMesh,
+  GlyphOrbitControls,
+} from "@glyphcss/react";
+
+const directionalLight = {
+  direction: ${vec3(lightDir)},
+  intensity: ${fmt(lightIntensity)},
+  color: "${lightColor}",
+};
+const ambientLight = { intensity: ${fmt(ambientIntensity)}, color: "${ambientColor}" };
+
+export function App() {
+  return (
+    ${cameraOpenTag}
+      
+        
+        ${meshTagReact}
+      
+    ${cameraCloseTag}
+  );
+}`;
+
+  // ── Vue ──────────────────────────────────────────────────────────────
+  const cameraOpenTagVue = isOrtho
+    ? ``
+    : ``;
+  const cameraCloseTagVue = isOrtho ? `` : ``;
+  const featureEdgesVue = mode === "wireframe" ? `\n    :feature-edges="${fmt(featureEdges)}"` : "";
+  const targetVue = hasTarget ? `\n    :target="${vec3(target)}"` : "";
+  const meshTagVue = isPrimitive
+    ? ``
+    : ``;
+
+  const vue = `
+
+`;
+
+  // ── Vanilla JS ───────────────────────────────────────────────────────
+  const createCameraCall = isOrtho
+    ? `createGlyphOrthographicCamera({ rotX: ${fmt(rotX)}, rotY: ${fmt(rotY)}, zoom: ${fmt(zoom)} })`
+    : `createGlyphPerspectiveCamera({\n  rotX: ${fmt(rotX)},\n  rotY: ${fmt(rotY)},\n  zoom: ${fmt(zoom)},\n  distance: ${fmt(distance)},\n})`;
+  const cameraImport = isOrtho ? "createGlyphOrthographicCamera" : "createGlyphPerspectiveCamera";
+  const featureEdgesV = mode === "wireframe" ? `\n  featureEdges: ${fmt(featureEdges)},` : "";
+  const targetV = hasTarget ? `\ncamera.target = ${vec3(target)};` : "";
+  const meshImportV = isPrimitive ? "" : "\n  loadMesh,";
+  const polygonsImportV = isPrimitive ? '\nimport { resolveGeometry } from "@glyphcss/core";' : "";
+  const meshLoadV = isPrimitive
+    ? `const polygons = resolveGeometry("${geometryName}", { size: 1 });
+scene.add(polygons${needsUpright ? `, { rotation: ${vec3(uprightRotation)} }` : ""});`
+    : `const { polygons } = await loadMesh("${url}");
+scene.add(polygons);`;
+
+  const vanilla = `import {
+  ${cameraImport},
+  createGlyphScene,
+  createGlyphOrbitControls,${meshImportV}
+} from "glyphcss";${polygonsImportV}
+
+const host = document.querySelector("#scene")!;
+
+const camera = ${createCameraCall};${targetV}
+
+const scene = createGlyphScene(host, {
+  camera,
+  mode: "${mode}",
+  cols: 100,
+  rows: 30,
+  glyphPalette: "${palette}",
+  useColors: ${useColors},
+  autoCenter: ${autoCenter},
+  lineHeight: ${fmt(lineHeight)},${featureEdgesV}
+  directionalLight: {
+    direction: ${vec3(lightDir)},
+    intensity: ${fmt(lightIntensity)},
+    color: "${lightColor}",
+  },
+  ambientLight: { intensity: ${fmt(ambientIntensity)}, color: "${ambientColor}" },
+});
+
+${meshLoadV}
+
+createGlyphOrbitControls(scene, { drag: true, wheel: true });`;
+
+  // ── HTML (custom elements) ──────────────────────────────────────────
+  const cameraHtmlTag = isOrtho ? "glyph-orthographic-camera" : "glyph-perspective-camera";
+  const cameraOpenHtml = isOrtho
+    ? ``
+    : ``;
+  const cameraCloseHtml = ``;
+  const featureEdgesHtml = mode === "wireframe" ? ` feature-edges="${fmt(featureEdges)}"` : "";
+  const meshTagHtml = isPrimitive
+    ? ``
+    : ``;
+
+  const html = `
+
+  
+    
+  
+  
+    ${cameraOpenHtml}
+      
+        
+        ${meshTagHtml}
+      
+    ${cameraCloseHtml}
+  
+`;
+
+  return { html, vanilla, react, vue };
+}
+
+const TAB_LABEL: Record = { html: "HTML", vanilla: "JS", react: "React", vue: "Vue" };
+const TAB_ORDER: Tab[] = ["html", "vanilla", "react", "vue"];
+
+export function CodePanel({ meshUrl, options, selectedPreset }: CodePanelProps) {
+  const [tab, setTab] = useState("react");
+  const [copied, setCopied] = useState(false);
+  const [collapsed, setCollapsed] = useState(false);
+  const snippets = useMemo(
+    () => generateSnippets({ meshUrl, options, selectedPreset }),
+    [meshUrl, options, selectedPreset],
+  );
+
+  const handleCopy = useCallback(async () => {
+    try {
+      await navigator.clipboard.writeText(snippets[tab]);
+      setCopied(true);
+      setTimeout(() => setCopied(false), 1200);
+    } catch {
+      /* no-op */
+    }
+  }, [snippets, tab]);
+
+  return (
+    
+  );
+}
diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx
index f7a99681..0e42ab74 100644
--- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx
+++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx
@@ -1,6 +1,7 @@
 import { useCallback, useMemo, useState, useRef } from "react";
 import { Inspector, type InspectorMesh } from "../Inspector";
-import { GlyphcssScene } from "../GlyphcssScene";
+import { GlyphScene } from "../GlyphScene";
+import { CodePanel } from "./CodePanel";
 import {
   Dock,
   DockModel,
@@ -12,7 +13,7 @@ import {
 import { ModelsSidebar } from "../ModelsSidebar";
 import { DropOverlay } from "../DropOverlay";
 import { StatsOverlay } from "../StatsOverlay";
-import type { GlyphcssMetrics, SceneOptionsState } from "./types";
+import type { GlyphMetrics, SceneOptionsState } from "./types";
 import "./gallery-workbench.css";
 import {
   PRESETS,
@@ -144,7 +145,7 @@ function CopySceneButton() {
   const [copied, setCopied] = useState(false);
 
   const handleCopy = useCallback(async () => {
-    const strip = document.querySelector(".glyphcss-demo__strip") as HTMLElement | null;
+    const strip = document.querySelector(".glyph-output") as HTMLElement | null;
     if (!strip) return;
     const parsed = parseStripCells(strip);
     if (!parsed) return;
@@ -200,10 +201,8 @@ const DEFAULT_SCENE: SceneOptionsState = {
   animationPaused: false,
   animationTimeScale: 1,
   autoCenter: true,
+  autoRotate: false,
   interactive: true,
-  showAxes: false,
-  showLight: false,
-  showGround: false,
   zoom: 0.25,
   rotX: 65,
   rotY: 45,
@@ -220,6 +219,8 @@ const DEFAULT_SCENE: SceneOptionsState = {
   glyphPalette: "default",
   lineHeight: 1.0,
   useColors: true,
+  smoothShading: false,
+  creaseAngle: 60,
   dragMode: "orbit",
   fpvLook: true,
   fpvMove: true,
@@ -234,7 +235,7 @@ const DEFAULT_SCENE: SceneOptionsState = {
   fpvInvertY: false,
 };
 
-const EMPTY_METRICS: GlyphcssMetrics = {
+const EMPTY_METRICS: GlyphMetrics = {
   measuredAt: 0,
   cells: 0,
   edges: 0,
@@ -269,8 +270,8 @@ export default function GalleryWorkbench() {
   // so the preset's zoom/rotX/rotY still win — but only after the first tick.
   const [sceneOptions, setSceneOptions] = useState(DEFAULT_SCENE);
   const [presetId, setPresetId] = useState(initialPreset.id);
-  const [meshUrl, setMeshUrl] = useState(initialPreset.url);
-  const [metrics, setMetrics] = useState(EMPTY_METRICS);
+  const [meshUrl, setMeshUrl] = useState(initialPreset.kind !== "primitive" ? initialPreset.url : "");
+  const [metrics, setMetrics] = useState(EMPTY_METRICS);
   const [selectedAnimation, setSelectedAnimation] = useState("");
   const [animationClips, setAnimationClips] = useState>([]);
   const [modelSearch, setModelSearch] = useState("");
@@ -449,8 +450,9 @@ export default function GalleryWorkbench() {
 
       
- setMetrics((m) => ({ ...m, bakeMs: ms }))} onCameraChange={handleCameraChange} @@ -463,6 +465,7 @@ export default function GalleryWorkbench() { animationTimeScale={sceneOptions.animationTimeScale} /> +
@@ -477,6 +480,8 @@ export default function GalleryWorkbench() { glyphPalette={sceneOptions.glyphPalette} lineHeight={sceneOptions.lineHeight} useColors={sceneOptions.useColors} + smoothShading={sceneOptions.smoothShading} + creaseAngle={sceneOptions.creaseAngle} onUpdateScene={updateScene} /> ; } -// The actual model loading (fetch + parse) happens inside the GlyphcssScene +// The actual model loading (fetch + parse) happens inside the GlyphScene // runtime. This hook's job is to resolve the URL and per-preset camera // defaults and push them into state when the selection changes. export function usePresetLoader({ @@ -20,23 +20,33 @@ export function usePresetLoader({ autoZoomPresetRef, }: UsePresetLoaderOptions): void { useEffect(() => { - const url = selectedDroppedSource - ? URL.createObjectURL(selectedDroppedSource.primaryFile) - : selectedPreset.url; + // Primitives carry no URL — GlyphScene reads the preset directly via + // selectedPreset and calls setPolygons(). We still call onMeshUrl so the + // meshUrl state stays in sync (GlyphScene uses selectedPreset.id to detect + // the primitive branch, not the URL value). + if (selectedPreset.kind !== "primitive") { + const url = selectedDroppedSource + ? URL.createObjectURL(selectedDroppedSource.primaryFile) + : selectedPreset.url; - onMeshUrl(url); + onMeshUrl(url); + if (autoZoomPresetRef.current !== selectedPreset.id) { + autoZoomPresetRef.current = selectedPreset.id; + onSceneDefaults(selectedPreset.zoom, selectedPreset.rotX, selectedPreset.rotY); + } + + return () => { + if (selectedDroppedSource) { + URL.revokeObjectURL(url); + } + }; + } + + // Primitive path: no URL fetch needed. if (autoZoomPresetRef.current !== selectedPreset.id) { autoZoomPresetRef.current = selectedPreset.id; - // Pass through undefined when the preset doesn't override — the consumer - // keeps its current DEFAULT_SCENE value rather than getting a stale fallback. onSceneDefaults(selectedPreset.zoom, selectedPreset.rotX, selectedPreset.rotY); } - - return () => { - if (selectedDroppedSource) { - URL.revokeObjectURL(url); - } - }; }, [selectedPreset.id, selectedDroppedSource?.id]); } diff --git a/website/src/components/GalleryWorkbench/index.ts b/website/src/components/GalleryWorkbench/index.ts index 8862b956..d8c130a2 100644 --- a/website/src/components/GalleryWorkbench/index.ts +++ b/website/src/components/GalleryWorkbench/index.ts @@ -1,2 +1,2 @@ export { default as GalleryWorkbench } from "./GalleryWorkbench"; -export type { GlyphcssMetrics, SceneOptionsState, PresetModel } from "./types"; +export type { GlyphMetrics, SceneOptionsState, PresetModel } from "./types"; diff --git a/website/src/components/GalleryWorkbench/presets/attributions.ts b/website/src/components/GalleryWorkbench/presets/attributions.ts index 73604bed..9d50593a 100644 --- a/website/src/components/GalleryWorkbench/presets/attributions.ts +++ b/website/src/components/GalleryWorkbench/presets/attributions.ts @@ -165,6 +165,11 @@ export const UTAH_TEAPOT_ATTRIBUTION: ModelAttribution = { sourceUrl: "https://graphics.cs.utah.edu/teapot/", }; +export const PRIMITIVE_ATTRIBUTION: ModelAttribution = { + creator: "Built-in primitive", + sourceUrl: "https://github.com/apresmoi/glyphcss", +}; + export function openGameArtAttribution( creator: string, slug: string, diff --git a/website/src/components/GalleryWorkbench/presets/buckets.ts b/website/src/components/GalleryWorkbench/presets/buckets.ts index 47b9b71b..3707470f 100644 --- a/website/src/components/GalleryWorkbench/presets/buckets.ts +++ b/website/src/components/GalleryWorkbench/presets/buckets.ts @@ -1,6 +1,6 @@ import type { GalleryBucket, PresetModel } from "../types"; -export const GALLERY_BUCKET_ORDER: GalleryBucket[] = ["Solid", "Textured", "Animated", "Voxel"]; +export const GALLERY_BUCKET_ORDER: GalleryBucket[] = ["Primitives", "Solid", "Textured", "Animated", "Voxel"]; export const ANIMATED_PRESET_IDS = new Set([ "glb-poly-pizza-cow", @@ -21,6 +21,7 @@ export function isAnimatedPreset(preset: Pick ({ + ...poly, + vertices: poly.vertices.map(([x, y, z]) => [x, -z, y] as Vec3), + })); +} + +const PRIMITIVE_DEFAULTS = { + kind: "primitive" as const, + category: "Primitives", + galleryBucket: "Primitives" as const, + attribution: PRIMITIVE_ATTRIBUTION, + // rotX / rotY are in DEGREES — gallery state stores degrees and converts to + // radians once before sending to the runtime camera. Matches the rest of the + // preset list. The default (65°, 45°) is the classic isometric view. + zoom: 0.1, + rotX: 65, + rotY: 45, +}; + +const PRIMITIVE_PRESETS: PrimitivePreset[] = [ + // Platonic solids + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-tetrahedron", + label: "Tetrahedron", + generatePolygons: () => resolveGeometry("tetrahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-cube", + label: "Cube", + generatePolygons: () => resolveGeometry("cube", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-octahedron", + label: "Octahedron", + generatePolygons: () => resolveGeometry("octahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-dodecahedron", + label: "Dodecahedron", + generatePolygons: () => resolveGeometry("dodecahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-icosahedron", + label: "Icosahedron", + generatePolygons: () => resolveGeometry("icosahedron", { size: 1 }), + }, + // Round / parametric + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-sphere", + label: "Sphere", + generatePolygons: () => resolveGeometry("sphere", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-cylinder", + label: "Cylinder", + generatePolygons: () => uprightAlongZ(resolveGeometry("cylinder", { size: 1 })), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-cone", + label: "Cone", + generatePolygons: () => uprightAlongZ(resolveGeometry("cone", { size: 1 })), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-torus", + label: "Torus", + rotX: 75, + generatePolygons: () => resolveGeometry("torus", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-pyramid", + label: "Pyramid", + generatePolygons: () => uprightAlongZ(resolveGeometry("pyramid", { size: 1 })), + }, + // Kepler-Poinsot star polyhedra + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-small-stellated-dodecahedron", + label: "Small Stellated Dodecahedron", + generatePolygons: () => resolveGeometry("smallStellatedDodecahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-great-dodecahedron", + label: "Great Dodecahedron", + generatePolygons: () => resolveGeometry("greatDodecahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-great-stellated-dodecahedron", + label: "Great Stellated Dodecahedron", + generatePolygons: () => resolveGeometry("greatStellatedDodecahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-great-icosahedron", + label: "Great Icosahedron", + generatePolygons: () => resolveGeometry("greatIcosahedron", { size: 1 }), + }, + // Archimedean solids + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-cuboctahedron", + label: "Cuboctahedron", + generatePolygons: () => resolveGeometry("cuboctahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-icosidodecahedron", + label: "Icosidodecahedron", + generatePolygons: () => resolveGeometry("icosidodecahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-truncated-tetrahedron", + label: "Truncated Tetrahedron", + generatePolygons: () => resolveGeometry("truncatedTetrahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-truncated-cube", + label: "Truncated Cube", + generatePolygons: () => resolveGeometry("truncatedCube", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-truncated-octahedron", + label: "Truncated Octahedron", + generatePolygons: () => resolveGeometry("truncatedOctahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-truncated-dodecahedron", + label: "Truncated Dodecahedron", + generatePolygons: () => resolveGeometry("truncatedDodecahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-truncated-icosahedron", + label: "Truncated Icosahedron", + generatePolygons: () => resolveGeometry("truncatedIcosahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-truncated-cuboctahedron", + label: "Truncated Cuboctahedron", + generatePolygons: () => resolveGeometry("truncatedCuboctahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-truncated-icosidodecahedron", + label: "Truncated Icosidodecahedron", + generatePolygons: () => resolveGeometry("truncatedIcosidodecahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-rhombicuboctahedron", + label: "Rhombicuboctahedron", + generatePolygons: () => resolveGeometry("rhombicuboctahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-rhombicosidodecahedron", + label: "Rhombicosidodecahedron", + generatePolygons: () => resolveGeometry("rhombicosidodecahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-snub-cube", + label: "Snub Cube", + generatePolygons: () => resolveGeometry("snubCube", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-snub-dodecahedron", + label: "Snub Dodecahedron", + generatePolygons: () => resolveGeometry("snubDodecahedron", { size: 1 }), + }, + // Catalan solids + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-rhombic-dodecahedron", + label: "Rhombic Dodecahedron", + generatePolygons: () => resolveGeometry("rhombicDodecahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-rhombic-triacontahedron", + label: "Rhombic Triacontahedron", + generatePolygons: () => resolveGeometry("rhombicTriacontahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-triakis-tetrahedron", + label: "Triakis Tetrahedron", + generatePolygons: () => resolveGeometry("triakisTetrahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-triakis-octahedron", + label: "Triakis Octahedron", + generatePolygons: () => resolveGeometry("triakisOctahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-triakis-icosahedron", + label: "Triakis Icosahedron", + generatePolygons: () => resolveGeometry("triakisIcosahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-tetrakis-hexahedron", + label: "Tetrakis Hexahedron", + generatePolygons: () => resolveGeometry("tetrakisHexahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-pentakis-dodecahedron", + label: "Pentakis Dodecahedron", + generatePolygons: () => resolveGeometry("pentakisDodecahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-disdyakis-dodecahedron", + label: "Disdyakis Dodecahedron", + generatePolygons: () => resolveGeometry("disdyakisDodecahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-disdyakis-triacontahedron", + label: "Disdyakis Triacontahedron", + generatePolygons: () => resolveGeometry("disdyakisTriacontahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-deltoidal-icositetrahedron", + label: "Deltoidal Icositetrahedron", + generatePolygons: () => resolveGeometry("deltoidalIcositetrahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-deltoidal-hexecontahedron", + label: "Deltoidal Hexecontahedron", + generatePolygons: () => resolveGeometry("deltoidalHexecontahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-pentagonal-icositetrahedron", + label: "Pentagonal Icositetrahedron", + generatePolygons: () => resolveGeometry("pentagonalIcositetrahedron", { size: 1 }), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-pentagonal-hexecontahedron", + label: "Pentagonal Hexecontahedron", + generatePolygons: () => resolveGeometry("pentagonalHexecontahedron", { size: 1 }), + }, + // Parametric families + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-prism", + label: "Prism (Hexagonal)", + generatePolygons: () => uprightAlongZ(resolveGeometry("prism", { size: 1 })), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-antiprism", + label: "Antiprism (Hexagonal)", + generatePolygons: () => uprightAlongZ(resolveGeometry("antiprism", { size: 1 })), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-bipyramid", + label: "Bipyramid (Hexagonal)", + generatePolygons: () => uprightAlongZ(resolveGeometry("bipyramid", { size: 1 })), + }, + { + ...PRIMITIVE_DEFAULTS, + id: "primitive-trapezohedron", + label: "Trapezohedron (Pentagonal)", + generatePolygons: () => uprightAlongZ(resolveGeometry("trapezohedron", { size: 1 })), + }, +]; export const PRESETS: PresetModel[] = [ + ...PRIMITIVE_PRESETS, { id: "chicken", label: "Chicken", diff --git a/website/src/components/GalleryWorkbench/types.ts b/website/src/components/GalleryWorkbench/types.ts index c206f98c..ddc36d08 100644 --- a/website/src/components/GalleryWorkbench/types.ts +++ b/website/src/components/GalleryWorkbench/types.ts @@ -2,8 +2,10 @@ // type declarations that flow between subfolders (presets/, helpers/, the // component itself) live here. Component-internal types stay local. -export type ModelKind = "obj" | "glb" | "gltf" | "vox"; -export type GalleryBucket = "Solid" | "Textured" | "Animated" | "Voxel"; +import type { Polygon } from "@glyphcss/core"; + +export type ModelKind = "obj" | "glb" | "gltf" | "vox" | "primitive"; +export type GalleryBucket = "Solid" | "Textured" | "Animated" | "Voxel" | "Primitives"; export type PerspectiveMode = "perspective" | "orthographic"; export type DragMode = "orbit" | "pan" | "fpv"; @@ -14,13 +16,10 @@ export interface ModelAttribution { tris?: number; } -export interface PresetModel { +interface BasePreset { id: string; label: string; - kind: ModelKind; category: string; - url: string; - mtlUrl?: string; zoom?: number; rotX?: number; rotY?: number; @@ -28,6 +27,21 @@ export interface PresetModel { attribution?: ModelAttribution; } +interface UrlPreset extends BasePreset { + kind: Exclude; + url: string; + mtlUrl?: string; +} + +export interface PrimitivePreset extends BasePreset { + kind: "primitive"; + url?: never; + mtlUrl?: never; + generatePolygons: () => Polygon[]; +} + +export type PresetModel = UrlPreset | PrimitivePreset; + export interface DroppedModelSource { id: string; label: string; @@ -54,7 +68,7 @@ export interface ObjGalleryPresetFile extends GalleryPresetFile { defaultColor?: string; } -export interface GlyphcssMetrics { +export interface GlyphMetrics { measuredAt: number; cells: number; edges: number; @@ -68,10 +82,8 @@ export interface SceneOptionsState { animationPaused: boolean; animationTimeScale: number; autoCenter: boolean; + autoRotate: boolean; interactive: boolean; - showAxes: boolean; - showLight: boolean; - showGround: boolean; zoom: number; rotX: number; rotY: number; @@ -85,9 +97,11 @@ export interface SceneOptionsState { target: [number, number, number]; renderMode: "wireframe" | "solid"; featureEdges: number; - glyphPalette: "default" | "ascii" | "dots" | "lines" | "blocks" | "stars" | "arrows" | "braille" | "runes" | "math" | "binary" | "hex"; + glyphPalette: "default" | "ascii" | "lines" | "blocks" | "stars" | "arrows" | "math" | "binary" | "hex"; lineHeight: number; useColors: boolean; + smoothShading: boolean; + creaseAngle: number; dragMode: DragMode; fpvLook: boolean; fpvMove: boolean; diff --git a/website/src/components/GlyphDemo.astro b/website/src/components/GlyphDemo.astro new file mode 100644 index 00000000..590abe1d --- /dev/null +++ b/website/src/components/GlyphDemo.astro @@ -0,0 +1,81 @@ +--- +/** + * GlyphDemo — live interactive ASCII scene for docs + landing pages. + * + * Usage in MDX: + * + * + * Renders a `
` strip with N pre-baked rotation frames + a sparse hit
+ * layer of 
hotspots, animated entirely via CSS `steps(N)`. JavaScript + * runs once at init and again only when the user drags / zooms / changes a + * lil-gui control. + * + * The full runtime lives in `src/glyph-runtime.ts`. It's imported as a + * client script so Astro's template parser never sees TS generics or block + * `{ ... }` bodies and mis-classifies them as JSX / template expressions. + */ + +interface Props { + id: string; + geometry?: 'cuboctahedron' | 'icosahedron' | 'cube'; + /** URL of an OBJ / GLB / glTF / VOX mesh. Takes priority over `geometry`. */ + mesh?: string; + controls?: string; + defaults?: string; + showStats?: boolean; + /** Hide the code panel beneath the demo (landing hero, gallery). */ + noCode?: boolean; + /** Hide the lil-gui controls column (landing hero clean view). */ + noControls?: boolean; + /** Auto-rotate the camera around Y from the moment the demo mounts. */ + autoRotate?: boolean; + /** Disable pointer/wheel interactivity. Useful for decorative landing demos. */ + interactive?: boolean; +} + +import '../styles/glyph-demo.css'; + +const { id, geometry, mesh, controls, defaults, showStats, noCode, noControls, autoRotate, interactive } = Astro.props; +--- + +
+
+
+
+
+

+        
+
+
+
+
Loading…
+
+ {!noControls &&
} +
+ {!noCode && ( +
+
+ + + +
+
+
+
+
+ )} +
+ + diff --git a/website/src/components/GlyphcssScene/GlyphcssScene.tsx b/website/src/components/GlyphScene/GlyphScene.tsx similarity index 78% rename from website/src/components/GlyphcssScene/GlyphcssScene.tsx rename to website/src/components/GlyphScene/GlyphScene.tsx index 78eb237c..82c6aa46 100644 --- a/website/src/components/GlyphcssScene/GlyphcssScene.tsx +++ b/website/src/components/GlyphScene/GlyphScene.tsx @@ -1,10 +1,12 @@ import { useEffect, useRef } from "react"; -import type { GlyphcssMetrics, SceneOptionsState } from "../GalleryWorkbench/types"; -import type { ParseAnimationClip } from "@glyphcss/core"; +import type { GlyphMetrics, PresetModel, SceneOptionsState } from "../GalleryWorkbench/types"; +import type { ParseAnimationClip, Polygon } from "@glyphcss/core"; -// Mirror of the handle shape exposed by glyphcss-runtime on demoEl.glyphcssDemo. +// Mirror of the handle shape exposed by glyph-runtime on demoEl.glyphcssDemo. interface DemoHandle { setMeshUrl: (url: string) => Promise; + setPolygons: (polygons: Polygon[]) => void; + setAutoRotate: (enabled: boolean) => void; setTunables: (partial: Record) => void; setControlState: (partial: { autoCenter?: boolean; @@ -45,12 +47,13 @@ interface DemoHandle { }) => void; } -export interface GlyphcssSceneProps { +export interface GlyphSceneProps { meshUrl: string; + selectedPreset?: PresetModel; options: SceneOptionsState; onBuild: (ms: number) => void; onCameraChange?: (cam: { rotX: number; rotY: number; zoom: number; target?: [number, number, number] }) => void; - onStatsChange: (stats: GlyphcssMetrics) => void; + onStatsChange: (stats: GlyphMetrics) => void; onAnimationInfoChange: (info: { clips: Array<{ index: number; name: string; duration: number }> }) => void; selectedAnimation: string; animationPaused: boolean; @@ -60,8 +63,9 @@ export interface GlyphcssSceneProps { const FRAMES = 60; const POLL_INTERVAL_MS = 500; -export function GlyphcssScene({ +export function GlyphScene({ meshUrl, + selectedPreset, options, onBuild, onCameraChange, @@ -70,21 +74,28 @@ export function GlyphcssScene({ selectedAnimation, animationPaused, animationTimeScale, -}: GlyphcssSceneProps) { +}: GlyphSceneProps) { const hostRef = useRef(null); const mountedRef = useRef(false); - const demoIdRef = useRef(`glyphcss-scene-${Math.random().toString(36).slice(2)}`); + const demoIdRef = useRef(`glyph-scene-${Math.random().toString(36).slice(2)}`); const pollIntervalRef = useRef | null>(null); const prevClipCountRef = useRef(0); const prevBakeMsRef = useRef(0); + // Keep a live ref to selectedPreset so the initial waitForHandle callback + // (which closes over mount-time values) can access the current preset. + const selectedPresetRef = useRef(selectedPreset); + selectedPresetRef.current = selectedPreset; // Last camera state applied via setTunables — guards against echo: when the // sidebar sets a value and the poll reads it back, we must not re-fire onCameraChange. const lastAppliedCameraRef = useRef<{ rotX: number; rotY: number; zoom: number; target: [number, number, number] } | null>(null); + // Track auto-rotate so the poll doesn't echo rotY changes back through setTunables + // while the RAF loop is spinning, which would cause setTunables to call stopAutoRotate. + const autoRotateRef = useRef(false); function getHandle(): DemoHandle | null { const host = hostRef.current; if (!host) return null; - const demoEl = host.querySelector(".glyphcss-demo") as (HTMLElement & { glyphcssDemo?: DemoHandle }) | null; + const demoEl = host.querySelector(".glyph-demo") as (HTMLElement & { glyphcssDemo?: DemoHandle }) | null; return demoEl?.glyphcssDemo ?? null; } @@ -100,28 +111,25 @@ export function GlyphcssScene({ }); const demoId = demoIdRef.current; + const isPrimitive = selectedPresetRef.current?.kind === "primitive"; host.innerHTML = ` -
-
-
-
-
-

-              
-
-
+
+
+
+
-
Loading…
+
Loading…
`; - import("../../glyphcss-runtime").then(({ initAllGlyphcssDemos }) => { - initAllGlyphcssDemos(); + import("../../glyph-runtime").then(({ initAllGlyphDemos }) => { + initAllGlyphDemos(); // Start polling for stats and animation info once the demo initializes. // The handle appears asynchronously (after the initial mesh load). @@ -144,6 +152,8 @@ export function GlyphcssScene({ featureEdges: options.featureEdges, glyphPalette: options.glyphPalette, useColors: options.useColors, + smoothShading: options.smoothShading, + creaseAngle: options.creaseAngle, }); handle.setDragMode(options.dragMode); handle.setFpvOptions({ @@ -167,6 +177,13 @@ export function GlyphcssScene({ keyColor: options.lightColor, ambientColor: options.ambientColor, }); + // If the initial preset is a primitive, load its polygons now. The + // runtime had no data-mesh attribute so it rendered the placeholder + // cuboctahedron; replace it with the actual primitive geometry. + const initialPreset = selectedPresetRef.current; + if (initialPreset?.kind === "primitive") { + handle.setPolygons(initialPreset.generatePolygons()); + } startPolling(handle); }; setTimeout(waitForHandle, 300); @@ -187,7 +204,7 @@ export function GlyphcssScene({ const stats = handle.getStats(); const animInfo = handle.getAnimationInfo(); - const metrics: GlyphcssMetrics = { + const metrics: GlyphMetrics = { measuredAt: Date.now(), cells: stats.cols * stats.rows, edges: stats.edges, @@ -215,18 +232,24 @@ export function GlyphcssScene({ // setTunables useEffect → rebuildSceneFromGeometry, the camera is recreated // every 500 ms and the FPV state resets. FPV manages its own camera; the // sidebar values should be left at the pre-FPV snapshot until exit. + // + // Skip rotY sync while auto-rotate is running: auto-rotate's RAF loop + // continuously advances camera.rotY, and if that propagates through + // onCameraChange → setTunables({ rotY }) it calls stopAutoRotate() + // on every poll cycle, killing the animation after one tick. if (onCameraChange && handle.getDragMode() !== "fpv") { const cam = handle.getCameraState(); const rotXDeg = (cam.rotX * 180) / Math.PI; const rotYDeg = (((cam.rotY * 180) / Math.PI) % 360 + 360) % 360; const last = lastAppliedCameraRef.current; const TOL = 0.01; + const isAutoRotating = autoRotateRef.current; // Only fire if the runtime camera meaningfully diverges from the last value // the sidebar sent, preventing the setTunables → getCameraState echo loop. if ( !last || + (!isAutoRotating && Math.abs(rotYDeg - last.rotY) > TOL) || Math.abs(rotXDeg - last.rotX) > TOL || - Math.abs(rotYDeg - last.rotY) > TOL || Math.abs(cam.scale - last.zoom) > TOL || Math.abs(cam.target[0] - last.target[0]) > TOL || Math.abs(cam.target[1] - last.target[1]) > TOL || @@ -239,16 +262,20 @@ export function GlyphcssScene({ }, POLL_INTERVAL_MS); } - // React to meshUrl changes. + // React to meshUrl/preset changes. useEffect(() => { const host = hostRef.current; if (!host || !mountedRef.current) return; const handle = getHandle(); if (!handle) return; - void handle.setMeshUrl(meshUrl); + if (selectedPreset?.kind === "primitive") { + handle.setPolygons(selectedPreset.generatePolygons()); + } else { + void handle.setMeshUrl(meshUrl); + } // Reset clip tracking so the Dock updates on next poll. prevClipCountRef.current = -1; - }, [meshUrl]); + }, [meshUrl, selectedPreset?.id]); // React to camera/zoom/rotX/rotY changes. useEffect(() => { @@ -286,6 +313,14 @@ export function GlyphcssScene({ handle.setControlState({ autoCenter: options.autoCenter }); }, [options.autoCenter]); + // React to autoRotate toggle. + useEffect(() => { + autoRotateRef.current = options.autoRotate; + const handle = getHandle(); + if (!handle) return; + handle.setAutoRotate(options.autoRotate); + }, [options.autoRotate]); + // React to target changes. useEffect(() => { const handle = getHandle(); @@ -336,6 +371,21 @@ export function GlyphcssScene({ handle.setTunables({ useColors: options.useColors }); }, [options.useColors]); + // React to smoothShading toggle. + useEffect(() => { + const handle = getHandle(); + if (!handle) return; + handle.setTunables({ smoothShading: options.smoothShading }); + }, [options.smoothShading]); + + // React to creaseAngle slider. + useEffect(() => { + const handle = getHandle(); + if (!handle) return; + handle.setTunables({ creaseAngle: options.creaseAngle }); + }, [options.creaseAngle]); + + // React to animation clip selection. useEffect(() => { const handle = getHandle(); diff --git a/website/src/components/GlyphScene/index.ts b/website/src/components/GlyphScene/index.ts new file mode 100644 index 00000000..6a1e9ab1 --- /dev/null +++ b/website/src/components/GlyphScene/index.ts @@ -0,0 +1,2 @@ +export { GlyphScene } from "./GlyphScene"; +export type { GlyphSceneProps } from "./GlyphScene"; diff --git a/website/src/components/GlyphcssDemo.astro b/website/src/components/GlyphcssDemo.astro deleted file mode 100644 index 09b77ddc..00000000 --- a/website/src/components/GlyphcssDemo.astro +++ /dev/null @@ -1,75 +0,0 @@ ---- -/** - * GlyphcssDemo — live interactive ASCII scene for docs + landing pages. - * - * Usage in MDX: - * - * - * Renders a `
` strip with N pre-baked rotation frames + a sparse hit
- * layer of 
hotspots, animated entirely via CSS `steps(N)`. JavaScript - * runs once at init and again only when the user drags / zooms / changes a - * lil-gui control. - * - * The full runtime lives in `src/glyphcss-runtime.ts`. It's imported as a - * client script so Astro's template parser never sees TS generics or block - * `{ ... }` bodies and mis-classifies them as JSX / template expressions. - */ - -interface Props { - id: string; - geometry?: 'cuboctahedron' | 'icosahedron' | 'cube'; - /** URL of an OBJ / GLB / glTF / VOX mesh. Takes priority over `geometry`. */ - mesh?: string; - controls?: string; - defaults?: string; - showStats?: boolean; - /** Hide the code panel beneath the demo (landing hero, gallery). */ - noCode?: boolean; - /** Hide the lil-gui controls column (landing hero clean view). */ - noControls?: boolean; -} - -import '../styles/glyphcss-demo.css'; - -const { id, geometry, mesh, controls, defaults, showStats, noCode, noControls } = Astro.props; ---- - -
-
-
-
-
-

-        
-
-
-
-
Loading…
-
- {!noControls &&
} -
- {!noCode && ( -
-
- - - -
-
-
-
-
- )} -
- - diff --git a/website/src/components/GlyphcssScene/index.ts b/website/src/components/GlyphcssScene/index.ts deleted file mode 100644 index 9b1d7637..00000000 --- a/website/src/components/GlyphcssScene/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { GlyphcssScene } from "./GlyphcssScene"; -export type { GlyphcssSceneProps } from "./GlyphcssScene"; diff --git a/website/src/components/StatsOverlay/StatsOverlay.tsx b/website/src/components/StatsOverlay/StatsOverlay.tsx index 08dbb501..25c43970 100644 --- a/website/src/components/StatsOverlay/StatsOverlay.tsx +++ b/website/src/components/StatsOverlay/StatsOverlay.tsx @@ -4,7 +4,7 @@ import Stats from "stats-js/src/Stats.js"; // Terminal aesthetic override for stats-js panels. // stats-js injects its own inline styles; we have to beat them with !important. const STATS_STYLE = ` - .glyphcss-stats-host { + .glyph-stats-host { position: fixed; right: 12px; bottom: 12px; @@ -16,7 +16,7 @@ const STATS_STYLE = ` gap: 4px; font-family: ui-monospace, "JetBrains Mono", "SF Mono", "Menlo", monospace !important; } - .glyphcss-stats-host > div { + .glyph-stats-host > div { border: 1px solid rgba(255, 232, 184, 0.18) !important; background: #0b0d10 !important; border-radius: 0 !important; @@ -25,10 +25,10 @@ const STATS_STYLE = ` /* No display override here — stats-js sets display: none on inactive canvases (each panel has 3 canvases for FPS/MS/MB modes), and forcing display: block makes all three render at once, triplicating each panel. */ - .glyphcss-stats-host canvas { + .glyph-stats-host canvas { filter: hue-rotate(180deg) saturate(0.6) brightness(0.75) !important; } - .glyphcss-stats-host > div > div:first-child { + .glyph-stats-host > div > div:first-child { background: #0b0d10 !important; color: rgba(255, 232, 184, 0.94) !important; font-family: ui-monospace, "JetBrains Mono", "SF Mono", "Menlo", monospace !important; @@ -44,12 +44,12 @@ export function StatsOverlay(): null { useEffect(() => { // Inject terminal style for stats panels const styleEl = document.createElement("style"); - styleEl.setAttribute("data-glyphcss-stats", ""); + styleEl.setAttribute("data-glyph-stats", ""); styleEl.textContent = STATS_STYLE; document.head.appendChild(styleEl); const statsContainer = document.createElement("div"); - statsContainer.className = "glyphcss-stats-host"; + statsContainer.className = "glyph-stats-host"; statsContainer.style.position = "fixed"; statsContainer.style.right = "12px"; statsContainer.style.bottom = "12px"; diff --git a/website/src/content/docs/api/headless.mdx b/website/src/content/docs/api/headless.mdx index 8e4491fb..6057a98f 100644 --- a/website/src/content/docs/api/headless.mdx +++ b/website/src/content/docs/api/headless.mdx @@ -3,20 +3,24 @@ title: Headless API description: Imperative API for building scenes outside React / Vue. --- -The `glyphcss` vanilla package exposes a glyphcss-shaped imperative API plus the raw -`glyphcss-core` math primitives. +The `glyphcss` vanilla package exposes a glyph-shaped imperative API plus the raw +`glyph-core` math primitives. -## `createGlyphcssScene` +## `createGlyphScene` The main entry point. Creates and mounts a scene into a host element and returns a -`GlyphcssSceneHandle` with methods to add meshes, hotspots, and update options. +`GlyphSceneHandle` with methods to add meshes, hotspots, and update options. +**Always construct a camera first and pass it in.** ```ts -import { createGlyphcssScene } from "glyphcss"; +import { createGlyphCamera, createGlyphScene } from "glyphcss"; const host = document.querySelector("#scene")!; -const scene = createGlyphcssScene(host, { +const camera = createGlyphCamera({ rotX: 0.4, zoom: 0.4 }); + +const scene = createGlyphScene(host, { + camera, mode: "solid", // "wireframe" | "solid" | "voxel" cols: 100, // grid width in character columns rows: 30, // grid height in character rows @@ -30,7 +34,7 @@ const scene = createGlyphcssScene(host, { ### `.add(polygons, transform?)` -Register a `Polygon[]` as a mesh. Returns a `GlyphcssMeshHandle`. +Register a `Polygon[]` as a mesh. Returns a `GlyphMeshHandle`. ```ts import { cubePolygons } from "@glyphcss/core"; @@ -80,17 +84,21 @@ one runnable block: ```ts import { - createGlyphcssScene, - createGlyphcssOrbitControls, + createGlyphCamera, + createGlyphScene, + createGlyphOrbitControls, } from "glyphcss"; import { cubePolygons, octahedronPolygons, axesHelperPolygons } from "@glyphcss/core"; const host = document.querySelector("#scene")!; -// 1. Create the scene. -const scene = createGlyphcssScene(host, { mode: "solid", cols: 100, rows: 30 }); +// 1. Create the camera first. +const camera = createGlyphCamera({ rotX: 0.4, zoom: 0.4 }); + +// 2. Create the scene with the camera. +const scene = createGlyphScene(host, { camera, mode: "solid", cols: 100, rows: 30 }); -// 2. Add meshes. Each call returns a handle. +// 3. Add meshes. Each call returns a handle. const cubeHandle = scene.add( cubePolygons({ center: [-1, 0, 0], size: 0.8, color: "#4488ff" }) ); @@ -99,23 +107,23 @@ const octaHandle = scene.add( ); scene.add(axesHelperPolygons({ size: 1.5 })); -// 3. Add a hotspot. +// 4. Add a hotspot. const hotspot = scene.addHotspot( { id: "octa-top", at: [1, 0.8, 0], size: [4, 2] }, () => alert("octahedron top"), ); -// 4. Attach orbit controls. -const controls = createGlyphcssOrbitControls(scene, { drag: true, wheel: true }); +// 5. Attach orbit controls. +const controls = createGlyphOrbitControls(scene, { drag: true, wheel: true }); -// 5. Update options at any time. +// 6. Update options at any time. scene.setOptions({ mode: "wireframe" }); -// 6. Move a mesh. +// 7. Move a mesh. cubeHandle.setTransform({ position: [-2, 0, 0] }); scene.rerender(); -// 7. Cleanup everything. +// 8. Cleanup everything. hotspot.remove(); controls.destroy(); octaHandle.dispose(); @@ -125,24 +133,27 @@ scene.destroy(); ## Camera factories +Two cameras: orthographic (default, `createGlyphCamera`) and perspective. +First-person view is `createGlyphPerspectiveCamera` + `createGlyphFirstPersonControls`. + ```ts import { - createGlyphcssPerspectiveCamera, - createGlyphcssOrthographicCamera, - createGlyphcssFirstPersonCamera, + createGlyphCamera, + createGlyphPerspectiveCamera, + createGlyphOrthographicCamera, } from "glyphcss"; -// Perspective (default) -const perspective = createGlyphcssPerspectiveCamera({ - rotX: 0.4, rotY: 0, - distance: 3, scale: 0.32, stretch: 0.95, -}); +// Orthographic — default alias, best for voxel/iso scenes +const camera = createGlyphCamera({ rotX: 0.4, zoom: 0.4 }); -// Orthographic — no foreshortening -const ortho = createGlyphcssOrthographicCamera({ rotX: 0.4, zoom: 0.4 }); +// Orthographic — explicit form +const ortho = createGlyphOrthographicCamera({ rotX: 0.4, zoom: 0.4 }); -// First-person — eye at origin -const fpv = createGlyphcssFirstPersonCamera({ rotX: Math.PI / 2, focal: 1 }); +// Perspective — foreshortened; required for first-person controls +const perspective = createGlyphPerspectiveCamera({ + rotX: 0.4, rotY: 0, + distance: 3, zoom: 0.32, stretch: 0.95, +}); // All camera properties are mutable after creation: perspective.rotY = Math.PI / 4; @@ -152,9 +163,9 @@ perspective.distance = 4; scene.setOptions({ camera: ortho }); ``` -## Lower-level pieces (`glyphcss-core`) +## Lower-level pieces (`glyph-core`) -`glyphcss-core` is browser-agnostic (no DOM globals) and can run in Node or a +`glyph-core` is browser-agnostic (no DOM globals) and can run in Node or a web worker. ```ts @@ -163,12 +174,12 @@ import { rasterize, projectHotspots, icosahedronPolygons, -} from "glyphcss-core"; -import { createGlyphcssPerspectiveCamera } from "glyphcss"; +} from "glyph-core"; +import { createGlyphPerspectiveCamera } from "glyphcss"; const triangles = icosahedronPolygons({ center: [0, 0, 0], size: 1, color: "#44ffcc" }); -const camera = createGlyphcssPerspectiveCamera({ rotX: 0.4, scale: 0.32 }); +const camera = createGlyphPerspectiveCamera({ rotX: 0.4, scale: 0.32 }); const grid = { cols: 160, rows: 48, cellAspect: 2.0 }; const ctx = buildSceneContext({ camera, grid, triangles, mode: "solid" }); diff --git a/website/src/content/docs/api/html.mdx b/website/src/content/docs/api/html.mdx new file mode 100644 index 00000000..952bec39 --- /dev/null +++ b/website/src/content/docs/api/html.mdx @@ -0,0 +1,166 @@ +--- +title: HTML API +description: Custom elements registered by the glyphcss/elements module. +--- + +`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`, +`feature-edges`, `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"` | 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`, `ascii`, `lines`, `blocks`, `stars`, `arrows`, `math`, `binary`, `hex` |
+| `use-colors` | bool | Emit color spans in the output |
+| `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 |
+
+### `` / `` / ``
+
+`` is the ergonomic default — an alias for
+``. All three accept the same orientation
+attributes; perspective additionally accepts `distance` and `stretch`.
+
+| Attribute | Notes |
+|---|---|
+| `rot-x` | Pitch in radians |
+| `rot-y` | Yaw in radians |
+| `zoom` | Scale factor |
+| `distance` | Perspective only |
+| `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 radians |
+| `normalize` | flag — auto-fit imported mesh to a unit bbox |
+
+### ``
+
+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 |
+
+### `` / ``
+
+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 |
+| `animate-speed` | Orbit-only: auto-rotation speed |
+| `animate-axis` | Orbit-only: `x` \| `y` \| `z` |
+
+## Scene-ready handshake
+
+Custom elements register asynchronously. If you need to call imperative
+methods on the scene from JavaScript, listen for `glyph-scene-ready` on
+the `` element:
+
+```ts
+const sceneEl = document.querySelector("glyph-scene")!;
+sceneEl.addEventListener("glyph-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
diff --git a/website/src/content/docs/api/react.mdx b/website/src/content/docs/api/react.mdx
new file mode 100644
index 00000000..141d84cc
--- /dev/null
+++ b/website/src/content/docs/api/react.mdx
@@ -0,0 +1,130 @@
+---
+title: React API
+description: React components, hooks, and types from @glyphcss/react.
+---
+
+`@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 |
+| `` | Default camera (alias for ``) |
+| `` | Foreshortened projection — needs a `distance` |
+| `` | Parallel projection — best for iso / voxel scenes |
+| `` | Polygon registration. Pass `polygons={…}`, `src="…"`, or `geometry="cube"` |
+| `` | 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(opts)` | 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 radians.
+
+## 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 the full `@glyphcss/core` surface — every
+polygon factory (`cubePolygons`, `dodecahedronPolygons`, …), the
+`resolveGeometry` registry, the math primitives, and the mesh parsers
+(`loadMesh`, `parseObj`, `parseGltf`, `parseVox`). You usually only need
+to import from `@glyphcss/react`.
+
+## 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
diff --git a/website/src/content/docs/api/types.mdx b/website/src/content/docs/api/types.mdx
index 75eee0e4..78c91581 100644
--- a/website/src/content/docs/api/types.mdx
+++ b/website/src/content/docs/api/types.mdx
@@ -1,6 +1,6 @@
 ---
 title: Core Types
-description: TypeScript types exported by glyphcss-core and glyphcss.
+description: TypeScript types exported by glyph-core and glyphcss.
 ---
 
 All types below are exported from `@glyphcss/core` unless marked with `glyphcss`
@@ -59,17 +59,17 @@ type EdgeWeight = 1 | 2 | 3;
 
 | Type | Description | Used in |
 |---|---|---|
-| `GlyphcssDirectionalLight` | Single distant light source | `SceneContextOptions.directionalLight` |
-| `GlyphcssAmbientLight` | Uniform fill light | `SceneContextOptions.ambientLight` |
+| `GlyphDirectionalLight` | Single distant light source | `SceneContextOptions.directionalLight` |
+| `GlyphAmbientLight` | Uniform fill light | `SceneContextOptions.ambientLight` |
 
 ```ts
-interface GlyphcssDirectionalLight {
+interface GlyphDirectionalLight {
   direction: Vec3;
   intensity?: number;  // default 1
   color?: string;      // hex, default white
 }
 
-interface GlyphcssAmbientLight {
+interface GlyphAmbientLight {
   intensity?: number;  // default 0.4
   color?: string;      // hex, default white
 }
@@ -148,16 +148,16 @@ interface ParseAnimationClip {
 
 | Type | Description | Used in |
 |---|---|---|
-| `GlyphcssAnimationMixer` | Drives one or more animation actions against a mesh target | `createGlyphcssAnimationMixer()` |
-| `GlyphcssAnimationAction` | Per-clip playback state | `mixer.clipAction()` |
+| `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 { createGlyphcssAnimationMixer, LoopRepeat, loadMesh } from "@glyphcss/core";
+import { createGlyphAnimationMixer, LoopRepeat, loadMesh } from "@glyphcss/core";
 
 const { polygons, animation } = await loadMesh("/character.glb");
-const mixer = createGlyphcssAnimationMixer(meshHandle, animation!);
+const mixer = createGlyphAnimationMixer(meshHandle, animation!);
 
 const action = mixer.clipAction("walk");
 action.setLoop(LoopRepeat, Infinity).play();
diff --git a/website/src/content/docs/api/vue.mdx b/website/src/content/docs/api/vue.mdx
new file mode 100644
index 00000000..1e8e6793
--- /dev/null
+++ b/website/src/content/docs/api/vue.mdx
@@ -0,0 +1,123 @@
+---
+title: Vue API
+description: Vue components, composables, and types from @glyphcss/vue.
+---
+
+`@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 |
+| `` | Default camera (alias for ``) |
+| `` | Foreshortened projection — needs a `distance` |
+| `` | Parallel projection — best for iso / voxel scenes |
+| `` | Polygon registration. Pass `:polygons="…"`, `src="…"`, or `geometry="cube"` |
+| `` | 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 `` |
+| `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 radians, passed as `:rotation="[x, y, z]"`.
+
+## End-to-end example
+
+```vue
+
+
+
+```
+
+## Re-exports from `@glyphcss/core`
+
+`@glyphcss/vue` re-exports the full `@glyphcss/core` surface — every polygon
+factory (`cubePolygons`, `dodecahedronPolygons`, …), the `resolveGeometry`
+registry, and the mesh parsers (`loadMesh`, `parseObj`, `parseGltf`,
+`parseVox`). You usually only need to import from `@glyphcss/vue`.
+
+## 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
diff --git a/website/src/content/docs/components/glyphcss-camera.mdx b/website/src/content/docs/components/glyph-camera.mdx
similarity index 54%
rename from website/src/content/docs/components/glyphcss-camera.mdx
rename to website/src/content/docs/components/glyph-camera.mdx
index 4a673d5a..30ae5e16 100644
--- a/website/src/content/docs/components/glyphcss-camera.mdx
+++ b/website/src/content/docs/components/glyph-camera.mdx
@@ -1,31 +1,31 @@
 ---
-title: GlyphcssCamera
+title: GlyphCamera
 description: Perspective or orthographic camera handle shared by the renderer and hit layer.
 ---
 
 import { Tabs, TabItem } from '@astrojs/starlight/components';
 
-`` (alias for ``) is the canonical projection
-state. The renderer and the hit layer both read from it — mutating `rotY` updates
-both simultaneously.
+`` (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 — perspective
+## Props — orthographic (`GlyphCamera` / `GlyphOrthographicCamera`)
 
 | Prop | Type | Default | Description |
 |---|---|---|---|
 | `rotX` | `number` (radians) | `0` | Tilt around X axis |
 | `rotY` | `number` (radians) | `0` | Spin around Y axis |
-| `distance` | `number` | `3` | Camera distance — smaller = more perspective foreshortening |
 | `zoom` | `number` | `0.4` | Camera zoom — mesh fills this fraction of `min(cols, rows)` |
-| `stretch` | `number` | `1.0` | Extra X scale on top of `cellAspect` |
 
-## Props — orthographic
+## Props — perspective (`GlyphPerspectiveCamera`)
 
 | Prop | Type | Default | Description |
 |---|---|---|---|
 | `rotX` | `number` (radians) | `0` | Tilt around X axis |
 | `rotY` | `number` (radians) | `0` | Spin around Y axis |
-| `zoom` | `number` | `0.4` | Scale (fraction of `min(cols, rows)`). Replaces `distance`. |
+| `zoom` | `number` | `0.4` | Camera zoom — mesh fills this fraction of `min(cols, rows)` |
+| `distance` | `number` | `3` | Camera distance — smaller = more perspective foreshortening |
+| `stretch` | `number` | `1.0` | Extra X scale on top of `cellAspect` |
 
 ## Full examples
 
@@ -34,35 +34,37 @@ both simultaneously.
 
 ```tsx
 import {
-  GlyphcssScene,
-  GlyphcssPerspectiveCamera,
-  GlyphcssOrthographicCamera,
-  GlyphcssMesh,
-  GlyphcssOrbitControls,
+  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 (default)
+// Perspective (foreshortened)
 export function PerspectiveExample() {
   return (
-    
-      
-      
-      
-    
+    
+      
+        
+        
+      
+    
   );
 }
 
 // Orthographic — parallel lines stay parallel (isometric style)
 export function OrthoExample() {
   return (
-    
-      
-      
-      
-    
+    
+      
+        
+        
+      
+    
   );
 }
 ```
@@ -72,23 +74,27 @@ export function OrthoExample() {
 
 ```vue
 
 
 
 
-
-  
-  
-
+
+  
+    
+    
+  
+
 ```
 
   
@@ -147,8 +157,8 @@ const controls = createGlyphcssOrbitControls(scene, { drag: true, wheel: true })
 
 | Method | Description |
 |---|---|
-| `scene.add(polygons, transform?)` | Register a mesh, returns a `GlyphcssMeshHandle` |
-| `scene.addHotspot(opts, onClick?)` | Register a hotspot overlay, returns a `GlyphcssHotspotHandle` |
+| `scene.add(polygons, transform?)` | Register a mesh, returns a `GlyphMeshHandle` |
+| `scene.addHotspot(opts, onClick?)` | Register a hotspot overlay, returns a `GlyphHotspotHandle` |
 | `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 |
diff --git a/website/src/content/docs/core-concepts.mdx b/website/src/content/docs/core-concepts.mdx
index 7d8dea95..9213dd2f 100644
--- a/website/src/content/docs/core-concepts.mdx
+++ b/website/src/content/docs/core-concepts.mdx
@@ -29,16 +29,16 @@ exposes a **sparse** hit layer: you opt-in to interactivity by registering hotsp
 at specific 3D anchors.
 
 ```tsx
-import { GlyphcssMesh, GlyphcssHotspot } from "@glyphcss/react";
+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, @@ -54,10 +54,10 @@ lockstep through rotation. Hotspots: ## 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 `GlyphcssCamera` handle: +This is enforced by both reading from a shared `GlyphCamera` handle: ```ts -const camera = createGlyphcssPerspectiveCamera({ +const camera = createGlyphPerspectiveCamera({ rotX: 0.4, rotY: 0, distance: 3, zoom: 0.32, stretch: 1.0, }); diff --git a/website/src/content/docs/guides/creating-shapes.mdx b/website/src/content/docs/guides/creating-shapes.mdx index cc74a1e2..9f44560c 100644 --- a/website/src/content/docs/guides/creating-shapes.mdx +++ b/website/src/content/docs/guides/creating-shapes.mdx @@ -15,7 +15,7 @@ A tetrahedron has 4 vertices and 4 triangular faces. The simplest approach: writ the vertices as a `Vec3[]` and the face indices by hand, then map to polygons. ```ts -import { createGlyphcssScene } from "glyphcss"; +import { createGlyphCamera, createGlyphScene } from "glyphcss"; import type { Polygon, Vec3 } from "@glyphcss/core"; // 4 vertices of a regular tetrahedron (circumradius ≈ 1). @@ -42,7 +42,8 @@ const polygons: Polygon[] = faceIndices.map(([a, b, c]) => ({ // Render it. const host = document.querySelector("#scene")!; -const scene = createGlyphcssScene(host, { mode: "solid", cols: 80, rows: 24 }); +const camera = createGlyphCamera({ rotX: 0.4 }); +const scene = createGlyphScene(host, { camera, mode: "solid", cols: 80, rows: 24 }); scene.add(polygons); ``` @@ -53,7 +54,7 @@ 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 `GlyphcssMesh` `polygons` prop. +`Polygon[]` — pass directly to `scene.add()` or to the `GlyphMesh` `polygons` prop. ### Platonic solids @@ -198,9 +199,10 @@ composing multiple framework components. ```tsx import { - GlyphcssScene, - GlyphcssMesh, - GlyphcssOrbitControls, + GlyphCamera, + GlyphScene, + GlyphMesh, + GlyphOrbitControls, } from "@glyphcss/react"; import { cubePolygons, octahedronPolygons, axesHelperPolygons } from "@glyphcss/core"; @@ -211,12 +213,14 @@ const axes = axesHelperPolygons({ size: 1.5 }); export function App() { return ( - - - - - - + + + + + + + + ); } ``` @@ -226,19 +230,22 @@ export function App() { ```vue ``` @@ -80,10 +80,14 @@ function onClick() { alert("vertex"); } ```html - - - - + + + + + + + + ``` @@ -92,9 +96,9 @@ function onClick() { alert("vertex"); } ## What you get - A single `
` rendered as ASCII glyphs, animating a full rotation in ~6 seconds.
-- One absolutely-positioned `
` for each ``, +- One absolutely-positioned `
` for each ``, tracking the rotating mesh exactly (per-frame keyframes synced via `steps(N)`). -- Drag-to-rotate and scroll-to-zoom out of the box via ``. +- Drag-to-rotate and scroll-to-zoom out of the box via ``. ## Try a Platonic solid @@ -102,19 +106,19 @@ function onClick() { alert("vertex"); } ```tsx -import { GlyphcssCamera, GlyphcssScene, GlyphcssOrbitControls, GlyphcssMesh } from "@glyphcss/react"; +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 ( - - - - - - + + + + + + ); } ``` @@ -124,16 +128,16 @@ export function IcosahedronDemo() { ```vue ', + ].join('\n'); + } + } + + // ── Stats overlay ───────────────────────────────────────────────────────── + if (wantStats) { + statsEl.classList.add('active'); + let fpsFrames = 0; + let fpsStart = 0; + const fpsTick = (now: number): void => { + if (!fpsStart) fpsStart = now; + fpsFrames++; + const elapsed = now - fpsStart; + if (elapsed >= 1000) { + const fps = Math.round((fpsFrames * 1000) / elapsed); + const opts = scene.getOptions(); + statsEl.innerHTML = `FPS: ${fps} · cells: ${opts.cols}×${opts.rows}`; + fpsFrames = 0; + fpsStart = now; + } + requestAnimationFrame(fpsTick); + }; + requestAnimationFrame(fpsTick); + } + + // ── Tabs ────────────────────────────────────────────────────────────────── + demoEl.querySelector('.glyph-demo__tabs')?.addEventListener('click', (e) => { + const btn = (e.target as HTMLElement).closest('.glyph-demo__tab') as HTMLElement | null; + if (!btn) return; + const fw = btn.dataset.fw; + demoEl.querySelectorAll('.glyph-demo__tab').forEach((t) => + t.classList.toggle('active', (t as HTMLElement).dataset.fw === fw)); + demoEl.querySelectorAll('.glyph-demo__snippet').forEach((p) => + p.classList.toggle('glyph-demo__snippet--hidden', (p as HTMLElement).dataset.fw !== fw)); + }); + + // ── Initial render ──────────────────────────────────────────────────────── + const initialMeshUrl = demoEl.getAttribute('data-mesh'); + if (initialMeshUrl) { + void setMeshUrl(initialMeshUrl); + } else { + applyMesh(); + doRerender(); + loadingEl.style.display = 'none'; + updateCode(); + } + + if (demoEl.getAttribute('data-auto-rotate') === '1') { + startAutoRotate(); + } +} + +function debounce(fn: (...args: unknown[]) => void, ms: number) { + let t: number | undefined; + return (...args: unknown[]) => { + if (t) window.clearTimeout(t); + t = window.setTimeout(() => fn(...args), ms); + }; +} + +// Keep debounce used for external callers +void debounce; + +export function initAllGlyphDemos(): void { + document.querySelectorAll('.glyph-demo').forEach(initGlyphDemo); +} + +document.addEventListener('astro:page-load', initAllGlyphDemos); +if (document.readyState !== 'loading') initAllGlyphDemos(); +else document.addEventListener('DOMContentLoaded', initAllGlyphDemos); diff --git a/website/src/glyphcss-runtime.ts b/website/src/glyphcss-runtime.ts deleted file mode 100644 index 4d871ec4..00000000 --- a/website/src/glyphcss-runtime.ts +++ /dev/null @@ -1,1709 +0,0 @@ -/** - * GlyphcssDemo runtime — imported once by GlyphcssDemo.astro. Lives in a standalone - * .ts file (instead of inline `', - ].join('\n'); - } - } - - // Initial render. If data-mesh is set, fetch+load that mesh (which rebakes - // on success). Otherwise bake the built-in geometry now. - const initialMeshUrl = demoEl.getAttribute('data-mesh'); - if (initialMeshUrl) { - void setMeshUrl(initialMeshUrl); - } else { - bakeAndApply(); - } -} - -function debounce(fn: (...args: unknown[]) => void, ms: number) { - let t: number | undefined; - return (...args: unknown[]) => { - if (t) window.clearTimeout(t); - t = window.setTimeout(() => fn(...args), ms); - }; -} - -export function initAllGlyphcssDemos(): void { - document.querySelectorAll('.glyphcss-demo').forEach(initGlyphcssDemo); -} - -document.addEventListener('astro:page-load', initAllGlyphcssDemos); -if (document.readyState !== 'loading') initAllGlyphcssDemos(); -else document.addEventListener('DOMContentLoaded', initAllGlyphcssDemos); diff --git a/website/src/lib/ascii-diagrams/howItWorks.ts b/website/src/lib/ascii-diagrams/howItWorks.ts index f830b5e2..23d48094 100644 --- a/website/src/lib/ascii-diagrams/howItWorks.ts +++ b/website/src/lib/ascii-diagrams/howItWorks.ts @@ -1,16 +1,25 @@ -import { Box, Column, Row, Rule, Spacer, Text } from "../ascii-layout"; +import { Box, Column, Row, Spacer, Text } from "../ascii-layout"; import type { Renderable } from "../ascii-layout"; -// The pipeline diagram: two parallel rows (mesh→rasterize→pre, hotspot→ -// project→keyframes) inside a single box, with a footer noting where JS runs -// once vs where CSS plays forever. +// Pipeline diagram for the landing's "How It Works" section. // -// Each cell is its own Renderable so the Row primitive can allocate column -// widths from the viewport's measured `cols` — the arrows line up at any -// width down to ~50 columns. -// Threshold below which the 3-column horizontal layout truncates labels -// ("rasterize …", "projectHotspo…"). Below this we switch to a stacked -// vertical layout where each step gets its own line. +// Two stacked, titled panels with a connecting arrow between them. +// +// ┌─ [ JS · runs once ] ───────────────────────────────────────┐ +// │ mesh.glb ─▶ rasterize ─▶
 × N frames     │
+//   │   hotspot    ─▶  projectHotspots  ─▶  @keyframes positions │
+//   └────────────────────────────────────────────────────────────┘
+//                                  │
+//                                  ▼
+//   ┌─ [ CSS · plays forever ] ──────────────────────────────────┐
+//   │           steps(N) drives the frame strip                  │
+//   │           @keyframes drives the hotspots                   │
+//   └────────────────────────────────────────────────────────────┘
+//
+// Below ~60 cols the horizontal "thing ─▶ thing ─▶ thing" rows get squeezed
+// and labels truncate; we fall back to a stacked layout where each step gets
+// its own line.
+
 const HORIZONTAL_MIN_COLS = 60;
 
 export function howItWorksDiagram(): Renderable {
@@ -20,40 +29,63 @@ export function howItWorksDiagram(): Renderable {
   };
 }
 
-function horizontalLayout(): Renderable {
-  const mesh = Text("mesh.glb", { wrap: "none", align: "center" });
-  const raster = Text("rasterize × N", { wrap: "none", align: "center" });
-  const frames = Text("
 × N frames", { wrap: "none", align: "center" });
-  const hotspot = Text("hotspot", { wrap: "none", align: "center" });
-  const project = Text("projectHotspots × N", { wrap: "none", align: "center" });
-  const keyframes = Text("@keyframes hit", { wrap: "none", align: "center" });
-
-  const meshRow = Row([mesh, raster, frames], { divider: "─→", gap: 2 });
-  const stepsNote = Text("▼ CSS steps(N)", { wrap: "none", align: "right" });
-  const hotspotRow = Row([hotspot, project, keyframes], { divider: "─→", gap: 2 });
-
-  const inner = Column(
-    [Spacer(1), meshRow, stepsNote, hotspotRow, Spacer(1)],
+function connector(): Renderable {
+  return Column(
+    [
+      Text("│", { wrap: "none", align: "center" }),
+      Text("▼", { wrap: "none", align: "center" }),
+    ],
     { gap: 0 },
   );
-  const box = Box(inner, { border: "single" });
-  const footer = Row(
+}
+
+function horizontalLayout(): Renderable {
+  const meshRow = Row(
     [
-      Text("JS runs once ↑", { wrap: "none", align: "left" }),
-      Text("↓ CSS plays forever", { wrap: "none", align: "right" }),
+      Text("mesh.glb", { wrap: "none", align: "center" }),
+      Text("rasterize", { wrap: "none", align: "center" }),
+      Text("
 × N frames", { wrap: "none", align: "center" }),
     ],
-    { weights: [1, 1] },
+    { divider: "─▶", gap: 2 },
   );
-  return Column([box, footer], { gap: 0 });
+  const hotspotRow = Row(
+    [
+      Text("hotspot", { wrap: "none", align: "center" }),
+      Text("projectHotspots", { wrap: "none", align: "center" }),
+      Text("@keyframes positions", { wrap: "none", align: "center" }),
+    ],
+    { divider: "─▶", gap: 2 },
+  );
+
+  const jsPanel = Box(
+    Column([Spacer(1), meshRow, Spacer(1), hotspotRow, Spacer(1)], { gap: 0 }),
+    { border: "single", title: "JS · runs once" },
+  );
+
+  const cssPanel = Box(
+    Column(
+      [
+        Spacer(1),
+        Text("steps(N) drives the frame strip", { wrap: "none", align: "center" }),
+        Text("@keyframes drives the hotspots", { wrap: "none", align: "center" }),
+        Spacer(1),
+      ],
+      { gap: 0 },
+    ),
+    { border: "single", title: "CSS · plays forever" },
+  );
+
+  return Column([jsPanel, connector(), cssPanel], { gap: 0 });
 }
 
 function verticalLayout(): Renderable {
   const arrow = Text("↓", { wrap: "none", align: "center" });
+
   const meshChain = Column(
     [
       Text("mesh.glb", { wrap: "none", align: "center" }),
       arrow,
-      Text("rasterize × N", { wrap: "none", align: "center" }),
+      Text("rasterize", { wrap: "none", align: "center" }),
       arrow,
       Text("
 × N frames", { wrap: "none", align: "center" }),
     ],
@@ -63,25 +95,30 @@ function verticalLayout(): Renderable {
     [
       Text("hotspot", { wrap: "none", align: "center" }),
       arrow,
-      Text("projectHotspots × N", { wrap: "none", align: "center" }),
+      Text("projectHotspots", { wrap: "none", align: "center" }),
       arrow,
-      Text("@keyframes hit", { wrap: "none", align: "center" }),
+      Text("@keyframes positions", { wrap: "none", align: "center" }),
     ],
     { gap: 0 },
   );
-  const stepsNote = Text("▼ CSS steps(N)", { wrap: "none", align: "center" });
 
-  const inner = Column(
-    [Spacer(1), meshChain, Spacer(1), stepsNote, Spacer(1), hotspotChain, Spacer(1)],
-    { gap: 0 },
+  const jsPanel = Box(
+    Column([Spacer(1), meshChain, Spacer(1), hotspotChain, Spacer(1)], { gap: 0 }),
+    { border: "single", title: "JS · once" },
   );
-  const box = Box(inner, { border: "single" });
-  const footer = Column(
-    [
-      Text("JS runs once ↑", { wrap: "none", align: "center" }),
-      Text("↓ CSS plays forever", { wrap: "none", align: "center" }),
-    ],
-    { gap: 0 },
+
+  const cssPanel = Box(
+    Column(
+      [
+        Spacer(1),
+        Text("steps(N) frame strip", { wrap: "none", align: "center" }),
+        Text("@keyframes hotspots", { wrap: "none", align: "center" }),
+        Spacer(1),
+      ],
+      { gap: 0 },
+    ),
+    { border: "single", title: "CSS · forever" },
   );
-  return Column([box, footer], { gap: 0 });
+
+  return Column([jsPanel, connector(), cssPanel], { gap: 0 });
 }
diff --git a/website/src/pages/gallery.astro b/website/src/pages/gallery.astro
index fb10b05a..e9939b91 100644
--- a/website/src/pages/gallery.astro
+++ b/website/src/pages/gallery.astro
@@ -1,7 +1,7 @@
 ---
 import GalleryWorkbench from '../components/GalleryWorkbench/GalleryWorkbench.tsx';
 import DocsHeader from '../components/DocsHeader.astro';
-import '../styles/glyphcss-demo.css';
+import '../styles/glyph-demo.css';
 ---
 
   
@@ -48,10 +48,10 @@ import '../styles/glyphcss-demo.css';
       width: 100%;
       height: 100%;
     }
-    /* Gallery canvas overrides — make the GlyphcssDemo fill the workbench's
+    /* Gallery canvas overrides — make the GlyphDemo fill the workbench's
        center viewport AND let the workbench's dark theme show through (no
        opaque background-rect like the hero/standalone variants). */
-    :global(.debug-page-shell .glyphcss-demo) {
+    :global(.debug-page-shell .glyph-demo) {
       width: 100% !important;
       height: 100% !important;
       margin: 0 !important;
@@ -59,15 +59,15 @@ import '../styles/glyphcss-demo.css';
       border-radius: 0 !important;
       background: transparent !important;
     }
-    :global(.debug-page-shell .glyphcss-demo__viewer),
-    :global(.debug-page-shell .glyphcss-demo__canvas),
-    :global(.debug-page-shell .glyphcss-demo__scene-host) {
+    :global(.debug-page-shell .glyph-demo__viewer),
+    :global(.debug-page-shell .glyph-demo__canvas),
+    :global(.debug-page-shell .glyph-demo__scene-host) {
       width: 100% !important;
       height: 100% !important;
       min-height: 0 !important;
       background: transparent !important;
     }
-    :global(.debug-page-shell .glyphcss-demo__code) {
+    :global(.debug-page-shell .glyph-demo__code) {
       display: none !important;
     }
   
diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro
index a87bdffd..ec0ae2aa 100644
--- a/website/src/pages/index.astro
+++ b/website/src/pages/index.astro
@@ -11,7 +11,7 @@ hljs.registerLanguage("tsx", typescript);
 hljs.registerLanguage("vue", xml);
 
 import DocsHeader from "../components/DocsHeader.astro";
-import GlyphcssDemo from "../components/GlyphcssDemo.astro";
+import GlyphDemo from "../components/GlyphDemo.astro";
 import AsciiArt from "../components/AsciiArt.astro";
 
 function highlight(code: string, lang: string): string {
@@ -29,27 +29,31 @@ const frameworkTabs = [
     language: "html",
     code: `
 
-
-  
-  
-`,
+
+  
+    
+    
+      
+    
+  
+`,
   },
   {
     id: "react",
     label: "React",
     language: "tsx",
-    code: `import { GlyphcssCamera, GlyphcssScene, GlyphcssOrbitControls, GlyphcssMesh, GlyphcssHotspot } from "@glyphcss/react";
+    code: `import { GlyphCamera, GlyphScene, GlyphOrbitControls, GlyphMesh, GlyphHotspot } from "@glyphcss/react";
 
 export function App() {
   return (
-    
-      
-        
-        
-           alert("roof")} />
-        
-      
-    
+    
+      
+        
+        
+           alert("vertex")} />
+        
+      
+    
   );
 }`,
   },
@@ -58,18 +62,18 @@ export function App() {
     label: "Vue",
     language: "vue",
     code: `
 `,
   },
 ].map(tab => ({
@@ -130,11 +134,13 @@ const ldJson = {
       
@@ -235,7 +241,7 @@ const ldJson = {

> - Interactivity is opt-in and sparse: drop a <GlyphcssHotspot> + Interactivity is opt-in and sparse: drop a <GlyphHotspot> at any 3D anchor and glyphcss emits one absolutely-positioned <div> over the projected cell. Real DOM events, real :hover styles, real role="button" @@ -254,8 +260,8 @@ const ldJson = {

> - glyphcss provides custom elements (<glyphcss-scene>, <glyphcss-mesh>), - an imperative createGlyphcssScene API, and optional React / Vue bindings. + glyphcss provides custom elements (<glyph-scene>, <glyph-mesh>), + an imperative createGlyphScene API, and optional React / Vue bindings. Use whichever entry point fits your stack.

@@ -484,11 +490,15 @@ const ldJson = { .hero--ascii .hero-scene { grid-area: scene; position: relative; - height: clamp(240px, 40vw, 360px); + /* The apple's natural projection at zoom 0.25 is taller than it is wide, + so the frame needs more vertical room than horizontal on narrow + viewports. 1.2× the available width gives the apple breathing space + without making the hero dominate the viewport. */ + height: clamp(420px, 120vw, 560px); overflow: hidden; min-width: 0; } - .hero--ascii .hero-scene > .glyphcss-demo { + .hero--ascii .hero-scene > .glyph-demo { width: 100%; height: 100%; margin: 0; @@ -496,9 +506,9 @@ const ldJson = { border: none; border-radius: 0; } - .hero--ascii .hero-scene .glyphcss-demo__viewer, - .hero--ascii .hero-scene .glyphcss-demo__canvas, - .hero--ascii .hero-scene .glyphcss-demo__scene-host { + .hero--ascii .hero-scene .glyph-demo__viewer, + .hero--ascii .hero-scene .glyph-demo__canvas, + .hero--ascii .hero-scene .glyph-demo__scene-host { width: 100%; height: 100%; min-height: 0; @@ -666,8 +676,8 @@ const ldJson = { height: 100%; position: relative; } - /* Make the GlyphcssDemo inside the hero fill the canvas column. */ - .hero-canvas > .glyphcss-demo { + /* Make the GlyphDemo inside the hero fill the canvas column. */ + .hero-canvas > .glyph-demo { height: 100%; margin: 0; /* Remove the dark box — hero demo should be transparent */ @@ -675,9 +685,9 @@ const ldJson = { border: none; border-radius: 0; } - .hero-canvas .glyphcss-demo__viewer, - .hero-canvas .glyphcss-demo__canvas, - .hero-canvas .glyphcss-demo__scene-host { + .hero-canvas .glyph-demo__viewer, + .hero-canvas .glyph-demo__canvas, + .hero-canvas .glyph-demo__scene-host { height: 100%; /* Fills the locked hero-canvas (parent .hero is height: 389.99px). */ min-height: 0; diff --git a/website/src/styles/glyphcss-demo.css b/website/src/styles/glyph-demo.css similarity index 71% rename from website/src/styles/glyphcss-demo.css rename to website/src/styles/glyph-demo.css index 7efdbee9..634bd62e 100644 --- a/website/src/styles/glyphcss-demo.css +++ b/website/src/styles/glyph-demo.css @@ -1,16 +1,16 @@ @import url("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"); -/* GlyphcssDemo — interactive ASCII scene viewer with code panel + tuning bench. - * Ported from poly-demo.css; glyphcss-specific additions preserved for the +/* GlyphDemo — interactive ASCII scene viewer with code panel + tuning bench. + * Ported from poly-demo.css; glyph-specific additions preserved for the * flipbook viewport/strip/hit-layer/hotspot layer that doesn't exist in glyphcss. */ -.glyphcss-demo { +.glyph-demo { margin: 1.5rem 0; border: 1px solid var(--sl-color-gray-5, rgba(255,255,255,0.08)); border-radius: 12px; overflow: hidden; background: #0b0f14; - /* The
 has white-space: pre, giving it an
+  /* The 
 has white-space: pre, giving it an
      intrinsic min-content equal to its widest rasterized frame. Without min-width:0
      here (and on the grid item chain below), the demo refuses to shrink below
      ~400-500px on narrow viewports, leaving it visually offset inside its container. */
@@ -19,17 +19,17 @@
 
 /* ── Viewer (canvas + controls side by side) ────────────────────────────── */
 
-.glyphcss-demo__viewer {
+.glyph-demo__viewer {
   display: grid;
   grid-template-columns: minmax(0, 1fr) auto;
   min-height: 320px;
   min-width: 0;
 }
-.glyphcss-demo__viewer[data-layout="canvas-only"] {
+.glyph-demo__viewer[data-layout="canvas-only"] {
   grid-template-columns: minmax(0, 1fr);
 }
 
-.glyphcss-demo__canvas {
+.glyph-demo__canvas {
   position: relative;
   min-height: 320px;
   min-width: 0;
@@ -37,9 +37,9 @@
   background: #01030a;
 }
 
-/* ── Scene host (contains the flipbook viewport + hit layer) ────────────── */
+/* ── Scene host (contains the public glyph-scene + hotspot layer) ───────── */
 
-.glyphcss-demo__scene-host {
+.glyph-demo__scene-host {
   width: 100%;
   height: 100%;
   min-height: 320px;
@@ -47,12 +47,47 @@
   overflow: hidden;
 }
 
-/* ── Flipbook viewport — glyphcss-specific; no glyphcss equivalent ─────────
+/* ── Public scene integration — make the managed scene fill its host ──────
+ * createGlyphScene injects a .glyph-scene div; we need it to fill the
+ * .glyph-demo__scene-host so autoSize:true can measure the full viewport. */
+
+.glyph-demo__scene-host .glyph-scene {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+/* Amber terminal color + monospace font matching the gallery aesthetic. */
+.glyph-demo__scene-host .glyph-output {
+  font-family: ui-monospace, "JetBrains Mono", "SF Mono", "Menlo", monospace;
+  color: rgba(255, 232, 184, 0.94);
+  text-shadow: 0 0 5px rgba(255, 200, 130, 0.45);
+  touch-action: none;
+  overscroll-behavior: contain;
+  cursor: grab;
+}
+
+.glyph-demo__scene-host .glyph-scene:active .glyph-output,
+.glyph-demo__scene-host .glyph-output:active {
+  cursor: grabbing;
+}
+
+/* Decorative landing demo — no pointer affordance, no event capture. */
+.glyph-demo[data-interactive="0"] .glyph-output,
+.glyph-demo[data-interactive="0"] .glyph-output:active {
+  cursor: default;
+  touch-action: auto;
+  pointer-events: none;
+}
+
+/* ── Flipbook viewport — glyph-specific; no glyphcss equivalent ─────────
  * Clipped to exactly one frame's height; centered vertically.
  * `overscroll-behavior: contain` stops wheel zoom from leaking to the page
  * scroll container where preventDefault() would be silently no-op'd. */
 
-.glyphcss-demo__viewport {
+.glyph-demo__viewport {
   position: absolute;
   left: 0;
   right: 0;
@@ -64,13 +99,13 @@
   touch-action: none;
   overscroll-behavior: contain;
 }
-.glyphcss-demo__viewport.dragging { cursor: grabbing; }
+.glyph-demo__viewport.dragging { cursor: grabbing; }
 
-/* ── Flipbook strip — glyphcss-specific; CSS-animated 
 ───────────────
+/* ── Flipbook strip — glyph-specific; CSS-animated 
 ───────────────
  * Runs a steps() scroll driven by ad-rotate. During drag the runtime
  * freezes the animation and directly sets a translate3d via style. */
 
-.glyphcss-demo__strip {
+.glyph-demo__strip {
   margin: 0;
   padding: 0;
   font-family: ui-monospace, "JetBrains Mono", "SF Mono", "Menlo", monospace;
@@ -85,7 +120,7 @@
 }
 /* Inline span colors (from source-model materials) override the amber fallback via
  * inline style specificity — no additional CSS rule needed. */
-.glyphcss-demo__viewport.dragging .glyphcss-demo__strip {
+.glyph-demo__viewport.dragging .glyph-demo__strip {
   animation: none !important;
   transform: translate3d(0, 0, 0) !important;
 }
@@ -96,17 +131,17 @@
 
 /* Auto-rotate toggle — pauses (not clears) the strip + hotspot animations so
    the mesh freezes at whatever frame is currently shown. Drag still works. */
-.glyphcss-demo.no-autorotate .glyphcss-demo__strip,
-.glyphcss-demo.no-autorotate .glyphcss-demo__hotspot {
+.glyph-demo.no-autorotate .glyph-demo__strip,
+.glyph-demo.no-autorotate .glyph-demo__hotspot {
   animation-play-state: paused;
 }
 
-/* ── Hit layer — glyphcss-specific; sparse DOM hotspots over the 
 ────
- * Mirrors .glyphcss-demo__viewport geometry exactly but is pointer-enabled.
- * Each .glyphcss-demo__hotspot inside runs its own ad-hit-* keyframe (emitted
+/* ── Hit layer — glyph-specific; sparse DOM hotspots over the 
 ────
+ * Mirrors .glyph-demo__viewport geometry exactly but is pointer-enabled.
+ * Each .glyph-demo__hotspot inside runs its own ad-hit-* keyframe (emitted
  * by the runtime) to stay locked to the same steps() cycle as the strip. */
 
-.glyphcss-demo__hit-layer {
+.glyph-demo__hit-layer {
   position: absolute;
   left: 0;
   right: 0;
@@ -116,7 +151,7 @@
   pointer-events: none;
 }
 
-.glyphcss-demo__hotspot {
+.glyph-demo__hotspot {
   position: absolute;
   pointer-events: auto;
   display: grid;
@@ -130,16 +165,16 @@
   animation-timing-function: steps(60, end);
   animation-iteration-count: infinite;
 }
-.glyphcss-demo__viewport.dragging ~ .glyphcss-demo__hit-layer .glyphcss-demo__hotspot {
+.glyph-demo__viewport.dragging ~ .glyph-demo__hit-layer .glyph-demo__hotspot {
   animation: none !important;
 }
-.glyphcss-demo__hotspot:hover,
-.glyphcss-demo__hotspot:focus-visible {
+.glyph-demo__hotspot:hover,
+.glyph-demo__hotspot:focus-visible {
   background: rgba(200, 90, 55, 0.18);
   border-color: rgba(201, 167, 101, 0.6);
   outline: none;
 }
-.glyphcss-demo__hotspot .badge {
+.glyph-demo__hotspot .badge {
   position: absolute;
   bottom: calc(100% + 4px);
   white-space: nowrap;
@@ -155,15 +190,15 @@
   transform: translateY(2px);
   transition: opacity 120ms ease, transform 120ms ease;
 }
-.glyphcss-demo__hotspot:hover .badge,
-.glyphcss-demo__hotspot:focus-visible .badge {
+.glyph-demo__hotspot:hover .badge,
+.glyph-demo__hotspot:focus-visible .badge {
   opacity: 1;
   transform: translateY(0);
 }
 
 /* ── Loading overlay ─────────────────────────────────────────────────────── */
 
-.glyphcss-demo__loading {
+.glyph-demo__loading {
   position: absolute;
   inset: 0;
   display: flex;
@@ -177,7 +212,7 @@
 
 /* ── Stats overlay ───────────────────────────────────────────────────────── */
 
-.glyphcss-demo__stats {
+.glyph-demo__stats {
   display: none;
   position: absolute;
   bottom: 12px;
@@ -193,17 +228,17 @@
   line-height: 1.6;
   pointer-events: none;
 }
-.glyphcss-demo__stats.active {
+.glyph-demo__stats.active {
   display: block;
 }
-.glyphcss-demo__stats .stat-value {
+.glyph-demo__stats .stat-value {
   color: #38bdf8;
   font-weight: 700;
 }
 
 /* ── Controls panel ──────────────────────────────────────────────────────── */
 
-.glyphcss-demo__controls {
+.glyph-demo__controls {
   display: flex;
   flex-direction: column;
   gap: 10px;
@@ -217,24 +252,24 @@
   overflow-y: auto;
   max-height: 400px;
 }
-.glyphcss-demo__controls label {
+.glyph-demo__controls label {
   display: flex;
   flex-direction: column;
   gap: 2px;
   color: #e2e8f0;
 }
-.glyphcss-demo__controls .range-heading {
+.glyph-demo__controls .range-heading {
   display: flex;
   justify-content: space-between;
   align-items: center;
   font-size: 13px;
   color: #cbd5e1;
 }
-.glyphcss-demo__controls .range-value {
+.glyph-demo__controls .range-value {
   color: #f8fafc;
   font-weight: 600;
 }
-.glyphcss-demo__controls input[type="range"] {
+.glyph-demo__controls input[type="range"] {
   -webkit-appearance: none;
   appearance: none;
   width: 100%;
@@ -245,7 +280,7 @@
   padding: 0;
   outline: none;
 }
-.glyphcss-demo__controls input[type="range"]::-webkit-slider-thumb {
+.glyph-demo__controls input[type="range"]::-webkit-slider-thumb {
   -webkit-appearance: none;
   appearance: none;
   width: 14px;
@@ -255,7 +290,7 @@
   border: 1px solid #94a3b8;
   cursor: pointer;
 }
-.glyphcss-demo__controls input[type="range"]::-moz-range-thumb {
+.glyph-demo__controls input[type="range"]::-moz-range-thumb {
   width: 14px;
   height: 14px;
   border-radius: 50%;
@@ -263,7 +298,7 @@
   border: 1px solid #94a3b8;
   cursor: pointer;
 }
-.glyphcss-demo__controls .switch {
+.glyph-demo__controls .switch {
   display: flex;
   flex-direction: row;
   align-items: center;
@@ -272,12 +307,12 @@
   color: #cbd5e1;
   font-size: 13px;
 }
-.glyphcss-demo__controls .switch input:not(:checked) + span {
+.glyph-demo__controls .switch input:not(:checked) + span {
   color: #64748b;
 }
 
 /* lil-gui panel — themed to match the glyphcss range-control palette */
-.glyphcss-demo__controls .lil-gui {
+.glyph-demo__controls .lil-gui {
   --background-color: #11141a;
   --text-color: #cbd5e1;
   --title-background-color: #0f1115;
@@ -298,19 +333,19 @@
 
 /* ── Code panel ──────────────────────────────────────────────────────────── */
 
-.glyphcss-demo__code {
+.glyph-demo__code {
   border-top: 1px solid rgba(255,255,255,0.06);
   max-height: 300px;
   overflow: auto;
 }
 
-.glyphcss-demo__tabs {
+.glyph-demo__tabs {
   display: flex;
   border-bottom: 1px solid rgba(255,255,255,0.06);
   background: #0a0c10;
 }
 
-.glyphcss-demo__tab {
+.glyph-demo__tab {
   background: none;
   border: none;
   color: #64748b;
@@ -320,15 +355,15 @@
   cursor: pointer;
   border-bottom: 2px solid transparent;
 }
-.glyphcss-demo__tab:hover {
+.glyph-demo__tab:hover {
   color: #cbd5e1;
 }
-.glyphcss-demo__tab.active {
+.glyph-demo__tab.active {
   color: #e2e8f0;
   border-bottom-color: #38bdf8;
 }
 
-.glyphcss-demo__snippet {
+.glyph-demo__snippet {
   margin: 0 !important;
   padding: 14px 16px !important;
   background: #000 !important;
@@ -336,10 +371,10 @@
   font-size: 13px !important;
   line-height: 1.5 !important;
 }
-.glyphcss-demo__snippet--hidden {
+.glyph-demo__snippet--hidden {
   display: none;
 }
-.glyphcss-demo__snippet code {
+.glyph-demo__snippet code {
   font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
   color: #e5e9f0 !important;
   white-space: pre !important;
@@ -350,10 +385,10 @@
 /* ── Responsive ──────────────────────────────────────────────────────────── */
 
 @media (max-width: 768px) {
-  .glyphcss-demo__viewer {
+  .glyph-demo__viewer {
     grid-template-columns: minmax(0, 1fr);
   }
-  .glyphcss-demo__controls {
+  .glyph-demo__controls {
     max-width: none;
     border-left: none;
     border-top: 1px solid rgba(255,255,255,0.06);
@@ -361,7 +396,7 @@
     flex-direction: row;
     flex-wrap: wrap;
   }
-  .glyphcss-demo__controls label {
+  .glyph-demo__controls label {
     flex: 1 1 45%;
   }
 }