diff --git a/AGENTS.md b/AGENTS.md index 4c1dfd74..275cf09f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,13 +79,13 @@ If you find yourself wanting a `requestAnimationFrame` loop to update many DOM n - Every public export gets a `Poly` prefix. Exceptions are generic math types: `Vec2`, `Vec3`, `Polygon`, `PolyMaterial` (already prefixed). - **Hooks/composables:** `usePolyCamera`, `usePolyMesh`, `usePolySceneContext`, `usePolySelect`, `usePolySelectionApi`, `usePolyAnimation`. -- **Components:** `PolyPerspectiveCamera`, `PolyOrthographicCamera`, `PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`, `PolySelect`, `PolyAxesHelper`, `PolyDirectionalLightHelper`, `PolyControls`. +- **Components:** `PolyPerspectiveCamera`, `PolyOrthographicCamera`, `PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`, `PolySelect`, `PolyAxesHelper`, `PolyDirectionalLightHelper`. - **Types:** `PolyDirectionalLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyAnimationMixer`. - **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `buildPolyVoxelFaceData`, `buildPolyVoxelSlicePlan`. -- **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createPolyControls`, `createTransformControls`, `createSelect`). -- **HTML custom elements:** `poly-` prefix + kebab-case. Existing tags: ``, ``, ``, ``, ``, ``. Any new element follows the same shape (e.g. ``, ``, ``). +- **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createTransformControls`, `createSelect`). +- **HTML custom elements:** `poly-` prefix + kebab-case. Existing tags: ``, ``, ``, ``, ``, ``, ``. Any new element follows the same shape (e.g. ``, ``). - **Leaf DOM tags (``, ``, ``, ``):** internal render-strategy tags. Not part of the public API and not user-facing — do not document them as such. -- `PolyCamera` is a kept alias for `PolyPerspectiveCamera` — the ergonomic default. **Not deprecated.** +- `PolyCamera` is a kept alias for `PolyOrthographicCamera` — the ergonomic default, optimised for iso/voxel/diagrammatic scenes which is polycss's structural strength. **Not deprecated.** ## Cross-package discipline diff --git a/README.md b/README.md index 55af94e4..70495771 100644 --- a/README.md +++ b/README.md @@ -24,14 +24,14 @@ npm install @layoutit/polycss ## Quick start: React ```tsx -import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react"; +import { PolyCamera, PolyScene, PolyOrbitControls, PolyIcosahedron } from "@layoutit/polycss-react"; export function App() { return ( - + ); @@ -45,13 +45,13 @@ export function App() { - + ``` @@ -60,10 +60,12 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po ```html - - - - + + + + + + ``` ## Per-polygon interactivity diff --git a/packages/core/src/helpers/conePolygons.test.ts b/packages/core/src/helpers/conePolygons.test.ts new file mode 100644 index 00000000..28bae891 --- /dev/null +++ b/packages/core/src/helpers/conePolygons.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import type { Vec3 } from "../types"; +import { conePolygons } from "./conePolygons"; + +// ── Test helpers ───────────────────────────────────────────────────────────── + +function cross(a: Vec3, b: Vec3): Vec3 { + 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 sub(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +function dot(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function len(v: Vec3): number { + return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); +} + +function isCoplanar(vertices: Vec3[], eps = 1e-4): boolean { + if (vertices.length <= 3) return true; + // Find first non-degenerate triplet (some vertices may coincide at the cone apex). + let n: Vec3 | null = null; + let ref: Vec3 | null = null; + for (let i = 0; i < vertices.length && n === null; i++) { + for (let j = i + 1; j < vertices.length && n === null; j++) { + for (let k = j + 1; k < vertices.length && n === null; k++) { + const candidate = cross(sub(vertices[j], vertices[i]), sub(vertices[k], vertices[i])); + if (len(candidate) > 1e-10) { n = candidate; ref = vertices[i]; } + } + } + } + if (n === null || ref === null) return true; // all points coincide — trivially coplanar + const nl = len(n); + for (const v of vertices) { + const dist = Math.abs(dot(n, sub(v, ref!))) / nl; + if (dist > eps) return false; + } + return true; +} + +function isCCWFromOutside(vertices: Vec3[], solidCentroid: Vec3): boolean { + // Find the first non-degenerate cross product (some quads are degenerate at cone apex). + let n: Vec3 | null = null; + for (let i = 0; i < vertices.length && n === null; i++) { + const a = vertices[i]; + const b = vertices[(i + 1) % vertices.length]; + const c = vertices[(i + 2) % vertices.length]; + const candidate = cross(sub(b, a), sub(c, a)); + if (len(candidate) > 1e-10) n = candidate; + } + if (n === null) return true; // fully degenerate — skip + const fc: Vec3 = [0, 0, 0]; + // Use only unique vertices for face center (avoid bias from duplicate apex point). + const unique = vertices.filter((v, i) => + !vertices.slice(0, i).some((u) => u[0] === v[0] && u[1] === v[1] && u[2] === v[2]), + ); + for (const v of unique) { fc[0] += v[0]; fc[1] += v[1]; fc[2] += v[2]; } + fc[0] /= unique.length; fc[1] /= unique.length; fc[2] /= unique.length; + return dot(n, sub(fc, solidCentroid)) > 0; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("conePolygons", () => { + it("returns n side quads + n bottom triangles for default 12 segments (no top cap)", () => { + // 12 sides + 12 bottom (radiusTop = 0 → no top cap) = 24 + const polygons = conePolygons(); + expect(polygons).toHaveLength(24); + }); + + it("side polygons have 4 vertices; cap triangles have 3 vertices", () => { + const n = 6; + const polygons = conePolygons({ radialSegments: n }); + for (let i = 0; i < n; i++) expect(polygons[i].vertices).toHaveLength(4); + for (let i = n; i < 2 * n; i++) expect(polygons[i].vertices).toHaveLength(3); + }); + + it("uses default radius 50, height 100, color #cccccc", () => { + const polygons = conePolygons(); + for (const p of polygons) expect(p.color).toBe("#cccccc"); + const zVals = polygons.flatMap((p) => p.vertices.map((v) => v[2])); + expect(Math.min(...zVals)).toBeCloseTo(-50, 5); + expect(Math.max(...zVals)).toBeCloseTo(50, 5); + }); + + it("apex is a single point at the top (+Z)", () => { + const polygons = conePolygons({ radialSegments: 6 }); + // Side quads are ordered [bl, br, tr, tl] where tr and tl are the apex-level vertices. + const apexPoints = new Set(); + for (let i = 0; i < 6; i++) { + // vertices[2] = tr, vertices[3] = tl — both collapse to apex when radiusTop = 0. + const tr = polygons[i].vertices[2]; + const tl = polygons[i].vertices[3]; + apexPoints.add(tr.map((x) => x.toFixed(6)).join(",")); + apexPoints.add(tl.map((x) => x.toFixed(6)).join(",")); + } + // All apex vertices collapse to one point (0, 0, +height/2) = (0, 0, 50). + expect(apexPoints.size).toBe(1); + const apex = polygons[0].vertices[2]; // tr of first side quad + expect(apex[0]).toBeCloseTo(0, 5); + expect(apex[1]).toBeCloseTo(0, 5); + expect(apex[2]).toBeCloseTo(50, 5); + }); + + it("every face is coplanar within epsilon", () => { + const polygons = conePolygons({ radialSegments: 12 }); + for (const p of polygons) expect(isCoplanar(p.vertices, 1e-4)).toBe(true); + }); + + it("all faces wind CCW from outside", () => { + const polygons = conePolygons({ radialSegments: 8 }); + const centroid: Vec3 = [0, 0, 0]; + for (const p of polygons) { + expect(isCCWFromOutside(p.vertices, centroid)).toBe(true); + } + }); +}); diff --git a/packages/core/src/helpers/conePolygons.ts b/packages/core/src/helpers/conePolygons.ts new file mode 100644 index 00000000..ffb8547f --- /dev/null +++ b/packages/core/src/helpers/conePolygons.ts @@ -0,0 +1,27 @@ +/** + * Y-axis cone geometry — a cylinder with `radiusTop: 0`. + * + * This is a thin wrapper around `cylinderPolygons` with `radiusTop` forced + * to zero. The top cap is omitted (no area at the tip). Geometry and winding + * conventions are identical to `cylinderPolygons`. + * + * Polycss world space: +X right, +Y forward, +Z up. Cone axis is Y; the apex + * is at Y = +height/2 and the base at Y = −height/2. + */ +import type { Polygon } from "../types"; +import { cylinderPolygons } from "./cylinderPolygons"; + +export interface ConePolygonsOptions { + /** Base radius. Default 50. */ + radius?: number; + /** Height along the Y axis. Default 100. */ + height?: number; + /** Number of radial segments. Default 12. */ + radialSegments?: number; + /** Fill color applied to all polygons. */ + color?: string; +} + +export function conePolygons(options: ConePolygonsOptions = {}): Polygon[] { + return cylinderPolygons({ ...options, radiusTop: 0 }); +} diff --git a/packages/core/src/helpers/cylinderPolygons.test.ts b/packages/core/src/helpers/cylinderPolygons.test.ts new file mode 100644 index 00000000..339d4313 --- /dev/null +++ b/packages/core/src/helpers/cylinderPolygons.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import type { Vec3 } from "../types"; +import { cylinderPolygons } from "./cylinderPolygons"; + +// ── Test helpers ───────────────────────────────────────────────────────────── + +function cross(a: Vec3, b: Vec3): Vec3 { + 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 sub(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +function dot(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function len(v: Vec3): number { + return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); +} + +function isCoplanar(vertices: Vec3[], eps = 1e-4): boolean { + if (vertices.length <= 3) return true; + const [a, b, c, ...rest] = vertices; + const n = cross(sub(b, a), sub(c, a)); + const nl = len(n); + if (nl < 1e-10) return false; + for (const v of rest) { + const dist = Math.abs(dot(n, sub(v, a))) / nl; + if (dist > eps) return false; + } + return true; +} + +function faceCenter(vertices: Vec3[]): Vec3 { + const c: Vec3 = [0, 0, 0]; + for (const v of vertices) { c[0] += v[0]; c[1] += v[1]; c[2] += v[2]; } + return [c[0] / vertices.length, c[1] / vertices.length, c[2] / vertices.length]; +} + +function isCCWFromOutside(vertices: Vec3[], solidCentroid: Vec3): boolean { + const [a, b, c] = vertices; + const n = cross(sub(b, a), sub(c, a)); + const fc = faceCenter(vertices); + return dot(n, sub(fc, solidCentroid)) > 0; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("cylinderPolygons", () => { + it("returns the correct polygon count for a closed cylinder (default: 12 segments)", () => { + // 12 side quads + 12 bottom triangles + 12 top triangles = 36 + const polygons = cylinderPolygons(); + expect(polygons).toHaveLength(36); + }); + + it("side quads have 4 vertices; cap triangles have 3 vertices", () => { + const n = 8; + const polygons = cylinderPolygons({ radialSegments: n }); + // First n = 8 polygons are side quads. + for (let i = 0; i < n; i++) expect(polygons[i].vertices).toHaveLength(4); + // Next 2n are cap triangles. + for (let i = n; i < 3 * n; i++) expect(polygons[i].vertices).toHaveLength(3); + }); + + it("uses default radius 50, height 100, color #cccccc", () => { + const polygons = cylinderPolygons(); + for (const p of polygons) expect(p.color).toBe("#cccccc"); + // All vertices should have |z| ≤ 50 (half height) — axis is Z (up). + for (const p of polygons) { + for (const v of p.vertices) expect(Math.abs(v[2])).toBeLessThanOrEqual(50 + 1e-6); + } + // Bottom-cap center is at z = -50; top-cap center at z = +50. + const zVals = polygons.flatMap((p) => p.vertices.map((v) => v[2])); + expect(Math.min(...zVals)).toBeCloseTo(-50, 5); + expect(Math.max(...zVals)).toBeCloseTo(50, 5); + }); + + it("respects custom radius, height, radialSegments, and color", () => { + const polygons = cylinderPolygons({ radius: 30, height: 60, radialSegments: 6, color: "#112233" }); + // 6 sides + 6 bottom + 6 top = 18 + expect(polygons).toHaveLength(18); + for (const p of polygons) expect(p.color).toBe("#112233"); + const zVals = polygons.flatMap((p) => p.vertices.map((v) => v[2])); + expect(Math.min(...zVals)).toBeCloseTo(-30, 5); + expect(Math.max(...zVals)).toBeCloseTo(30, 5); + }); + + it("every face is coplanar within epsilon", () => { + const polygons = cylinderPolygons({ radialSegments: 12 }); + for (const p of polygons) expect(isCoplanar(p.vertices, 1e-4)).toBe(true); + }); + + it("all faces wind CCW from outside", () => { + const polygons = cylinderPolygons({ radialSegments: 8 }); + const centroid: Vec3 = [0, 0, 0]; // cylinder centered at origin + for (const p of polygons) { + expect(isCCWFromOutside(p.vertices, centroid)).toBe(true); + } + }); + + it("omits top cap when radiusTop is 0", () => { + const n = 6; + const polygons = cylinderPolygons({ radialSegments: n, radiusTop: 0 }); + // n sides + n bottom cap (no top cap) = 2n + expect(polygons).toHaveLength(2 * n); + }); + + it("supports a tapered cylinder (frustum)", () => { + const n = 6; + const polygons = cylinderPolygons({ radius: 40, radiusTop: 20, radialSegments: n }); + // n sides + n bottom + n top = 3n + expect(polygons).toHaveLength(3 * n); + }); +}); diff --git a/packages/core/src/helpers/cylinderPolygons.ts b/packages/core/src/helpers/cylinderPolygons.ts new file mode 100644 index 00000000..fa00ecb0 --- /dev/null +++ b/packages/core/src/helpers/cylinderPolygons.ts @@ -0,0 +1,107 @@ +/** + * Z-axis cylinder geometry with optional radius taper. + * + * Geometry: + * - `radialSegments` side quads (one per angular segment). + * - `radialSegments` bottom-cap triangles (fan from center). + * - `radialSegments` top-cap triangles (fan from center), omitted when + * radiusTop ≈ 0 (i.e. cone tip). + * + * The cylinder sits centered at the origin, spanning Z = −height/2 to + * Z = +height/2. Side quads are axis-aligned in the cylinder's own local + * frame, which maximises the chance of hitting the quad fast-path. + * + * Polycss world space: +X right, +Y forward, +Z up. The cylinder axis is + * the Z axis so a typical upright pillar stands without any extra rotation. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface CylinderPolygonsOptions { + /** Bottom-cap radius. Default 50. */ + radius?: number; + /** Top-cap radius. Defaults to `radius` (straight cylinder). + * Set to 0 (or near 0) for a cone. */ + radiusTop?: number; + /** Height along the Z axis. Default 100. */ + height?: number; + /** Number of radial segments (quads on the side). Default 12. */ + radialSegments?: number; + /** Fill color applied to all polygons. */ + color?: string; +} + +/** Threshold below which `radiusTop` is treated as zero (cone tip, no cap). */ +const RADIUS_ZERO_EPS = 1e-6; + +export function cylinderPolygons(options: CylinderPolygonsOptions = {}): Polygon[] { + const { + radius = 50, + height = 100, + radialSegments = 12, + color = "#cccccc", + } = options; + const radiusTop = options.radiusTop ?? radius; + + const halfH = height / 2; + const zBottom = -halfH; + const zTop = halfH; + const n = Math.max(3, radialSegments); + const polygons: Polygon[] = []; + + // Pre-compute the angle for each segment boundary. + const angles: number[] = Array.from({ length: n + 1 }, (_, i) => (i / n) * Math.PI * 2); + + // ── Side quads ─────────────────────────────────────────────────────────── + // One quad per segment. Order [bl, br, tr, tl] is CCW from outside in + // Z-up world space: the outward normal points radially away from the axis. + for (let i = 0; i < n; i++) { + const a0 = angles[i]; + const a1 = angles[i + 1]; + const bx0 = Math.cos(a0) * radius; + const by0 = Math.sin(a0) * radius; + const bx1 = Math.cos(a1) * radius; + const by1 = Math.sin(a1) * radius; + const tx0 = Math.cos(a0) * radiusTop; + const ty0 = Math.sin(a0) * radiusTop; + const tx1 = Math.cos(a1) * radiusTop; + const ty1 = Math.sin(a1) * radiusTop; + + const bl: Vec3 = [bx0, by0, zBottom]; + const br: Vec3 = [bx1, by1, zBottom]; + const tr: Vec3 = [tx1, ty1, zTop]; + const tl: Vec3 = [tx0, ty0, zTop]; + + // CCW from outside: cross((br - bl), (tr - bl)) points radially outward. + polygons.push({ vertices: [bl, br, tr, tl], color }); + } + + // ── Bottom cap (radius > 0) ────────────────────────────────────────────── + // Triangle fan from the bottom center. Outward normal is −Z, so CCW order + // viewed from below is [center, p1, p0] (the reverse of the ring sweep). + if (radius > RADIUS_ZERO_EPS) { + const center: Vec3 = [0, 0, zBottom]; + for (let i = 0; i < n; i++) { + const a0 = angles[i]; + const a1 = angles[i + 1]; + const p0: Vec3 = [Math.cos(a0) * radius, Math.sin(a0) * radius, zBottom]; + const p1: Vec3 = [Math.cos(a1) * radius, Math.sin(a1) * radius, zBottom]; + polygons.push({ vertices: [[...center], [...p1], [...p0]], color }); + } + } + + // ── Top cap (radiusTop > 0) ────────────────────────────────────────────── + // Triangle fan from the top center. Outward normal is +Z, so CCW order + // viewed from above is [center, p0, p1] (matching the ring sweep). + if (radiusTop > RADIUS_ZERO_EPS) { + const center: Vec3 = [0, 0, zTop]; + for (let i = 0; i < n; i++) { + const a0 = angles[i]; + const a1 = angles[i + 1]; + const p0: Vec3 = [Math.cos(a0) * radiusTop, Math.sin(a0) * radiusTop, zTop]; + const p1: Vec3 = [Math.cos(a1) * radiusTop, Math.sin(a1) * radiusTop, zTop]; + polygons.push({ vertices: [[...center], [...p0], [...p1]], color }); + } + } + + return polygons; +} diff --git a/packages/core/src/helpers/dodecahedronPolygons.test.ts b/packages/core/src/helpers/dodecahedronPolygons.test.ts new file mode 100644 index 00000000..5395decf --- /dev/null +++ b/packages/core/src/helpers/dodecahedronPolygons.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import type { Vec3 } from "../types"; +import { dodecahedronPolygons } from "./dodecahedronPolygons"; + +// ── Test helpers ───────────────────────────────────────────────────────────── + +function cross(a: Vec3, b: Vec3): Vec3 { + 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 sub(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +function dot(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function len(v: Vec3): number { + return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); +} + +/** + * Returns true if all vertices of a polygon lie on the same plane within + * the given epsilon. Uses the plane defined by the first three vertices. + */ +function isCoplanar(vertices: Vec3[], eps = 1e-4): boolean { + if (vertices.length <= 3) return true; + const [a, b, c, ...rest] = vertices; + const n = cross(sub(b, a), sub(c, a)); + const nl = len(n); + if (nl < 1e-10) return false; // degenerate + for (const v of rest) { + const dist = Math.abs(dot(n, sub(v, a))) / nl; + if (dist > eps) return false; + } + return true; +} + +function isCCWFromOutside(vertices: Vec3[], solidCentroid: Vec3): boolean { + const [a, b, c] = vertices; + const n = cross(sub(b, a), sub(c, a)); + const fc: Vec3 = [0, 0, 0]; + for (const v of vertices) { fc[0] += v[0]; fc[1] += v[1]; fc[2] += v[2]; } + fc[0] /= vertices.length; fc[1] /= vertices.length; fc[2] /= vertices.length; + return dot(n, sub(fc, solidCentroid)) > 0; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("dodecahedronPolygons", () => { + it("returns exactly 12 faces", () => { + const polygons = dodecahedronPolygons(); + expect(polygons).toHaveLength(12); + }); + + it("each face has exactly 5 vertices (pentagons)", () => { + const polygons = dodecahedronPolygons(); + for (const p of polygons) expect(p.vertices).toHaveLength(5); + }); + + it("uses default size 100 and color #cccccc", () => { + const polygons = dodecahedronPolygons(); + for (const p of polygons) expect(p.color).toBe("#cccccc"); + // All 20 unique vertices should sit at radius ~100. + const seen = new Set(); + for (const p of polygons) { + for (const v of p.vertices) { + const key = v.map((x) => x.toFixed(5)).join(","); + if (!seen.has(key)) { + seen.add(key); + expect(len(v)).toBeCloseTo(100, 3); + } + } + } + expect(seen.size).toBe(20); + }); + + it("respects custom size and color", () => { + const polygons = dodecahedronPolygons({ size: 50, color: "#123456" }); + for (const p of polygons) { + expect(p.color).toBe("#123456"); + for (const v of p.vertices) expect(len(v)).toBeCloseTo(50, 3); + } + }); + + it("every face is coplanar within epsilon", () => { + const polygons = dodecahedronPolygons({ size: 100 }); + for (const p of polygons) { + expect(isCoplanar(p.vertices, 1e-3)).toBe(true); + } + }); + + it("all faces wind CCW from outside", () => { + const polygons = dodecahedronPolygons({ size: 100 }); + const centroid: Vec3 = [0, 0, 0]; + for (const p of polygons) { + expect(isCCWFromOutside(p.vertices, centroid)).toBe(true); + } + }); +}); diff --git a/packages/core/src/helpers/dodecahedronPolygons.ts b/packages/core/src/helpers/dodecahedronPolygons.ts new file mode 100644 index 00000000..52d3995c --- /dev/null +++ b/packages/core/src/helpers/dodecahedronPolygons.ts @@ -0,0 +1,90 @@ +/** + * Regular dodecahedron geometry — 12 regular pentagonal faces. + * + * Vertices are placed on a sphere of radius `size` centered at the origin. + * Each face is a pentagon (5 vertices) wound CCW from the outside. + * + * All 12 pentagonal faces of a regular dodecahedron are truly planar — each + * lies on a single tangent plane. No triangulation is needed. The renderer + * uses (border-shape) on Chromium and elsewhere for non-quad polygons. + * + * Polycss world space: +X right, +Y forward, +Z up. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface DodecahedronPolygonsOptions { + /** Circumradius: distance from center to each vertex. Default 100. */ + size?: number; + /** Fill color applied to all 12 faces. */ + color?: string; +} + +export function dodecahedronPolygons(options: DodecahedronPolygonsOptions = {}): Polygon[] { + const { size = 100, color = "#cccccc" } = options; + + // A regular dodecahedron has 20 vertices built from three classes: + // + // • 8 cube corners: (±1, ±1, ±1) + // • 4 on the YZ golden rect: (0, ±q, ±φ) + // • 4 on the XZ golden rect: (±q, ±φ, 0) + // • 4 on the XY golden rect: (±φ, 0, ±q) + // + // where φ = golden ratio ≈ 1.618 and q = 1/φ ≈ 0.618. + // All 20 raw vertices lie on a sphere of radius √3; we scale to `size`. + const phi = (1 + Math.sqrt(5)) / 2; + const q = 1 / phi; + + const scale = size / Math.sqrt(3); + + const raw: Vec3[] = [ + // cube corners + [ 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 + // golden-rectangle class A: (0, ±q, ±φ) + [0, q, phi], // 8 + [0, -q, phi], // 9 + [0, q, -phi], // 10 + [0, -q, -phi], // 11 + // golden-rectangle class B: (±q, ±φ, 0) + [ q, phi, 0], // 12 + [-q, phi, 0], // 13 + [ q, -phi, 0], // 14 + [-q, -phi, 0], // 15 + // golden-rectangle class C: (±φ, 0, ±q) + [ phi, 0, q], // 16 + [ phi, 0, -q], // 17 + [-phi, 0, q], // 18 + [-phi, 0, -q], // 19 + ]; + + const v: Vec3[] = raw.map(([x, y, z]) => [x * scale, y * scale, z * scale]); + + // 12 pentagonal faces, each with 5 vertex indices ordered CCW from outside. + // Derived by graph-traversal on the edge adjacency of the dodecahedron + // (edges connect vertices at distance 2/φ ≈ 1.236 in the raw unit coordinates). + const faceIndices: number[][] = [ + [12, 13, 4, 8, 0], + [ 0, 8, 9, 2, 16], + [16, 17, 1, 12, 0], + [ 1, 10, 5, 13, 12], + [17, 3, 11, 10, 1], + [ 2, 9, 6, 15, 14], + [ 2, 14, 3, 17, 16], + [14, 15, 7, 11, 3], + [18, 6, 9, 8, 4], + [ 4, 13, 5, 19, 18], + [ 5, 10, 11, 7, 19], + [18, 19, 7, 15, 6], + ]; + + return faceIndices.map((indices) => ({ + vertices: indices.map((i) => v[i]), + color, + })); +} diff --git a/packages/core/src/helpers/icosahedronPolygons.test.ts b/packages/core/src/helpers/icosahedronPolygons.test.ts new file mode 100644 index 00000000..d66540c8 --- /dev/null +++ b/packages/core/src/helpers/icosahedronPolygons.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import type { Vec3 } from "../types"; +import { icosahedronPolygons } from "./icosahedronPolygons"; + +// ── Test helpers ───────────────────────────────────────────────────────────── + +function cross(a: Vec3, b: Vec3): Vec3 { + 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 sub(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +function dot(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function len(v: Vec3): number { + return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); +} + +function isCCWFromOutside(vertices: Vec3[], solidCentroid: Vec3): boolean { + const [a, b, c] = vertices; + const n = cross(sub(b, a), sub(c, a)); + const fc: Vec3 = [0, 0, 0]; + for (const v of vertices) { fc[0] += v[0]; fc[1] += v[1]; fc[2] += v[2]; } + fc[0] /= vertices.length; fc[1] /= vertices.length; fc[2] /= vertices.length; + return dot(n, sub(fc, solidCentroid)) > 0; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("icosahedronPolygons", () => { + it("returns exactly 20 triangular faces", () => { + const polygons = icosahedronPolygons(); + expect(polygons).toHaveLength(20); + for (const p of polygons) expect(p.vertices).toHaveLength(3); + }); + + it("uses default size 100 and color #cccccc", () => { + const polygons = icosahedronPolygons(); + for (const p of polygons) { + expect(p.color).toBe("#cccccc"); + } + // All 12 unique vertices should sit at radius ~100. + const seen = new Set(); + for (const p of polygons) { + for (const v of p.vertices) { + const key = v.map((x) => x.toFixed(6)).join(","); + if (!seen.has(key)) { + seen.add(key); + expect(len(v)).toBeCloseTo(100, 4); + } + } + } + expect(seen.size).toBe(12); // exactly 12 distinct vertices + }); + + it("respects custom size and color", () => { + const polygons = icosahedronPolygons({ size: 200, color: "#abcdef" }); + for (const p of polygons) { + expect(p.color).toBe("#abcdef"); + for (const v of p.vertices) expect(len(v)).toBeCloseTo(200, 3); + } + }); + + it("all faces wind CCW from outside", () => { + const polygons = icosahedronPolygons({ size: 100 }); + const centroid: Vec3 = [0, 0, 0]; + for (const p of polygons) { + expect(isCCWFromOutside(p.vertices, centroid)).toBe(true); + } + }); + + it("all face normals point outward (dot with face center > 0)", () => { + const polygons = icosahedronPolygons({ size: 100 }); + for (const p of polygons) { + const [a, b, c] = p.vertices; + const n = cross(sub(b, a), sub(c, a)); + const fc: Vec3 = [ + (a[0] + b[0] + c[0]) / 3, + (a[1] + b[1] + c[1]) / 3, + (a[2] + b[2] + c[2]) / 3, + ]; + expect(dot(n, fc)).toBeGreaterThan(0); + } + }); + + it("all faces are equilateral (equal edge lengths within epsilon)", () => { + const polygons = icosahedronPolygons({ size: 100 }); + for (const p of polygons) { + const [a, b, c] = p.vertices; + const ab = len(sub(b, a)); + const bc = len(sub(c, b)); + const ca = len(sub(a, c)); + expect(ab).toBeCloseTo(bc, 4); + expect(bc).toBeCloseTo(ca, 4); + } + }); +}); diff --git a/packages/core/src/helpers/icosahedronPolygons.ts b/packages/core/src/helpers/icosahedronPolygons.ts new file mode 100644 index 00000000..43981fc7 --- /dev/null +++ b/packages/core/src/helpers/icosahedronPolygons.ts @@ -0,0 +1,58 @@ +/** + * Regular icosahedron geometry — 20 equilateral triangular faces. + * + * Vertices are placed on a sphere of radius `size` centered at the origin. + * Faces wind CCW from the outside. + * + * Polycss world space: +X right, +Y forward, +Z up. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface IcosahedronPolygonsOptions { + /** Circumradius: distance from center to each vertex. Default 100. */ + size?: number; + /** Fill color applied to all 20 faces. */ + color?: string; +} + +export function icosahedronPolygons(options: IcosahedronPolygonsOptions = {}): Polygon[] { + const { size = 100, color = "#cccccc" } = options; + + // Icosahedron vertices via the golden-ratio construction. + // Three orthogonal golden rectangles yield 12 vertices on the unit sphere. + const phi = (1 + Math.sqrt(5)) / 2; + // Normalization so all vertices sit exactly at radius `size`. + const scale = size / Math.sqrt(1 + phi * phi); + + const v: Vec3[] = [ + [-1, phi, 0], // 0 + [ 1, phi, 0], // 1 + [-1, -phi, 0], // 2 + [ 1, -phi, 0], // 3 + [0, -1, phi], // 4 + [0, 1, phi], // 5 + [0, -1, -phi], // 6 + [0, 1, -phi], // 7 + [ phi, 0, -1], // 8 + [ phi, 0, 1], // 9 + [-phi, 0, -1], // 10 + [-phi, 0, 1], // 11 + ].map(([x, y, z]) => [x * scale, y * scale, z * scale] as Vec3); + + // 20 triangular faces, wound CCW from outside. + const faces: [number, number, number][] = [ + // Top cap (around vertex 1, the north pole) + [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], + // Upper ring + [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], + // Lower ring + [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], + // Bottom cap (around vertex 3, the south pole) + [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1], + ]; + + return faces.map(([a, b, c]) => ({ + vertices: [v[a], v[b], v[c]], + color, + })); +} diff --git a/packages/core/src/helpers/index.ts b/packages/core/src/helpers/index.ts index 199675f1..79d8d8e0 100644 --- a/packages/core/src/helpers/index.ts +++ b/packages/core/src/helpers/index.ts @@ -12,3 +12,17 @@ export { planePolygons } from "./planePolygons"; export type { PlanePolygonsOptions } from "./planePolygons"; export { octahedronPolygons } from "./octahedronPolygons"; export type { OctahedronPolygonsOptions } from "./octahedronPolygons"; +export { spherePolygons } from "./spherePolygons"; +export type { SpherePolygonsOptions } from "./spherePolygons"; +export { tetrahedronPolygons } from "./tetrahedronPolygons"; +export type { TetrahedronPolygonsOptions } from "./tetrahedronPolygons"; +export { icosahedronPolygons } from "./icosahedronPolygons"; +export type { IcosahedronPolygonsOptions } from "./icosahedronPolygons"; +export { dodecahedronPolygons } from "./dodecahedronPolygons"; +export type { DodecahedronPolygonsOptions } from "./dodecahedronPolygons"; +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"; diff --git a/packages/core/src/helpers/spherePolygons.test.ts b/packages/core/src/helpers/spherePolygons.test.ts new file mode 100644 index 00000000..1de37dd1 --- /dev/null +++ b/packages/core/src/helpers/spherePolygons.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import type { Vec3 } from "../types"; +import { spherePolygons } from "./spherePolygons"; + +// ── Test helpers ───────────────────────────────────────────────────────────── + +function cross(a: Vec3, b: Vec3): Vec3 { + 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 sub(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +function dot(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function len(v: Vec3): number { + return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); +} + +function isCCWFromOutside(vertices: Vec3[], solidCentroid: Vec3): boolean { + const [a, b, c] = vertices; + const n = cross(sub(b, a), sub(c, a)); + const fc: Vec3 = [0, 0, 0]; + for (const v of vertices) { fc[0] += v[0]; fc[1] += v[1]; fc[2] += v[2]; } + fc[0] /= vertices.length; fc[1] /= vertices.length; fc[2] /= vertices.length; + return dot(n, sub(fc, solidCentroid)) > 0; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("spherePolygons", () => { + it("default polygon count is 80 (icosahedron 20 × 4 at subdivisions 1)", () => { + expect(spherePolygons()).toHaveLength(80); + }); + + it("returns correct counts at subdivisions 0/1/2/3", () => { + expect(spherePolygons({ subdivisions: 0 })).toHaveLength(20); + expect(spherePolygons({ subdivisions: 1 })).toHaveLength(80); + expect(spherePolygons({ subdivisions: 2 })).toHaveLength(320); + expect(spherePolygons({ subdivisions: 3 })).toHaveLength(1280); + }); + + it("every face is a triangle", () => { + for (const p of spherePolygons()) { + expect(p.vertices).toHaveLength(3); + } + }); + + it("every face is coplanar (trivially true for triangles)", () => { + // Three points always define a plane, so this is an invariant by definition. + for (const p of spherePolygons()) { + expect(p.vertices).toHaveLength(3); + } + }); + + it("every face winds CCW from outside (outward normal = away from origin)", () => { + const centroid: Vec3 = [0, 0, 0]; + for (const p of spherePolygons()) { + expect(isCCWFromOutside(p.vertices, centroid)).toBe(true); + } + }); + + it("default radius is 50, vertex magnitude ≈ 50 for every vertex", () => { + const polygons = spherePolygons(); + for (const p of polygons) { + for (const v of p.vertices) { + expect(len(v)).toBeCloseTo(50, 4); + } + } + }); + + it("respects custom radius", () => { + const polygons = spherePolygons({ radius: 200 }); + for (const p of polygons) { + for (const v of p.vertices) { + expect(len(v)).toBeCloseTo(200, 3); + } + } + }); + + it("respects custom color", () => { + const polygons = spherePolygons({ color: "#abcdef" }); + for (const p of polygons) { + expect(p.color).toBe("#abcdef"); + } + }); + + it("uses default color #cccccc", () => { + const polygons = spherePolygons(); + for (const p of polygons) { + expect(p.color).toBe("#cccccc"); + } + }); + + it("custom radius and subdivisions round-trip correctly", () => { + const polygons = spherePolygons({ radius: 75, subdivisions: 2, color: "#112233" }); + expect(polygons).toHaveLength(320); + for (const p of polygons) { + expect(p.color).toBe("#112233"); + for (const v of p.vertices) { + expect(len(v)).toBeCloseTo(75, 3); + } + } + }); + + it("subdivisions > 3 is clamped to 3", () => { + expect(spherePolygons({ subdivisions: 5 })).toHaveLength(1280); + expect(spherePolygons({ subdivisions: 10 })).toHaveLength(1280); + }); + + it("all face normals point outward (dot with face centroid > 0)", () => { + const polygons = spherePolygons({ subdivisions: 1 }); + for (const p of polygons) { + const [a, b, c] = p.vertices; + const n = cross(sub(b, a), sub(c, a)); + const fc: Vec3 = [ + (a[0] + b[0] + c[0]) / 3, + (a[1] + b[1] + c[1]) / 3, + (a[2] + b[2] + c[2]) / 3, + ]; + expect(dot(n, fc)).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/core/src/helpers/spherePolygons.ts b/packages/core/src/helpers/spherePolygons.ts new file mode 100644 index 00000000..9a3af40e --- /dev/null +++ b/packages/core/src/helpers/spherePolygons.ts @@ -0,0 +1,98 @@ +/** + * Icosphere (subdivided icosahedron) geometry — approximates a sphere with + * triangular faces. Each subdivision step quadruples the face count: + * subdivisions 0 → 20, 1 → 80, 2 → 320, 3 → 1280 (capped). + * + * Vertex coordinates use polycss world space: +X right, +Y forward, +Z up. + * The sphere is centered at the origin; all vertices sit at distance `radius`. + * Faces wind CCW from the outside (outward normal = away from origin). + */ +import type { Polygon, Vec3 } from "../types"; + +export interface SpherePolygonsOptions { + /** Radius of the sphere. Default 50. */ + radius?: number; + /** Subdivision level (0 = bare icosahedron, 20 triangles; each +1 quadruples count). Default 1 → 80 triangles. Cap at 3 (1280 triangles). */ + subdivisions?: number; + /** Fill color applied to all faces. */ + color?: string; +} + +export function spherePolygons(options: SpherePolygonsOptions = {}): Polygon[] { + const { radius = 50, color = "#cccccc" } = options; + // Cap subdivisions at 3 (1280 triangles) to prevent accidental extreme DOM cost. + const subdivisions = Math.min(Math.max(0, Math.floor(options.subdivisions ?? 1)), 3); + + const phi = (1 + Math.sqrt(5)) / 2; + + // 12 vertices of a regular icosahedron, normalized to the unit sphere. + let verts: Vec3[] = [ + [-1, phi, 0], + [ 1, phi, 0], + [-1, -phi, 0], + [ 1, -phi, 0], + [0, -1, phi], + [0, 1, phi], + [0, -1, -phi], + [0, 1, -phi], + [ phi, 0, -1], + [ phi, 0, 1], + [-phi, 0, -1], + [-phi, 0, 1], + ].map(([x, y, z]) => { + const l = Math.sqrt(x * x + y * y + z * z); + return [x / l, y / l, z / l] as Vec3; + }); + + // 20 triangular faces wound CCW from the outside. + let faces: [number, number, number][] = [ + [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], + [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], + [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], + [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1], + ]; + + // Subdivide: each triangle is split into 4 by inserting midpoints on each + // edge. Midpoints are projected back onto the unit sphere to preserve the + // spherical shape. + for (let s = 0; s < subdivisions; s++) { + const newVerts: Vec3[] = [...verts]; + const newFaces: [number, number, number][] = []; + const midCache = new Map(); + + const midpoint = (i: number, j: number): number => { + const key = i < j ? `${i}-${j}` : `${j}-${i}`; + const cached = midCache.get(key); + if (cached !== undefined) return cached; + const a = verts[i], b = verts[j]; + let mx = (a[0] + b[0]) / 2, my = (a[1] + b[1]) / 2, mz = (a[2] + b[2]) / 2; + const l = Math.sqrt(mx * mx + my * my + mz * mz); + mx /= l; my /= l; mz /= l; + const idx = newVerts.length; + newVerts.push([mx, my, mz]); + midCache.set(key, idx); + return idx; + }; + + for (const [a, b, c] of faces) { + const ab = midpoint(a, b); + const bc = midpoint(b, c); + const ca = midpoint(c, a); + newFaces.push([a, ab, ca]); + newFaces.push([b, bc, ab]); + newFaces.push([c, ca, bc]); + newFaces.push([ab, bc, ca]); + } + + verts = newVerts; + faces = newFaces; + } + + // Scale from unit sphere to the requested radius. + const scaled: Vec3[] = verts.map(([x, y, z]) => [x * radius, y * radius, z * radius]); + + return faces.map(([a, b, c]) => ({ + vertices: [scaled[a], scaled[b], scaled[c]], + color, + })); +} diff --git a/packages/core/src/helpers/tetrahedronPolygons.test.ts b/packages/core/src/helpers/tetrahedronPolygons.test.ts new file mode 100644 index 00000000..08823ce9 --- /dev/null +++ b/packages/core/src/helpers/tetrahedronPolygons.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import type { Vec3 } from "../types"; +import { tetrahedronPolygons } from "./tetrahedronPolygons"; + +// ── Test helpers ──────────────────────────────────────────────────────────── + +/** Cross product of two Vec3 vectors. */ +function cross(a: Vec3, b: Vec3): Vec3 { + 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], + ]; +} + +/** Subtract b from a. */ +function sub(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +/** Dot product. */ +function dot(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +/** Length of a vector. */ +function len(v: Vec3): number { + return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); +} + +/** + * Returns true if winding is CCW from outside: + * the cross product of the first two edges should point away from the + * centroid of all vertices passed in (i.e. the origin for a centered solid). + */ +function isCCWFromOutside(vertices: Vec3[], solidCentroid: Vec3): boolean { + const [a, b, c] = vertices; + const e1 = sub(b, a); + const e2 = sub(c, a); + const n = cross(e1, e2); + // Face center + const fc: Vec3 = [0, 0, 0]; + for (const v of vertices) { + fc[0] += v[0]; fc[1] += v[1]; fc[2] += v[2]; + } + fc[0] /= vertices.length; fc[1] /= vertices.length; fc[2] /= vertices.length; + // Vector from solid centroid to face center + const outDir = sub(fc, solidCentroid); + return dot(n, outDir) > 0; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe("tetrahedronPolygons", () => { + it("returns exactly 4 triangular faces", () => { + const polygons = tetrahedronPolygons(); + expect(polygons).toHaveLength(4); + for (const p of polygons) expect(p.vertices).toHaveLength(3); + }); + + it("uses default size 100 and color #cccccc", () => { + const polygons = tetrahedronPolygons(); + for (const p of polygons) { + expect(p.color).toBe("#cccccc"); + } + // All vertices should be at distance ~100 from origin. + for (const p of polygons) { + for (const v of p.vertices) { + expect(len(v)).toBeCloseTo(100, 4); + } + } + }); + + it("respects a custom size", () => { + const polygons = tetrahedronPolygons({ size: 50 }); + for (const p of polygons) { + for (const v of p.vertices) { + expect(len(v)).toBeCloseTo(50, 4); + } + } + }); + + it("respects a custom color", () => { + const polygons = tetrahedronPolygons({ color: "#ff0000" }); + for (const p of polygons) expect(p.color).toBe("#ff0000"); + }); + + it("all faces are coplanar (triangles are always planar)", () => { + // A triangle is always planar by definition — this test just confirms + // vertex count doesn't accidentally introduce a 4th vertex. + const polygons = tetrahedronPolygons({ size: 100 }); + for (const p of polygons) expect(p.vertices).toHaveLength(3); + }); + + it("all faces wind CCW from outside", () => { + const polygons = tetrahedronPolygons({ size: 100 }); + const centroid: Vec3 = [0, 0, 0]; // tetrahedron is centered at origin + for (const p of polygons) { + expect(isCCWFromOutside(p.vertices, centroid)).toBe(true); + } + }); + + it("all four face normals point in distinct directions", () => { + const polygons = tetrahedronPolygons({ size: 100 }); + const normals = polygons.map((p) => { + const [a, b, c] = p.vertices; + const n = cross(sub(b, a), sub(c, a)); + const l = len(n); + return [n[0] / l, n[1] / l, n[2] / l] as Vec3; + }); + // No two normals should be identical (or antiparallel) — tetrahedron has 4 distinct faces. + for (let i = 0; i < normals.length; i++) { + for (let j = i + 1; j < normals.length; j++) { + const d = Math.abs(dot(normals[i], normals[j])); + // A regular tetrahedron has normals at ~109.5° apart, so |dot| ≈ 1/3. + expect(d).toBeLessThan(0.99); + } + } + }); +}); diff --git a/packages/core/src/helpers/tetrahedronPolygons.ts b/packages/core/src/helpers/tetrahedronPolygons.ts new file mode 100644 index 00000000..caafb686 --- /dev/null +++ b/packages/core/src/helpers/tetrahedronPolygons.ts @@ -0,0 +1,55 @@ +/** + * Regular tetrahedron geometry — four equilateral triangular faces. + * + * Vertices are placed so the tetrahedron is centered at the origin. + * Faces wind CCW from the outside. + * + * Polycss world space: +X right, +Y forward, +Z up. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface TetrahedronPolygonsOptions { + /** Circumradius: distance from center to each vertex. Default 100. */ + size?: number; + /** Fill color applied to all four faces. */ + color?: string; +} + +export function tetrahedronPolygons(options: TetrahedronPolygonsOptions = {}): Polygon[] { + const { size = 100, color = "#cccccc" } = options; + + // Four vertices of a regular tetrahedron inscribed in a sphere of radius `size`. + // Coordinates chosen so the solid is roughly upright (+Z dominant top vertex). + // + // Standard derivation: place two base vertices in the XY plane and two + // at +/-X, then lift the apex. + // + // Using the canonical form: + // v0 = ( 1, 1, 1) * k + // v1 = ( 1, -1, -1) * k + // v2 = (-1, 1, -1) * k + // v3 = (-1, -1, 1) * k + // Each has magnitude sqrt(3)*k, so k = size / sqrt(3). + const k = size / Math.sqrt(3); + const v: Vec3[] = [ + [ k, k, k], // 0 + [ k, -k, -k], // 1 + [-k, k, -k], // 2 + [-k, -k, k], // 3 + ]; + + // Four triangular faces, wound CCW from outside. + // Winding verified by checking that cross(e01, e02) points away from the + // origin (the centroid of the centered tetrahedron). + const faces: [number, number, number][] = [ + [0, 1, 2], // bottom + [0, 3, 1], // right + [0, 2, 3], // left + [1, 3, 2], // back + ]; + + return faces.map(([a, b, c]) => ({ + vertices: [v[a], v[b], v[c]], + color, + })); +} diff --git a/packages/core/src/helpers/torusPolygons.test.ts b/packages/core/src/helpers/torusPolygons.test.ts new file mode 100644 index 00000000..9d7ecfd4 --- /dev/null +++ b/packages/core/src/helpers/torusPolygons.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import type { Vec3 } from "../types"; +import { torusPolygons } from "./torusPolygons"; + +// ── Test helpers ───────────────────────────────────────────────────────────── + +function cross(a: Vec3, b: Vec3): Vec3 { + 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 sub(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} + +function dot(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function len(v: Vec3): number { + return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); +} + +function isCoplanar(vertices: Vec3[], eps = 1e-3): boolean { + if (vertices.length <= 3) return true; + const [a, b, c, ...rest] = vertices; + const n = cross(sub(b, a), sub(c, a)); + const nl = len(n); + if (nl < 1e-10) return false; + for (const v of rest) { + const dist = Math.abs(dot(n, sub(v, a))) / nl; + if (dist > eps) return false; + } + return true; +} + +/** + * Returns true if the outward normal of the face (from first-edge cross product) + * points away from the torus surface interior. + * + * In Z-up world space the ring lies in the XY plane; the donut hole points + * along Z. The outward direction at face center fc is (fc - ringPoint) where + * ringPoint is the nearest point on the ring axis circle in XY. + */ +function isCCWFromOutside(vertices: Vec3[], radius: number): boolean { + const [a, b, c] = vertices; + const n = cross(sub(b, a), sub(c, a)); + const fc: Vec3 = [0, 0, 0]; + for (const v of vertices) { fc[0] += v[0]; fc[1] += v[1]; fc[2] += v[2]; } + fc[0] /= vertices.length; fc[1] /= vertices.length; fc[2] /= vertices.length; + // Ring center at the same angular position as fc (in XY plane). + const fcXY = Math.sqrt(fc[0] * fc[0] + fc[1] * fc[1]); + if (fcXY < 1e-10) return true; // degenerate — skip + const ringX = (fc[0] / fcXY) * radius; + const ringY = (fc[1] / fcXY) * radius; + const outDir: Vec3 = [fc[0] - ringX, fc[1] - ringY, fc[2]]; + return dot(n, outDir) > 0; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("torusPolygons", () => { + it("returns radialSegments × tubularSegments quads (default: 12 × 16 = 192)", () => { + const polygons = torusPolygons(); + expect(polygons).toHaveLength(192); + for (const p of polygons) expect(p.vertices).toHaveLength(4); + }); + + it("uses default color #cccccc", () => { + const polygons = torusPolygons(); + for (const p of polygons) expect(p.color).toBe("#cccccc"); + }); + + it("respects custom segment counts", () => { + const polygons = torusPolygons({ radialSegments: 6, tubularSegments: 8 }); + expect(polygons).toHaveLength(48); + }); + + it("respects custom radius, tube, and color", () => { + const polygons = torusPolygons({ radius: 80, tube: 20, color: "#aabbcc", radialSegments: 4, tubularSegments: 4 }); + expect(polygons).toHaveLength(16); + for (const p of polygons) expect(p.color).toBe("#aabbcc"); + // All vertices should be within [radius - tube, radius + tube] of the Z axis. + for (const p of polygons) { + for (const [x, y] of p.vertices) { + const rDist = Math.sqrt(x * x + y * y); + expect(rDist).toBeGreaterThanOrEqual(80 - 20 - 1e-4); + expect(rDist).toBeLessThanOrEqual(80 + 20 + 1e-4); + } + } + }); + + it("every quad is coplanar within epsilon", () => { + // Torus quads are constructed by sampling at equal angular steps; each quad + // is exactly planar (bilinear patch with zero twist at symmetric parametric spacing). + const polygons = torusPolygons({ radialSegments: 12, tubularSegments: 16 }); + for (const p of polygons) { + expect(isCoplanar(p.vertices, 1e-3)).toBe(true); + } + }); + + it("all faces wind CCW from outside (outward normal points away from tube axis)", () => { + const r = 50; + const polygons = torusPolygons({ radius: r, radialSegments: 8, tubularSegments: 8 }); + for (const p of polygons) { + expect(isCCWFromOutside(p.vertices, r)).toBe(true); + } + }); + + it("Z coordinates span the tube diameter (−tube to +tube)", () => { + // Donut hole is along the Z axis; tube reaches ±tube in Z. + const polygons = torusPolygons({ tube: 20, radialSegments: 4, tubularSegments: 16 }); + const zVals = polygons.flatMap((p) => p.vertices.map((v) => v[2])); + expect(Math.min(...zVals)).toBeCloseTo(-20, 4); + expect(Math.max(...zVals)).toBeCloseTo(20, 4); + }); +}); diff --git a/packages/core/src/helpers/torusPolygons.ts b/packages/core/src/helpers/torusPolygons.ts new file mode 100644 index 00000000..4db18230 --- /dev/null +++ b/packages/core/src/helpers/torusPolygons.ts @@ -0,0 +1,96 @@ +/** + * Torus geometry — Z-axis ring plane. + * + * The torus is centered at the origin. The ring lies in the XY plane (the + * ground plane in polycss world space, where Z is up). The donut hole points + * along the Z axis. + * + * Geometry: `radialSegments × tubularSegments` quads on the surface. + * + * DOM cost note: at default settings (12 × 16 = 192 quads) this is the + * heaviest of the built-in primitives. Reduce radialSegments / tubularSegments + * if render budget is tight. + * + * Polycss world space: +X right, +Y forward, +Z up. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface TorusPolygonsOptions { + /** Distance from center of tube to center of torus. Default 50. */ + radius?: number; + /** Radius of the tube. Default 15. */ + tube?: number; + /** Number of segments around the main ring. Default 12. */ + radialSegments?: number; + /** Number of segments around the tube cross-section. Default 16. */ + tubularSegments?: number; + /** Fill color applied to all polygons. */ + color?: string; +} + +/** + * Compute one point on the torus surface. + * + * @param theta Angle around the main ring (Z axis), in [0, 2π). + * @param phi Angle around the tube cross-section, in [0, 2π). + * @param R Main radius. + * @param r Tube radius. + */ +function torusPoint(theta: number, phi: number, R: number, r: number): Vec3 { + // Ring lies in XY plane; tube sweeps around the Z axis. + // Point on the tube cross-section at angle `theta` around the ring: + // ring center: (R·cos(θ), R·sin(θ), 0) + // tube outward radial: (cos(θ), sin(θ), 0) + // tube "up" (cross-section plane): (0, 0, 1) + // Full point: + // x = (R + r·cos(φ)) · cos(θ) + // y = (R + r·cos(φ)) · sin(θ) + // z = r · sin(φ) + const sinT = Math.sin(theta); + const cosT = Math.cos(theta); + const sinP = Math.sin(phi); + const cosP = Math.cos(phi); + return [ + (R + r * cosP) * cosT, + (R + r * cosP) * sinT, + r * sinP, + ]; +} + +export function torusPolygons(options: TorusPolygonsOptions = {}): Polygon[] { + const { + radius = 50, + tube = 15, + radialSegments = 12, + tubularSegments = 16, + color = "#cccccc", + } = options; + + const nr = Math.max(3, radialSegments); + const nt = Math.max(3, tubularSegments); + + const polygons: Polygon[] = []; + + for (let i = 0; i < nr; i++) { + const theta0 = (i / nr) * Math.PI * 2; + const theta1 = ((i + 1) / nr) * Math.PI * 2; + + for (let j = 0; j < nt; j++) { + const phi0 = (j / nt) * Math.PI * 2; + const phi1 = ((j + 1) / nt) * Math.PI * 2; + + // Four corners of this quad on the torus surface. + const p00 = torusPoint(theta0, phi0, radius, tube); + const p10 = torusPoint(theta1, phi0, radius, tube); + const p11 = torusPoint(theta1, phi1, radius, tube); + const p01 = torusPoint(theta0, phi1, radius, tube); + + // CCW from outside in Z-up world space: ordering [p00, p10, p11, p01]. + // The outward normal at (θ, φ) is (cosφ·cosθ, cosφ·sinθ, sinφ); cross + // of edges (p10−p00) × (p01−p00) points in that direction. + polygons.push({ vertices: [p00, p10, p11, p01], color }); + } + } + + return polygons; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 068f4823..78ac91ef 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -118,8 +118,8 @@ export type { } from "./cull/cameraBackfaceCulling"; // ── Helper geometry (boxes, axes, light marker, transform arrows / rings) ─ -export { axesHelperPolygons, boxPolygons, arrowPolygons, ringPolygons, ringQuadPolygons, planePolygons, octahedronPolygons } from "./helpers"; -export type { AxesHelperOptions, BoxFace, BoxFaceOptions, BoxPolygonsOptions, ArrowPolygonsOptions, RingPolygonsOptions, RingQuadPolygonsOptions, PlanePolygonsOptions, OctahedronPolygonsOptions } from "./helpers"; +export { axesHelperPolygons, boxPolygons, arrowPolygons, ringPolygons, ringQuadPolygons, planePolygons, octahedronPolygons, spherePolygons, tetrahedronPolygons, icosahedronPolygons, dodecahedronPolygons, cylinderPolygons, conePolygons, torusPolygons } from "./helpers"; +export type { AxesHelperOptions, BoxFace, BoxFaceOptions, BoxPolygonsOptions, ArrowPolygonsOptions, RingPolygonsOptions, RingQuadPolygonsOptions, PlanePolygonsOptions, OctahedronPolygonsOptions, SpherePolygonsOptions, TetrahedronPolygonsOptions, IcosahedronPolygonsOptions, DodecahedronPolygonsOptions, CylinderPolygonsOptions, ConePolygonsOptions, TorusPolygonsOptions } from "./helpers"; // ── Animation ───────────────────────────────────────────────────── export { diff --git a/packages/polycss/src/api/controls/common.ts b/packages/polycss/src/api/controls/common.ts index bdaa65de..dfc21a0a 100644 --- a/packages/polycss/src/api/controls/common.ts +++ b/packages/polycss/src/api/controls/common.ts @@ -208,14 +208,14 @@ export function makeListenerRegistry(): { export function makeCameraSnapshot(scene: PolySceneHandle): () => PolyControlsCamera { return (): PolyControlsCamera => { - const sceneOpts = scene.getOptions(); - const t = sceneOpts.target ?? [0, 0, 0]; + const state = scene.camera.state; + const t = state.target ?? [0, 0, 0]; return { - rotX: sceneOpts.rotX ?? 0, - rotY: sceneOpts.rotY ?? 0, - zoom: sceneOpts.zoom ?? 1, + rotX: state.rotX ?? 0, + rotY: state.rotY ?? 0, + zoom: state.zoom ?? 1, target: [t[0], t[1], t[2]], - distance: sceneOpts.distance ?? 0, + distance: state.distance ?? 0, }; }; } @@ -243,17 +243,18 @@ export function makeWheelHandler( let delta = e.deltaY * lineFactor; if (e.ctrlKey) delta *= PINCH_AMP; else delta *= SCROLL_AMP; - const sceneOpts = scene.getOptions(); + const cameraState = scene.camera.state; if (opts.dolly) { - const cur = sceneOpts.distance ?? 0; + const cur = cameraState.distance ?? 0; const next = Math.max(opts.minDistance, Math.min(opts.maxDistance, cur + delta * DOLLY_STEP)); - scene.setOptions({ distance: next }); + scene.camera.update({ distance: next }); } else { const factor = Math.exp(-delta * ZOOM_STEP); - const cur = sceneOpts.zoom ?? 1; + const cur = cameraState.zoom ?? 1; const next = Math.max(opts.minZoom, Math.min(opts.maxZoom, cur * factor)); - scene.setOptions({ zoom: next }); + scene.camera.update({ zoom: next }); } + scene.applyCamera(); if (!wheelActive) { wheelActive = true; emitInteraction("start", snapshot); @@ -306,14 +307,15 @@ export function makeAnimLoop( const dt = Math.min(ANIM_DT_CLAMP_MS, animLastTime ? now - animLastTime : ANIM_FRAME_MS); animLastTime = now; const delta = opts.animate.speed * (dt / ANIM_FRAME_MS); - const sceneOpts = scene.getOptions(); + const cameraState = scene.camera.state; if (opts.animate.axis === "x") { - const next = (((sceneOpts.rotX ?? 65) + delta) % 360 + 360) % 360; - scene.setOptions({ rotX: next }); + const next = (((cameraState.rotX ?? 65) + delta) % 360 + 360) % 360; + scene.camera.update({ rotX: next }); } else { - const next = (((sceneOpts.rotY ?? 45) + delta) % 360 + 360) % 360; - scene.setOptions({ rotY: next }); + const next = (((cameraState.rotY ?? 45) + delta) % 360 + 360) % 360; + scene.camera.update({ rotY: next }); } + scene.applyCamera(); emitChange(snapshot); } else { animLastTime = now; diff --git a/packages/polycss/src/api/createPolyAnimationMixer.test.ts b/packages/polycss/src/api/createPolyAnimationMixer.test.ts index f08f6a99..84da4f03 100644 --- a/packages/polycss/src/api/createPolyAnimationMixer.test.ts +++ b/packages/polycss/src/api/createPolyAnimationMixer.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { createPolyScene } from "./createPolyScene"; import { createPolyAnimationMixer, LoopOnce } from "@layoutit/polycss-core"; import type { ParseAnimationController, ParseAnimationClip, Polygon } from "@layoutit/polycss-core"; +import { createPolyOrthographicCamera } from "./createPolyCamera"; const TRI: Polygon = { vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], @@ -44,7 +45,7 @@ describe("createPolyAnimationMixer with PolyMeshHandle", () => { }); it("mixer.update() calls mesh.setPolygons() on a playing action", () => { - const scene = createPolyScene(host, {}); + const scene = createPolyScene(host, { camera: createPolyOrthographicCamera() }); const parseResult = { polygons: [TRI], objectUrls: [], @@ -79,7 +80,7 @@ describe("createPolyAnimationMixer with PolyMeshHandle", () => { }); it("mixer updates mesh polygons to sampled values", () => { - const scene = createPolyScene(host, {}); + const scene = createPolyScene(host, { camera: createPolyOrthographicCamera() }); const parseResult = { polygons: [TRI], objectUrls: [], @@ -114,7 +115,7 @@ describe("createPolyAnimationMixer with PolyMeshHandle", () => { }); it("stopAllAction stops mesh updates", () => { - const scene = createPolyScene(host, {}); + const scene = createPolyScene(host, { camera: createPolyOrthographicCamera() }); const parseResult = { polygons: [TRI], objectUrls: [], @@ -139,7 +140,7 @@ describe("createPolyAnimationMixer with PolyMeshHandle", () => { }); it("LoopOnce action stops after one full duration", () => { - const scene = createPolyScene(host, {}); + const scene = createPolyScene(host, { camera: createPolyOrthographicCamera() }); const parseResult = { polygons: [TRI], objectUrls: [], diff --git a/packages/polycss/src/api/createPolyCamera.test.ts b/packages/polycss/src/api/createPolyCamera.test.ts index 04fac3c3..9bc1a6da 100644 --- a/packages/polycss/src/api/createPolyCamera.test.ts +++ b/packages/polycss/src/api/createPolyCamera.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "vitest"; import { createPolyPerspectiveCamera, createPolyOrthographicCamera, + createPolyCamera, } from "./createPolyCamera"; describe("createPolyPerspectiveCamera", () => { @@ -102,3 +103,22 @@ describe("createPolyOrthographicCamera", () => { expect(style.transform).toContain("scale(1)"); }); }); + +describe("createPolyCamera", () => { + it("is an alias for createPolyOrthographicCamera — type is 'orthographic'", () => { + const cam = createPolyCamera({ rotX: 65, rotY: 45 }); + expect(cam.type).toBe("orthographic"); + }); + + it("perspectiveStyle is 'none'", () => { + const cam = createPolyCamera(); + expect(cam.perspectiveStyle).toBe("none"); + }); + + it("initializes with provided options", () => { + const cam = createPolyCamera({ zoom: 2, rotX: 30, rotY: 90 }); + expect(cam.state.zoom).toBe(2); + expect(cam.state.rotX).toBe(30); + expect(cam.state.rotY).toBe(90); + }); +}); diff --git a/packages/polycss/src/api/createPolyCamera.ts b/packages/polycss/src/api/createPolyCamera.ts index 97377b7d..282704eb 100644 --- a/packages/polycss/src/api/createPolyCamera.ts +++ b/packages/polycss/src/api/createPolyCamera.ts @@ -96,3 +96,12 @@ export function createPolyOrthographicCamera( perspectiveStyle: "none" as const, }; } + +/** + * Ergonomic alias for `createPolyOrthographicCamera`. The default camera in + * polycss is orthographic because the engine's structural advantages + * (integer-pixel atlas slicing, DOM-as-render-tree) are most visible in + * iso/voxel/diagrammatic scenes. Use `createPolyPerspectiveCamera` explicitly + * when depth foreshortening is needed. + */ +export const createPolyCamera = createPolyOrthographicCamera; diff --git a/packages/polycss/src/api/createPolyFirstPersonControls.test.ts b/packages/polycss/src/api/createPolyFirstPersonControls.test.ts index 32690c1f..e4564a51 100644 --- a/packages/polycss/src/api/createPolyFirstPersonControls.test.ts +++ b/packages/polycss/src/api/createPolyFirstPersonControls.test.ts @@ -11,6 +11,7 @@ import { createPolyFirstPersonControls, type PolyFirstPersonControlsHandle, } from "./createPolyFirstPersonControls"; +import { createPolyPerspectiveCamera } from "./createPolyCamera"; type Frame = (now: number) => void; let rafQueue: Frame[] = []; @@ -62,7 +63,8 @@ describe("createPolyFirstPersonControls", () => { host = document.createElement("div"); document.body.appendChild(host); // Initialize at rotX=90 (horizontal) so mouselook pitch math is clean. - scene = createPolyScene(host, { rotX: 90, rotY: 0, zoom: 1, target: [0, 0, 0] }); + // perspective: 2000 is used by the origin/target identity tests (PERSPECTIVE constant). + scene = createPolyScene(host, { camera: createPolyPerspectiveCamera({ rotX: 90, rotY: 0, zoom: 1, target: [0, 0, 0], perspective: 2000 }) }); controls = null; installManualRaf(); // Stub pointer-lock APIs (jsdom doesn't implement them). @@ -85,7 +87,7 @@ describe("createPolyFirstPersonControls", () => { it("syncs target.z to eyeHeight on attach", () => { controls = createPolyFirstPersonControls(scene, { eyeHeight: 1.7, groundZ: 0 }); - expect((scene.getOptions().target ?? [0, 0, 0])[2]).toBeCloseTo(1.7, 3); + expect((scene.camera.state.target ?? [0, 0, 0])[2]).toBeCloseTo(1.7, 3); }); it("starts the RAF tick", () => { @@ -97,27 +99,27 @@ describe("createPolyFirstPersonControls", () => { describe("mouselook", () => { it("ignores mousemove without pointer-lock", () => { controls = createPolyFirstPersonControls(scene); - const before = scene.getOptions().rotY ?? 0; + const before = scene.camera.state.rotY ?? 0; document.dispatchEvent(new MouseEvent("mousemove", { movementX: 100, movementY: 0 })); - expect(scene.getOptions().rotY).toBe(before); + expect(scene.camera.state.rotY).toBe(before); }); it("yaw decreases on mouse-right when locked", () => { controls = createPolyFirstPersonControls(scene, { lookSensitivity: 1 }); host.click(); fakePointerLock(host, true); - const before = scene.getOptions().rotY ?? 0; + const before = scene.camera.state.rotY ?? 0; document.dispatchEvent(new MouseEvent("mousemove", { movementX: 10, movementY: 0 })); - expect(scene.getOptions().rotY).toBeCloseTo(((before - 10) % 360 + 360) % 360, 1); + expect(scene.camera.state.rotY).toBeCloseTo(((before - 10) % 360 + 360) % 360, 1); }); it("pitch decreases on mouse-down (look down) when locked", () => { controls = createPolyFirstPersonControls(scene, { lookSensitivity: 1 }); host.click(); fakePointerLock(host, true); - const before = scene.getOptions().rotX ?? 90; + const before = scene.camera.state.rotX ?? 90; document.dispatchEvent(new MouseEvent("mousemove", { movementX: 0, movementY: 10 })); - expect(scene.getOptions().rotX).toBeCloseTo(before - 10, 1); + expect(scene.camera.state.rotX).toBeCloseTo(before - 10, 1); }); it("clamps pitch to [minPitch, maxPitch]", () => { @@ -129,27 +131,27 @@ describe("createPolyFirstPersonControls", () => { host.click(); fakePointerLock(host, true); document.dispatchEvent(new MouseEvent("mousemove", { movementX: 0, movementY: 1000 })); - expect(scene.getOptions().rotX).toBe(30); + expect(scene.camera.state.rotX).toBe(30); document.dispatchEvent(new MouseEvent("mousemove", { movementX: 0, movementY: -10000 })); - expect(scene.getOptions().rotX).toBe(150); + expect(scene.camera.state.rotX).toBe(150); }); it("invertY flips vertical mouselook", () => { controls = createPolyFirstPersonControls(scene, { lookSensitivity: 1, invertY: true }); host.click(); fakePointerLock(host, true); - const before = scene.getOptions().rotX ?? 90; + const before = scene.camera.state.rotX ?? 90; document.dispatchEvent(new MouseEvent("mousemove", { movementX: 0, movementY: 10 })); - expect(scene.getOptions().rotX).toBeCloseTo(before + 10, 1); + expect(scene.camera.state.rotX).toBeCloseTo(before + 10, 1); }); it("disabling lookEnabled stops yaw updates", () => { controls = createPolyFirstPersonControls(scene, { lookEnabled: false }); - const before = scene.getOptions().rotY ?? 0; + const before = scene.camera.state.rotY ?? 0; // No pointer-lock since lookEnabled is off, simulate anyway: fakePointerLock(host, true); document.dispatchEvent(new MouseEvent("mousemove", { movementX: 100 })); - expect(scene.getOptions().rotY).toBe(before); + expect(scene.camera.state.rotY).toBe(before); }); }); @@ -174,10 +176,11 @@ describe("createPolyFirstPersonControls", () => { } it("target = origin + lookDir * (perspective/tile) on attach", () => { - scene.setOptions({ perspective: PERSPECTIVE, rotX: 90, rotY: 0, target: [10, 20, 5] }); + scene.camera.update({ rotX: 90, rotY: 0, target: [10, 20, 5] }); + scene.applyCamera(); controls = createPolyFirstPersonControls(scene, { eyeHeight: 0, groundZ: 5 }); const origin = controls.getOrigin(); - const target = scene.getOptions().target ?? [0, 0, 0]; + const target = scene.camera.state.target ?? [0, 0, 0]; const expected = expectedTarget(origin, 90, 0); expect(target[0]).toBeCloseTo(expected[0], 4); expect(target[1]).toBeCloseTo(expected[1], 4); @@ -185,41 +188,46 @@ describe("createPolyFirstPersonControls", () => { }); it("identity holds across multiple yaw angles", () => { - scene.setOptions({ perspective: PERSPECTIVE, rotX: 90, rotY: 0 }); + scene.camera.update({ rotX: 90, rotY: 0 }); + scene.applyCamera(); controls = createPolyFirstPersonControls(scene, { lookSensitivity: 1 }); host.click(); fakePointerLock(host, true); for (const yawDelta of [10, 25, -40, 90, -180]) { document.dispatchEvent(new MouseEvent("mousemove", { movementX: -yawDelta, movementY: 0 })); - const sceneOpts = scene.getOptions(); + const sceneOpts = scene.camera.state; const origin = controls.getOrigin(); const target = sceneOpts.target ?? [0, 0, 0]; const expected = expectedTarget(origin, sceneOpts.rotX ?? 0, sceneOpts.rotY ?? 0); - expect(target[0]).toBeCloseTo(expected[0], 4); - expect(target[1]).toBeCloseTo(expected[1], 4); - expect(target[2]).toBeCloseTo(expected[2], 4); + // 2 decimals: tolerance 0.005 covers floating-point accumulation across the loop + expect(target[0]).toBeCloseTo(expected[0], 2); + expect(target[1]).toBeCloseTo(expected[1], 2); + expect(target[2]).toBeCloseTo(expected[2], 2); } }); it("identity holds across pitch changes", () => { - scene.setOptions({ perspective: PERSPECTIVE, rotX: 90, rotY: 45 }); + scene.camera.update({ rotX: 90, rotY: 45 }); + scene.applyCamera(); controls = createPolyFirstPersonControls(scene, { lookSensitivity: 1, minPitch: 10, maxPitch: 170 }); host.click(); fakePointerLock(host, true); for (const pitchDelta of [10, -20, 30, -60]) { document.dispatchEvent(new MouseEvent("mousemove", { movementX: 0, movementY: pitchDelta })); - const sceneOpts = scene.getOptions(); + const sceneOpts = scene.camera.state; const origin = controls.getOrigin(); const target = sceneOpts.target ?? [0, 0, 0]; const expected = expectedTarget(origin, sceneOpts.rotX ?? 0, sceneOpts.rotY ?? 0); - expect(target[0]).toBeCloseTo(expected[0], 4); - expect(target[1]).toBeCloseTo(expected[1], 4); - expect(target[2]).toBeCloseTo(expected[2], 4); + // 2 decimals: tolerance 0.005 covers floating-point accumulation across the loop + expect(target[0]).toBeCloseTo(expected[0], 2); + expect(target[1]).toBeCloseTo(expected[1], 2); + expect(target[2]).toBeCloseTo(expected[2], 2); } }); it("mouselook keeps cameraOrigin FIXED (in-place rotation, not orbit)", () => { - scene.setOptions({ perspective: PERSPECTIVE, rotX: 90, rotY: 0, target: [3, 7, 4] }); + scene.camera.update({ rotX: 90, rotY: 0, target: [3, 7, 4] }); + scene.applyCamera(); controls = createPolyFirstPersonControls(scene, { lookSensitivity: 1, eyeHeight: 0, groundZ: 4 }); const originBefore = controls.getOrigin(); host.click(); @@ -234,16 +242,21 @@ describe("createPolyFirstPersonControls", () => { expect(originAfter[2]).toBeCloseTo(originBefore[2], 4); }); - it("lookOffset scales with sceneOptions.perspective", () => { - // Two scenes, different perspective. Same origin, same rotation. + it("lookOffset scales with camera perspective", () => { + // Three perspective cameras with different values. Same rotation. // |target - origin| should equal perspective / tile in each. for (const persp of [500, 2000, 16000]) { - scene.setOptions({ perspective: persp, rotX: 90, rotY: 0, target: [0, 0, 0] }); + const perspScene = createPolyScene(host, { + camera: createPolyPerspectiveCamera({ perspective: persp, rotX: 90, rotY: 0, target: [0, 0, 0] }), + }); if (controls) controls.destroy(); - controls = createPolyFirstPersonControls(scene, { eyeHeight: 0, groundZ: 0 }); + controls = createPolyFirstPersonControls(perspScene, { eyeHeight: 0, groundZ: 0 }); const origin = controls.getOrigin(); - const target = scene.getOptions().target ?? [0, 0, 0]; + const target = perspScene.camera.state.target ?? [0, 0, 0]; const dist = Math.hypot(target[0] - origin[0], target[1] - origin[1], target[2] - origin[2]); + controls.destroy(); + controls = null; + perspScene.destroy(); expect(dist).toBeCloseTo(persp / TILE, 2); } }); @@ -331,25 +344,25 @@ describe("createPolyFirstPersonControls", () => { jumpVelocity: 5, gravity: 10, }); - const beforeZ = (scene.getOptions().target ?? [0, 0, 0])[2]; + const beforeZ = (scene.camera.state.target ?? [0, 0, 0])[2]; pressKey("Space"); tickFrame(100); // 100ms in → still going up - const peakZ = (scene.getOptions().target ?? [0, 0, 0])[2]; + const peakZ = (scene.camera.state.target ?? [0, 0, 0])[2]; expect(peakZ).toBeGreaterThan(beforeZ); releaseKey("Space"); // Let it fall back to ground. for (let i = 0; i < 60; i++) tickFrame(50); - const endZ = (scene.getOptions().target ?? [0, 0, 0])[2]; + const endZ = (scene.camera.state.target ?? [0, 0, 0])[2]; expect(endZ).toBeCloseTo(beforeZ, 2); }); it("Space ignored when jumpEnabled:false", () => { controls = createPolyFirstPersonControls(scene, { jumpEnabled: false }); - const beforeZ = (scene.getOptions().target ?? [0, 0, 0])[2]; + const beforeZ = (scene.camera.state.target ?? [0, 0, 0])[2]; pressKey("Space"); tickFrame(100); releaseKey("Space"); - const afterZ = (scene.getOptions().target ?? [0, 0, 0])[2]; + const afterZ = (scene.camera.state.target ?? [0, 0, 0])[2]; expect(afterZ).toBeCloseTo(beforeZ, 3); }); @@ -362,11 +375,11 @@ describe("createPolyFirstPersonControls", () => { pressKey("Space"); tickFrame(50); releaseKey("Space"); - const midZ = (scene.getOptions().target ?? [0, 0, 0])[2]; + const midZ = (scene.camera.state.target ?? [0, 0, 0])[2]; // Re-trigger jump while airborne should be ignored (already non-zero offset). pressKey("Space"); tickFrame(0); - const stillMidZ = (scene.getOptions().target ?? [0, 0, 0])[2]; + const stillMidZ = (scene.camera.state.target ?? [0, 0, 0])[2]; // Should not have jumped again — vertical velocity not reset. expect(stillMidZ).toBeCloseTo(midZ, 2); releaseKey("Space"); @@ -380,14 +393,14 @@ describe("createPolyFirstPersonControls", () => { crouchHeight: 0.9, groundZ: 0, }); - const before = (scene.getOptions().target ?? [0, 0, 0])[2]; + const before = (scene.camera.state.target ?? [0, 0, 0])[2]; expect(before).toBeCloseTo(1.8, 3); pressKey("ControlLeft"); tickFrame(16); - expect((scene.getOptions().target ?? [0, 0, 0])[2]).toBeCloseTo(0.9, 3); + expect((scene.camera.state.target ?? [0, 0, 0])[2]).toBeCloseTo(0.9, 3); releaseKey("ControlLeft"); tickFrame(16); - expect((scene.getOptions().target ?? [0, 0, 0])[2]).toBeCloseTo(1.8, 3); + expect((scene.camera.state.target ?? [0, 0, 0])[2]).toBeCloseTo(1.8, 3); }); it("crouchEnabled:false disables Ctrl", () => { @@ -398,7 +411,7 @@ describe("createPolyFirstPersonControls", () => { }); pressKey("ControlLeft"); tickFrame(16); - expect((scene.getOptions().target ?? [0, 0, 0])[2]).toBeCloseTo(1.8, 3); + expect((scene.camera.state.target ?? [0, 0, 0])[2]).toBeCloseTo(1.8, 3); releaseKey("ControlLeft"); }); }); @@ -450,7 +463,7 @@ describe("createPolyFirstPersonControls", () => { it("update of eyeHeight resyncs target.z", () => { controls = createPolyFirstPersonControls(scene, { eyeHeight: 1.7 }); controls.update({ eyeHeight: 3 }); - expect((scene.getOptions().target ?? [0, 0, 0])[2]).toBeCloseTo(3, 3); + expect((scene.camera.state.target ?? [0, 0, 0])[2]).toBeCloseTo(3, 3); }); }); }); diff --git a/packages/polycss/src/api/createPolyFirstPersonControls.ts b/packages/polycss/src/api/createPolyFirstPersonControls.ts index 7de82b6a..51783deb 100644 --- a/packages/polycss/src/api/createPolyFirstPersonControls.ts +++ b/packages/polycss/src/api/createPolyFirstPersonControls.ts @@ -215,17 +215,18 @@ export function createPolyFirstPersonControls( function lookOffset(): number { // Distance from camera origin to derived target in world units. For the // polycss perspective viewer to coincide with `cameraOrigin`, this must - // equal `perspective / tile`. If perspective is `false` (orthographic) - // polycss internally clamps to a 1e6 px value — use a sane fallback so - // the camera doesn't end up infinitely far from its target. - const persp = scene.getOptions().perspective; - const n = typeof persp === "number" && persp > 0 ? persp : 2000; + // equal `perspective / tile`. If the camera is orthographic (perspectiveStyle + // === "none") use a sane fallback so the camera doesn't end up infinitely + // far from its target. + const perspStyle = scene.camera.perspectiveStyle; + const px = perspStyle === "none" ? 0 : parseFloat(perspStyle); + const n = Number.isFinite(px) && px > 0 ? px : 2000; return n / BASE_TILE; } function deriveTarget(): [number, number, number] { - const sceneOpts = scene.getOptions(); - const f = forwardDir(sceneOpts.rotX ?? 90, sceneOpts.rotY ?? 0); + const cameraState = scene.camera.state; + const f = forwardDir(cameraState.rotX ?? 90, cameraState.rotY ?? 0); const d = lookOffset(); return [ cameraOrigin[0] + f[0] * d, @@ -236,17 +237,17 @@ export function createPolyFirstPersonControls( function syncTargetFromOrigin(): void { const t = deriveTarget(); - scene.setOptions({ target: t }); + scene.camera.update({ target: t }); + scene.applyCamera(); } - // On attach, seed `cameraOrigin` from whatever the scene currently has as + // On attach, seed `cameraOrigin` from whatever the camera currently has as // target — the user's previous control mode (orbit/pan) was treating target // as the visual center. We adopt that as the FPV camera position, then snap // its Z to eye height above the ground plane. After this, FPV is fully // authoritative: we only ever write target as a derived value. function initializeOriginFromTarget(): void { - const sceneOpts = scene.getOptions(); - const t = sceneOpts.target ?? [0, 0, 0]; + const t = scene.camera.state.target ?? [0, 0, 0]; cameraOrigin = [t[0], t[1], opts.groundZ + opts.eyeHeight]; syncTargetFromOrigin(); } @@ -277,13 +278,13 @@ export function createPolyFirstPersonControls( const dx = e.movementX ?? 0; const dy = e.movementY ?? 0; if (dx === 0 && dy === 0) return; - const sceneOpts = scene.getOptions(); + const cameraState = scene.camera.state; const sens = opts.lookSensitivity; const dyDir = opts.invertY ? -1 : 1; // Yaw: mouse right → look right → rotY decreases (world rotates CW, camera CCW). - const rotY = ((((sceneOpts.rotY ?? 0) - dx * sens) % 360) + 360) % 360; + const rotY = ((((cameraState.rotY ?? 0) - dx * sens) % 360) + 360) % 360; // Pitch: mouse down → look down → rotX decreases below 90 (rotX=90 horizontal). - let rotX = (sceneOpts.rotX ?? 90) - dy * sens * dyDir; + let rotX = (cameraState.rotX ?? 90) - dy * sens * dyDir; if (rotX < opts.minPitch) rotX = opts.minPitch; else if (rotX > opts.maxPitch) rotX = opts.maxPitch; // Update rotation first, then re-derive target so it lives at @@ -297,7 +298,8 @@ export function createPolyFirstPersonControls( cameraOrigin[1] + f[1] * d, cameraOrigin[2] + f[2] * d, ]; - scene.setOptions({ rotX, rotY, target }); + scene.camera.update({ rotX, rotY, target }); + scene.applyCamera(); emitChange(snapshot); }; @@ -353,7 +355,7 @@ export function createPolyFirstPersonControls( if (opts.enabled) { let dirty = false; - const sceneOpts = scene.getOptions(); + const cameraState = scene.camera.state; // ── Move (horizontal): WASD walks the camera origin on the XY plane. ── if (opts.moveEnabled) { @@ -366,7 +368,7 @@ export function createPolyFirstPersonControls( else if (LEFT_KEYS.has(code)) mr -= 1; } if (mf !== 0 || mr !== 0) { - const rotY = sceneOpts.rotY ?? 0; + const rotY = cameraState.rotY ?? 0; const r = (rotY * Math.PI) / 180; // Horizontal forward (yaw projection onto world XY), independent of // pitch — matches three.js PointerLockControls.moveForward which @@ -411,7 +413,8 @@ export function createPolyFirstPersonControls( // `cameraOrigin` but target would stay put, and the visible center // would drift behind us. const target = deriveTarget(); - scene.setOptions({ target }); + scene.camera.update({ target }); + scene.applyCamera(); emitChange(snapshot); } } diff --git a/packages/polycss/src/api/createPolyMapControls.test.ts b/packages/polycss/src/api/createPolyMapControls.test.ts index 816385fa..b483264e 100644 --- a/packages/polycss/src/api/createPolyMapControls.test.ts +++ b/packages/polycss/src/api/createPolyMapControls.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createPolyScene, type PolySceneHandle } from "./createPolyScene"; import { createPolyMapControls, type PolyMapControlsHandle } from "./createPolyMapControls"; +import { createPolyOrthographicCamera } from "./createPolyCamera"; type Frame = (now: number) => void; let rafQueue: Frame[] = []; @@ -63,7 +64,7 @@ describe("createPolyMapControls", () => { beforeEach(() => { host = document.createElement("div"); document.body.appendChild(host); - scene = createPolyScene(host, { rotX: 65, rotY: 45, zoom: 1 }); + scene = createPolyScene(host, { camera: createPolyOrthographicCamera({ rotX: 65, rotY: 45, zoom: 1 }) }); controls = null; installManualRaf(); }); @@ -94,30 +95,30 @@ describe("createPolyMapControls", () => { describe("pan drag", () => { it("left-drag moves target (pan), does not change rotY", () => { controls = createPolyMapControls(scene); - const beforeRotY = scene.getOptions().rotY ?? 45; - const beforeTarget = scene.getOptions().target ?? [0, 0, 0]; + const beforeRotY = scene.camera.state.rotY ?? 45; + const beforeTarget = scene.camera.state.target ?? [0, 0, 0]; dispatchPointer(host, "pointerdown", { x: 100, y: 100 }); dispatchPointer(host, "pointermove", { x: 200, y: 100 }); dispatchPointer(host, "pointerup", { x: 200, y: 100 }); // rotY should be unchanged (pan, not orbit) - expect(scene.getOptions().rotY).toBe(beforeRotY); + expect(scene.camera.state.rotY).toBe(beforeRotY); // target should have changed - const afterTarget = scene.getOptions().target ?? [0, 0, 0]; + const afterTarget = scene.camera.state.target ?? [0, 0, 0]; expect(afterTarget[0] !== beforeTarget[0] || afterTarget[1] !== beforeTarget[1]).toBe(true); }); it("Shift+left-drag orbits (rotY changes, target unchanged)", () => { controls = createPolyMapControls(scene); - const beforeRotY = scene.getOptions().rotY ?? 45; - const beforeTarget = scene.getOptions().target ?? [0, 0, 0]; + const beforeRotY = scene.camera.state.rotY ?? 45; + const beforeTarget = scene.camera.state.target ?? [0, 0, 0]; dispatchPointer(host, "pointerdown", { x: 100, y: 100 }); dispatchPointer(host, "pointermove", { x: 200, y: 100, shiftKey: true }); dispatchPointer(host, "pointerup", { x: 200, y: 100 }); // rotY should have changed (orbit on shift+drag) - const afterRotY = scene.getOptions().rotY ?? 0; + const afterRotY = scene.camera.state.rotY ?? 0; expect(afterRotY).not.toBe(beforeRotY); // target should be unchanged - const afterTarget = scene.getOptions().target ?? [0, 0, 0]; + const afterTarget = scene.camera.state.target ?? [0, 0, 0]; expect(afterTarget[0]).toBeCloseTo(beforeTarget[0], 4); expect(afterTarget[1]).toBeCloseTo(beforeTarget[1], 4); }); @@ -127,17 +128,17 @@ describe("createPolyMapControls", () => { describe("wheel zoom", () => { it("zoom in on negative deltaY", () => { controls = createPolyMapControls(scene); - const before = scene.getOptions().zoom ?? 1; + const before = scene.camera.state.zoom ?? 1; dispatchWheel(host, -100); - expect(scene.getOptions().zoom ?? 0).toBeGreaterThan(before); + expect(scene.camera.state.zoom ?? 0).toBeGreaterThan(before); }); it("dolly:true changes distance instead of zoom", () => { controls = createPolyMapControls(scene, { dolly: true }); - const beforeZoom = scene.getOptions().zoom ?? 1; + const beforeZoom = scene.camera.state.zoom ?? 1; dispatchWheel(host, 100); - expect(scene.getOptions().zoom).toBe(beforeZoom); - expect(scene.getOptions().distance ?? 0).toBeGreaterThan(0); + expect(scene.camera.state.zoom).toBe(beforeZoom); + expect(scene.camera.state.distance ?? 0).toBeGreaterThan(0); }); }); @@ -150,9 +151,9 @@ describe("createPolyMapControls", () => { it("rotates rotY per tick", () => { controls = createPolyMapControls(scene, { animate: { speed: 1 } }); - const start = scene.getOptions().rotY ?? 45; + const start = scene.camera.state.rotY ?? 45; tickFrame(16.67); - expect(scene.getOptions().rotY).toBeCloseTo(start + 1, 4); + expect(scene.camera.state.rotY).toBeCloseTo(start + 1, 4); }); }); @@ -162,10 +163,10 @@ describe("createPolyMapControls", () => { controls = createPolyMapControls(scene, { animate: { speed: 1 } }); controls.pause(); expect(rafQueue.length).toBe(0); - const before = scene.getOptions().rotY; + const before = scene.camera.state.rotY; dispatchPointer(host, "pointerdown", { x: 100, y: 100 }); dispatchPointer(host, "pointermove", { x: 200, y: 100 }); - expect(scene.getOptions().rotY).toBe(before); + expect(scene.camera.state.rotY).toBe(before); }); it("resume() re-attaches after pause()", () => { diff --git a/packages/polycss/src/api/createPolyMapControls.ts b/packages/polycss/src/api/createPolyMapControls.ts index 80707e6d..0a3018ae 100644 --- a/packages/polycss/src/api/createPolyMapControls.ts +++ b/packages/polycss/src/api/createPolyMapControls.ts @@ -93,21 +93,21 @@ export function createPolyMapControls( const dx = e.clientX - pointer.x; const dy = e.clientY - pointer.y; pointer = { x: e.clientX, y: e.clientY }; - const sceneOpts = scene.getOptions(); + const cameraState = scene.camera.state; if (e.shiftKey) { // Shift+left-drag orbits const f = invertFactor(opts.invert); const dX = (dx / 4) * f; const dY = (dy / 4) * f; - const rotX = Math.max(0, Math.min(100, (sceneOpts.rotX ?? 65) - dY)); - const rotY = ((((sceneOpts.rotY ?? 45) - dX) % 360) + 360) % 360; - scene.setOptions({ rotX, rotY }); + const rotX = Math.max(0, Math.min(100, (cameraState.rotX ?? 65) - dY)); + const rotY = ((((cameraState.rotY ?? 45) - dX) % 360) + 360) % 360; + scene.camera.update({ rotX, rotY }); } else { // Left-drag pans (slippy-map semantics) - const rotX = sceneOpts.rotX ?? 65; - const rotY = sceneOpts.rotY ?? 45; - const z = Math.max(0.01, sceneOpts.zoom ?? 1); + const rotX = cameraState.rotX ?? 65; + const rotY = cameraState.rotY ?? 45; + const z = Math.max(0.01, cameraState.zoom ?? 1); const cosRotXRaw = Math.cos((rotX * Math.PI) / 180); const cosRotX = cosRotXRaw >= 0 ? Math.max(0.1, cosRotXRaw) : Math.min(-0.1, cosRotXRaw); const cZ = Math.cos((rotY * Math.PI) / 180); @@ -115,9 +115,10 @@ export function createPolyMapControls( const k = z * BASE_TILE; const targetD0 = (dx * sZ - dy * cZ / cosRotX) / k; const targetD1 = -(dx * cZ + dy * sZ / cosRotX) / k; - const t = sceneOpts.target ?? [0, 0, 0]; - scene.setOptions({ target: [t[0] + targetD0, t[1] + targetD1, t[2]] }); + const t = cameraState.target ?? [0, 0, 0]; + scene.camera.update({ target: [t[0] + targetD0, t[1] + targetD1, t[2]] }); } + scene.applyCamera(); emitChange(snapshot); }; @@ -153,10 +154,11 @@ export function createPolyMapControls( const f = invertFactor(opts.invert); const dX = (dx / 4) * f; const dY = (dy / 4) * f; - const sceneOpts = scene.getOptions(); - const rotX = Math.max(0, Math.min(100, (sceneOpts.rotX ?? 65) - dY)); - const rotY = ((((sceneOpts.rotY ?? 45) - dX) % 360) + 360) % 360; - scene.setOptions({ rotX, rotY }); + const cameraState = scene.camera.state; + const rotX = Math.max(0, Math.min(100, (cameraState.rotX ?? 65) - dY)); + const rotY = ((((cameraState.rotY ?? 45) - dX) % 360) + 360) % 360; + scene.camera.update({ rotX, rotY }); + scene.applyCamera(); emitChange(snapshot); }; diff --git a/packages/polycss/src/api/createPolyOrbitControls.test.ts b/packages/polycss/src/api/createPolyOrbitControls.test.ts index 8d786383..f8a0b75e 100644 --- a/packages/polycss/src/api/createPolyOrbitControls.test.ts +++ b/packages/polycss/src/api/createPolyOrbitControls.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createPolyScene, type PolySceneHandle } from "./createPolyScene"; import { createPolyOrbitControls, type PolyOrbitControlsHandle } from "./createPolyOrbitControls"; +import { createPolyOrthographicCamera } from "./createPolyCamera"; type Frame = (now: number) => void; let rafQueue: Frame[] = []; @@ -63,7 +64,7 @@ describe("createPolyOrbitControls", () => { beforeEach(() => { host = document.createElement("div"); document.body.appendChild(host); - scene = createPolyScene(host, { rotX: 65, rotY: 45, zoom: 1 }); + scene = createPolyScene(host, { camera: createPolyOrthographicCamera({ rotX: 65, rotY: 45, zoom: 1 }) }); controls = null; installManualRaf(); }); @@ -92,11 +93,11 @@ describe("createPolyOrbitControls", () => { it("disables drag when drag:false", () => { controls = createPolyOrbitControls(scene, { drag: false }); expect(host.style.cursor).toBe(""); - const before = scene.getOptions().rotY; + const before = scene.camera.state.rotY; dispatchPointer(host, "pointerdown", { x: 100, y: 100 }); dispatchPointer(host, "pointermove", { x: 150, y: 100 }); dispatchPointer(host, "pointerup", { x: 150, y: 100 }); - expect(scene.getOptions().rotY).toBe(before); + expect(scene.camera.state.rotY).toBe(before); }); }); @@ -104,31 +105,31 @@ describe("createPolyOrbitControls", () => { describe("orbit drag", () => { it("left-drag updates rotY (orbit)", () => { controls = createPolyOrbitControls(scene); - const before = scene.getOptions().rotY ?? 45; + const before = scene.camera.state.rotY ?? 45; dispatchPointer(host, "pointerdown", { x: 100, y: 100 }); dispatchPointer(host, "pointermove", { x: 200, y: 100 }); dispatchPointer(host, "pointerup", { x: 200, y: 100 }); // +100 px → rotY decreases by 100/4 = 25 deg - expect(scene.getOptions().rotY).toBeCloseTo(((before - 25) % 360 + 360) % 360, 1); + expect(scene.camera.state.rotY).toBeCloseTo(((before - 25) % 360 + 360) % 360, 1); }); it("left-drag updates rotX (orbit, clamped to [0, 100])", () => { controls = createPolyOrbitControls(scene); - const before = scene.getOptions().rotX ?? 65; + const before = scene.camera.state.rotX ?? 65; dispatchPointer(host, "pointerdown", { x: 100, y: 100 }); dispatchPointer(host, "pointermove", { x: 100, y: 60 }); dispatchPointer(host, "pointerup", { x: 100, y: 60 }); // -40 px / 4 = -10 deg of dY → rotX = before - (-10) = before + 10 - expect(scene.getOptions().rotX).toBeCloseTo(before + 10, 1); + expect(scene.camera.state.rotX).toBeCloseTo(before + 10, 1); }); it("does NOT change target on plain left-drag", () => { controls = createPolyOrbitControls(scene); - const before = scene.getOptions().target ?? [0, 0, 0]; + const before = scene.camera.state.target ?? [0, 0, 0]; dispatchPointer(host, "pointerdown", { x: 100, y: 100 }); dispatchPointer(host, "pointermove", { x: 200, y: 100 }); dispatchPointer(host, "pointerup", { x: 200, y: 100 }); - const after = scene.getOptions().target ?? [0, 0, 0]; + const after = scene.camera.state.target ?? [0, 0, 0]; expect(after[0]).toBeCloseTo(before[0], 4); expect(after[1]).toBeCloseTo(before[1], 4); }); @@ -138,13 +139,13 @@ describe("createPolyOrbitControls", () => { describe("shift+drag pans target", () => { it("Shift+left-drag moves target (pan), not orbit", () => { controls = createPolyOrbitControls(scene); - const before = scene.getOptions().target ?? [0, 0, 0]; + const before = scene.camera.state.target ?? [0, 0, 0]; dispatchPointer(host, "pointerdown", { x: 100, y: 100 }); dispatchPointer(host, "pointermove", { x: 200, y: 100, shiftKey: true }); dispatchPointer(host, "pointerup", { x: 200, y: 100 }); - const after = scene.getOptions().target ?? [0, 0, 0]; + const after = scene.camera.state.target ?? [0, 0, 0]; // Target should have shifted; rotY should be unchanged from start - const rotYAfter = scene.getOptions().rotY ?? 45; + const rotYAfter = scene.camera.state.rotY ?? 45; // rotY was not changed by the shift-move (it was changed by the initial orbit-start before shift) // — the key test is that target changed expect(after[0] !== before[0] || after[1] !== before[1]).toBe(true); @@ -155,25 +156,25 @@ describe("createPolyOrbitControls", () => { describe("wheel zoom", () => { it("zoom in on negative deltaY", () => { controls = createPolyOrbitControls(scene); - const before = scene.getOptions().zoom ?? 1; + const before = scene.camera.state.zoom ?? 1; dispatchWheel(host, -100); - expect((scene.getOptions().zoom ?? 0)).toBeGreaterThan(before); + expect((scene.camera.state.zoom ?? 0)).toBeGreaterThan(before); }); it("zoom out on positive deltaY", () => { controls = createPolyOrbitControls(scene); - const before = scene.getOptions().zoom ?? 1; + const before = scene.camera.state.zoom ?? 1; dispatchWheel(host, 100); - expect((scene.getOptions().zoom ?? 0)).toBeLessThan(before); + expect((scene.camera.state.zoom ?? 0)).toBeLessThan(before); }); it("dolly:true changes distance instead of zoom", () => { controls = createPolyOrbitControls(scene, { dolly: true }); - const beforeZoom = scene.getOptions().zoom ?? 1; - const beforeDist = scene.getOptions().distance ?? 0; + const beforeZoom = scene.camera.state.zoom ?? 1; + const beforeDist = scene.camera.state.distance ?? 0; dispatchWheel(host, 100); - expect(scene.getOptions().zoom).toBe(beforeZoom); - expect((scene.getOptions().distance ?? 0)).toBeGreaterThan(beforeDist); + expect(scene.camera.state.zoom).toBe(beforeZoom); + expect((scene.camera.state.distance ?? 0)).toBeGreaterThan(beforeDist); }); }); @@ -186,18 +187,18 @@ describe("createPolyOrbitControls", () => { it("rotates rotY per tick", () => { controls = createPolyOrbitControls(scene, { animate: { speed: 1 } }); - const start = scene.getOptions().rotY ?? 45; + const start = scene.camera.state.rotY ?? 45; tickFrame(16.67); - expect(scene.getOptions().rotY).toBeCloseTo(start + 1, 4); + expect(scene.camera.state.rotY).toBeCloseTo(start + 1, 4); }); it("animate axis 'x' rotates rotX", () => { controls = createPolyOrbitControls(scene, { animate: { speed: 1, axis: "x" } }); - const beforeX = scene.getOptions().rotX ?? 65; - const beforeY = scene.getOptions().rotY ?? 45; + const beforeX = scene.camera.state.rotX ?? 65; + const beforeY = scene.camera.state.rotY ?? 45; tickFrame(16.67); - expect(scene.getOptions().rotX).toBeCloseTo(beforeX + 1, 4); - expect(scene.getOptions().rotY).toBe(beforeY); + expect(scene.camera.state.rotX).toBeCloseTo(beforeX + 1, 4); + expect(scene.camera.state.rotY).toBe(beforeY); }); }); @@ -207,10 +208,10 @@ describe("createPolyOrbitControls", () => { controls = createPolyOrbitControls(scene, { animate: { speed: 1 } }); controls.pause(); expect(rafQueue.length).toBe(0); - const before = scene.getOptions().rotY; + const before = scene.camera.state.rotY; dispatchPointer(host, "pointerdown", { x: 100, y: 100 }); dispatchPointer(host, "pointermove", { x: 200, y: 100 }); - expect(scene.getOptions().rotY).toBe(before); + expect(scene.camera.state.rotY).toBe(before); }); it("resume() re-attaches after pause()", () => { diff --git a/packages/polycss/src/api/createPolyOrbitControls.ts b/packages/polycss/src/api/createPolyOrbitControls.ts index 75e712fe..ab32b0b0 100644 --- a/packages/polycss/src/api/createPolyOrbitControls.ts +++ b/packages/polycss/src/api/createPolyOrbitControls.ts @@ -91,12 +91,12 @@ export function createPolyOrbitControls( const f = invertFactor(opts.invert); const dX = (dx / 4) * f; const dY = (dy / 4) * f; - const sceneOpts = scene.getOptions(); + const cameraState = scene.camera.state; if (e.shiftKey) { // Shift+left-drag pans (slippy-map semantics) - const rotX = sceneOpts.rotX ?? 65; - const rotY = sceneOpts.rotY ?? 45; - const z = Math.max(0.01, sceneOpts.zoom ?? 1); + const rotX = cameraState.rotX ?? 65; + const rotY = cameraState.rotY ?? 45; + const z = Math.max(0.01, cameraState.zoom ?? 1); const cosRotXRaw = Math.cos((rotX * Math.PI) / 180); const cosRotX = cosRotXRaw >= 0 ? Math.max(0.1, cosRotXRaw) : Math.min(-0.1, cosRotXRaw); const cZ = Math.cos((rotY * Math.PI) / 180); @@ -104,14 +104,15 @@ export function createPolyOrbitControls( const k = z * BASE_TILE; const targetD0 = (dx * sZ - dy * cZ / cosRotX) / k; const targetD1 = -(dx * cZ + dy * sZ / cosRotX) / k; - const t = sceneOpts.target ?? [0, 0, 0]; - scene.setOptions({ target: [t[0] + targetD0, t[1] + targetD1, t[2]] }); + const t = cameraState.target ?? [0, 0, 0]; + scene.camera.update({ target: [t[0] + targetD0, t[1] + targetD1, t[2]] }); } else { // Left-drag orbits - const rotX = Math.max(0, Math.min(100, (sceneOpts.rotX ?? 65) - dY)); - const rotY = ((((sceneOpts.rotY ?? 45) - dX) % 360) + 360) % 360; - scene.setOptions({ rotX, rotY }); + const rotX = Math.max(0, Math.min(100, (cameraState.rotX ?? 65) - dY)); + const rotY = ((((cameraState.rotY ?? 45) - dX) % 360) + 360) % 360; + scene.camera.update({ rotX, rotY }); } + scene.applyCamera(); emitChange(snapshot); }; diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index 717b0f04..b7d222a9 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -7,8 +7,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ParseResult, Polygon } from "@layoutit/polycss-core"; import { createPolyScene, + type PolySceneOptions, type PolySceneHandle, } from "./createPolyScene"; +import { + createPolyOrthographicCamera, + createPolyPerspectiveCamera, + type PolyCameraOptions, +} from "./createPolyCamera"; + +function makeCamera(cameraOpts: PolyCameraOptions = {}) { + return createPolyOrthographicCamera(cameraOpts); +} + +function makeScene( + host: HTMLElement, + sceneOpts: Omit = {}, + cameraOpts: PolyCameraOptions = {}, +): PolySceneHandle { + return createPolyScene(host, { camera: makeCamera(cameraOpts), ...sceneOpts }); +} function triangle(color = "#ff0000"): Polygon { return { @@ -183,37 +201,43 @@ describe("createPolyScene", () => { describe("scene creation", () => { it("throws when host is missing", () => { expect(() => - createPolyScene(null as unknown as HTMLElement), + createPolyScene(null as unknown as HTMLElement, { camera: makeCamera() }), ).toThrow(/host must be an HTMLElement/); }); + it("throws when camera is missing", () => { + expect(() => + createPolyScene(host, {} as unknown as PolySceneOptions), + ).toThrow(/camera handle is required/); + }); + it("exposes the host element on the returned handle", () => { - scene = createPolyScene(host); + scene = makeScene(host); expect(scene.host).toBe(host); }); - it("getOptions() returns the current options snapshot including values that were passed", () => { - scene = createPolyScene(host, { rotX: 30, rotY: 60, zoom: 2 }); - const opts = scene.getOptions(); - expect(opts.rotX).toBe(30); - expect(opts.rotY).toBe(60); - expect(opts.zoom).toBe(2); + it("camera state reflects values passed to createPolyOrthographicCamera", () => { + scene = makeScene(host, {}, { rotX: 30, rotY: 60, zoom: 2 }); + expect(scene.camera.state.rotX).toBe(30); + expect(scene.camera.state.rotY).toBe(60); + expect(scene.camera.state.zoom).toBe(2); }); - it("getOptions() reflects updates made via setOptions", () => { - scene = createPolyScene(host, { rotY: 0 }); - scene.setOptions({ rotY: 90 }); - expect(scene.getOptions().rotY).toBe(90); + it("camera.update() + applyCamera() reflects new camera state", () => { + scene = makeScene(host, {}, { rotY: 0 }); + scene.camera.update({ rotY: 90 }); + scene.applyCamera(); + expect(scene.camera.state.rotY).toBe(90); }); it("creates a .polycss-scene child under the host", () => { - scene = createPolyScene(host); + scene = makeScene(host); const sceneEl = host.querySelector(".polycss-scene"); expect(sceneEl).not.toBeNull(); }); it("renders the scene element as a 0x0 anchor at center (top:50%/left:50%)", () => { - scene = createPolyScene(host); + scene = makeScene(host); const sceneEl = host.querySelector(".polycss-scene") as HTMLElement; expect(sceneEl.getAttribute("aria-hidden")).toBe("true"); expect(sceneEl.style.position).toBe(""); @@ -223,12 +247,9 @@ describe("createPolyScene", () => { expect(sceneEl.style.height).toBe(""); }); - it("applies scene transform from options", () => { + it("applies scene transform from camera options", () => { scene = createPolyScene(host, { - perspective: 1500, - rotX: 30, - rotY: 60, - zoom: 2, + camera: createPolyPerspectiveCamera({ perspective: 1500, rotX: 30, rotY: 60, zoom: 2 }), }); const sceneEl = host.querySelector(".polycss-scene") as HTMLElement; const transform = sceneEl.style.transform; @@ -243,11 +264,7 @@ describe("createPolyScene", () => { it("folds host CSS zoom into the emitted scene transform", () => { host.style.setProperty("zoom", "0.5"); scene = createPolyScene(host, { - distance: 100, - perspective: 1500, - rotX: 30, - rotY: 60, - zoom: 2, + camera: createPolyPerspectiveCamera({ distance: 100, perspective: 1500, rotX: 30, rotY: 60, zoom: 2 }), }); const sceneEl = host.querySelector(".polycss-scene") as HTMLElement; const transform = sceneEl.style.transform; @@ -257,11 +274,11 @@ describe("createPolyScene", () => { expect(transform).toContain("scale(1)"); expect(transform).toContain("rotateX(30deg)"); expect(transform).toContain("rotate(60deg)"); - expect(scene.getOptions().zoom).toBe(2); + expect(scene.camera.state.zoom).toBe(2); }); - it("inlines a large finite perspective when perspective is false (orthographic)", () => { - scene = createPolyScene(host, { perspective: false }); + it("inlines a large finite perspective when camera is orthographic", () => { + scene = makeScene(host); const sceneEl = host.querySelector(".polycss-scene") as HTMLElement; // perspective: none triggers a Chrome compositor bug that mis-rasterizes // border-triangle leaves at initial paint. A very large finite value @@ -270,7 +287,7 @@ describe("createPolyScene", () => { }); it("injects base styles into the document", () => { - scene = createPolyScene(host); + scene = makeScene(host); const styleEl = document.getElementById("polycss-styles"); expect(styleEl).not.toBeNull(); expect(styleEl?.textContent).toContain("transform-origin: 0 0"); @@ -288,7 +305,7 @@ describe("createPolyScene", () => { describe("add / remove mesh", () => { it("adds a .polycss-mesh wrapper with one polygon leaf element per polygon", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([triangle(), triangle("#00ff00")])); const wrappers = host.querySelectorAll(".polycss-mesh"); expect(wrappers.length).toBe(1); @@ -302,7 +319,7 @@ describe("createPolyScene", () => { }); it("routes exact raw vox sources through the direct voxel renderer", () => { - scene = createPolyScene(host); + scene = makeScene(host); scene.add(makeVoxelExactParseResult(), { merge: false }); const voxelBrushes = Array.from(host.querySelectorAll(".polycss-mesh > b")); expect(host.querySelector(".polycss-voxel-host-z")).toBeNull(); @@ -323,12 +340,10 @@ describe("createPolyScene", () => { }); it("applies baked lighting to direct voxel quads", () => { - scene = createPolyScene(host, { - rotX: 0, - rotY: 0, + scene = makeScene(host, { directionalLight: { direction: [0, 0, -1], color: "#ffffff", intensity: 1 }, ambientLight: { color: "#ffffff", intensity: 0 }, - }); + }, { rotX: 0, rotY: 0 }); scene.add(makeVoxelExactParseResult(), { merge: false }); const brush = host.querySelector(".polycss-mesh > b") as HTMLElement | null; expect(brush).not.toBeNull(); @@ -336,12 +351,10 @@ describe("createPolyScene", () => { }); it("uses exact parsed voxel polygons for direct matrix placement", () => { - scene = createPolyScene(host, { - rotX: 65, - rotY: 45, + scene = makeScene(host, { directionalLight: { direction: [0, 0, 1], color: "#ffffff", intensity: 0 }, ambientLight: { color: "#ffffff", intensity: 1 }, - }); + }, { rotX: 65, rotY: 45 }); scene.add(makeVoxelExactParseResult(), { merge: false }); const brush = host.querySelector(".polycss-mesh > b") as HTMLElement | null; expect(brush).not.toBeNull(); @@ -352,7 +365,7 @@ describe("createPolyScene", () => { }); it("falls back to polygon rendering when raw vox polygons are not exact direct quads", () => { - scene = createPolyScene(host); + scene = makeScene(host); scene.add(makeVoxelParseResult(), { merge: false }); expect(host.querySelector(".polycss-voxel-host-z")).toBeNull(); expect(host.querySelector(".polycss-mesh > b")).toBeNull(); @@ -360,7 +373,7 @@ describe("createPolyScene", () => { }); it("falls back to polygon rendering after setPolygons replaces vox source geometry", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeVoxelExactParseResult(), { merge: false }); expect(host.querySelector(".polycss-mesh > b")).not.toBeNull(); handle.setPolygons([triangle()], { merge: false }); @@ -371,7 +384,7 @@ describe("createPolyScene", () => { }); it("hoists the repeated baked solid paint to the mesh wrapper", () => { - scene = createPolyScene(host); + scene = makeScene(host); scene.add(makeParseResult([triangle(), triangle()]), { merge: false }); const wrapper = host.querySelector(".polycss-mesh") as HTMLElement; const polys = Array.from(host.querySelectorAll("u")) as HTMLElement[]; @@ -382,7 +395,7 @@ describe("createPolyScene", () => { }); it("hoists repeated dynamic solid base RGB channels to the mesh wrapper", () => { - scene = createPolyScene(host, { textureLighting: "dynamic" }); + scene = makeScene(host, { textureLighting: "dynamic" }); scene.add(makeParseResult([triangle(), triangle()]), { merge: false }); const wrapper = host.querySelector(".polycss-mesh") as HTMLElement; const polys = Array.from(host.querySelectorAll("u")) as HTMLElement[]; @@ -394,7 +407,7 @@ describe("createPolyScene", () => { }); it("renders textured polygons as polygon s elements", () => { - scene = createPolyScene(host); + scene = makeScene(host); scene.add(makeParseResult([texturedTriangle()])); const poly = host.querySelector("s"); expect(poly).not.toBeNull(); @@ -402,7 +415,7 @@ describe("createPolyScene", () => { }); it("applies mesh transform CSS", () => { - scene = createPolyScene(host); + scene = makeScene(host); scene.add(makeParseResult(), { position: [10, 20, 30], rotation: [45, 0, 0], @@ -415,7 +428,7 @@ describe("createPolyScene", () => { }); it("handle.remove() detaches the wrapper from the DOM", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult()); expect(host.querySelectorAll(".polycss-mesh").length).toBe(1); handle.remove(); @@ -423,7 +436,7 @@ describe("createPolyScene", () => { }); it("handle.setTransform() updates the wrapper transform without re-mount", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult(), { position: [0, 0, 0] }); handle.setTransform({ position: [5, 5, 5] }); const wrapper = host.querySelector(".polycss-mesh") as HTMLElement; @@ -431,7 +444,7 @@ describe("createPolyScene", () => { }); it("can update stableDom mesh geometry without replacing polygon elements", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([triangle()]), { merge: false, stableDom: true, @@ -456,7 +469,7 @@ describe("createPolyScene", () => { }); it("preserves caller-mounted mesh wrapper children across setPolygons()", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([triangle()]), { merge: false }); const nested = document.createElement("div"); nested.className = "nested-helper"; @@ -470,7 +483,7 @@ describe("createPolyScene", () => { }); it("updates stableDom textured triangles without replacing loaded atlas elements", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([texturedTriangle()]), { merge: false, stableDom: true, @@ -498,7 +511,7 @@ describe("createPolyScene", () => { }); it("handle.dispose() detaches the wrapper AND calls parseResult.dispose()", () => { - scene = createPolyScene(host); + scene = makeScene(host); const pr = makeParseResult(); const handle = scene.add(pr); handle.dispose(); @@ -507,7 +520,7 @@ describe("createPolyScene", () => { }); it("handle.dispose() is idempotent", () => { - scene = createPolyScene(host); + scene = makeScene(host); const pr = makeParseResult(); const handle = scene.add(pr); handle.dispose(); @@ -515,14 +528,14 @@ describe("createPolyScene", () => { }); it("supports vec3 scale", () => { - scene = createPolyScene(host); + scene = makeScene(host); scene.add(makeParseResult(), { scale: [1, 2, 3] }); const wrapper = host.querySelector(".polycss-mesh") as HTMLElement; expect(wrapper.style.transform).toContain("scale3d(1, 2, 3)"); }); it("renders nothing for degenerate polygons", () => { - scene = createPolyScene(host); + scene = makeScene(host); const degenerate: Polygon = { vertices: [[0, 0, 0]], color: "#ff0000" }; scene.add(makeParseResult([degenerate])); const polys = host.querySelectorAll("i,b,s,u"); @@ -530,7 +543,7 @@ describe("createPolyScene", () => { }); it("keeps source polygon alignment when degenerate polygons are skipped", () => { - scene = createPolyScene(host); + scene = makeScene(host); const degenerate: Polygon = { vertices: [[0, 0, 0]], color: "#ff0000" }; scene.add(makeParseResult([degenerate, triangle()])); const poly = host.querySelector("i,b,s,u") as HTMLElement; @@ -542,13 +555,13 @@ describe("createPolyScene", () => { describe("rebakeAtlas", () => { it("rebakeAtlas() does not throw", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([triangle()])); expect(() => handle.rebakeAtlas()).not.toThrow(); }); it("rebakeAtlas() re-renders the mesh (polygon elements are replaced)", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([triangle()])); handle.setTransform({ rotation: [0, 45, 0] }); @@ -564,7 +577,7 @@ describe("createPolyScene", () => { }); it("rebakeAtlas() is callable multiple times without throwing", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([triangle()])); expect(() => { handle.setTransform({ rotation: [0, 30, 0] }); @@ -577,7 +590,7 @@ describe("createPolyScene", () => { }); it("rebakeAtlas() calls renderEntry (spy on setPolygons verifies re-render pathway)", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([triangle()])); handle.setTransform({ rotation: [0, 45, 0] }); @@ -594,7 +607,7 @@ describe("createPolyScene", () => { }); it("rebakeAtlas() on a mesh with no rotation uses zero-rotation inverse (identity light)", () => { - scene = createPolyScene(host, { + scene = makeScene(host, { directionalLight: { direction: [0, 0, 1], color: "#ffffff", intensity: 1 }, }); const handle = scene.add(makeParseResult([triangle()])); @@ -606,7 +619,7 @@ describe("createPolyScene", () => { }); it("rebakeAtlas() is a no-op spy target (can be mocked externally)", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([triangle()])); const spy = vi.spyOn(handle, "rebakeAtlas"); handle.rebakeAtlas(); @@ -617,13 +630,13 @@ describe("createPolyScene", () => { describe("PolyMeshHandle getters", () => { it("getPolygons() returns the same array as handle.polygons", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([triangle()])); expect(handle.getPolygons()).toBe(handle.polygons); }); it("getPolygons() reflects setPolygons() update", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([triangle()])); const newPolys = [triangle("#00ff00"), triangle("#0000ff")]; handle.setPolygons(newPolys, { merge: false }); @@ -632,7 +645,7 @@ describe("createPolyScene", () => { }); it("getPosition() returns transform.position", () => { - scene = createPolyScene(host); + scene = makeScene(host); const pos: [number, number, number] = [1, 2, 3]; const handle = scene.add(makeParseResult(), { position: pos }); expect(handle.getPosition()).toEqual(pos); @@ -640,20 +653,20 @@ describe("createPolyScene", () => { }); it("getPosition() returns undefined when no position set", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult()); expect(handle.getPosition()).toBeUndefined(); }); it("getPosition() reflects setTransform() update", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult(), { position: [0, 0, 0] }); handle.setTransform({ position: [10, 20, 30] }); expect(handle.getPosition()).toEqual([10, 20, 30]); }); it("getRotation() returns transform.rotation", () => { - scene = createPolyScene(host); + scene = makeScene(host); const rot: [number, number, number] = [45, 90, 180]; const handle = scene.add(makeParseResult(), { rotation: rot }); expect(handle.getRotation()).toEqual(rot); @@ -661,27 +674,27 @@ describe("createPolyScene", () => { }); it("getRotation() returns undefined when no rotation set", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult()); expect(handle.getRotation()).toBeUndefined(); }); it("getScale() returns transform.scale (number)", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult(), { scale: 2.5 }); expect(handle.getScale()).toBe(2.5); expect(handle.getScale()).toBe(handle.transform.scale); }); it("getScale() returns transform.scale (Vec3)", () => { - scene = createPolyScene(host); + scene = makeScene(host); const scale: [number, number, number] = [1, 2, 3]; const handle = scene.add(makeParseResult(), { scale }); expect(handle.getScale()).toEqual(scale); }); it("getScale() returns undefined when no scale set", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult()); expect(handle.getScale()).toBeUndefined(); }); @@ -706,7 +719,7 @@ describe("createPolyScene", () => { ], color: "#ff0000", }; - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([tri1, tri2])); // After merge there should be 1 polygon, not 2. expect(handle.polygons.length).toBe(1); @@ -714,32 +727,31 @@ describe("createPolyScene", () => { }); describe("setOptions", () => { - it("updates scene transform when rotation options change", () => { - scene = createPolyScene(host, { rotX: 0 }); + it("updates scene transform when camera.update + applyCamera changes rotation", () => { + scene = makeScene(host, {}, { rotX: 0 }); const sceneEl = host.querySelector(".polycss-scene") as HTMLElement; const before = sceneEl.style.transform; - scene.setOptions({ rotX: 90 }); + scene.camera.update({ rotX: 90 }); + scene.applyCamera(); expect(sceneEl.style.transform).not.toBe(before); expect(sceneEl.style.transform).toContain("rotateX(90deg)"); }); - it("inlines perspective on setOptions update", () => { - scene = createPolyScene(host, { perspective: 1000 }); - scene.setOptions({ perspective: 2500 }); + it("perspective camera applies the configured perspective at creation", () => { + scene = createPolyScene(host, { camera: createPolyPerspectiveCamera({ perspective: 2500 }) }); const sceneEl = host.querySelector(".polycss-scene") as HTMLElement; expect(sceneEl.style.perspective).toBe("2500px"); }); - it("updates perspective to orthographic stand-in when setOptions sets perspective=false", () => { - scene = createPolyScene(host, { perspective: 1000 }); - scene.setOptions({ perspective: false }); + it("orthographic camera produces the 1000000px stand-in perspective", () => { + scene = makeScene(host); const sceneEl = host.querySelector(".polycss-scene") as HTMLElement; // See "inlines a large finite perspective..." for the rationale. expect(sceneEl.style.perspective).toBe("1000000px"); }); it("emits dynamic light cascade vars on the scene element when textureLighting='dynamic'", () => { - scene = createPolyScene(host, { + scene = makeScene(host, { textureLighting: "dynamic", directionalLight: { direction: [0, 0, 1], color: "#ff8800", intensity: 1.5 }, ambientLight: { color: "#222222", intensity: 0.3 }, @@ -757,7 +769,7 @@ describe("createPolyScene", () => { }); it("removes dynamic light vars when textureLighting flips back to baked", () => { - scene = createPolyScene(host, { textureLighting: "dynamic" }); + scene = makeScene(host, { textureLighting: "dynamic" }); const sceneEl = host.querySelector(".polycss-scene") as HTMLElement; expect(sceneEl.style.getPropertyValue("--plz")).not.toBe(""); scene.setOptions({ textureLighting: "baked" }); @@ -766,14 +778,14 @@ describe("createPolyScene", () => { }); it("honors strategies.disable at creation time", () => { - scene = createPolyScene(host, { strategies: { disable: ["u"] } }); + scene = makeScene(host, { strategies: { disable: ["u"] } }); scene.add(makeParseResult([triangle()])); expect(host.querySelector("u")).toBeNull(); expect(host.querySelector("i, s")).not.toBeNull(); }); it("re-renders meshes when strategies changes via setOptions", () => { - scene = createPolyScene(host); + scene = makeScene(host); scene.add(makeParseResult([triangle()])); expect(host.querySelector("u")).not.toBeNull(); scene.setOptions({ strategies: { disable: ["u"] } }); @@ -782,7 +794,7 @@ describe("createPolyScene", () => { }); it("re-enables a strategy when removed from disable list via setOptions", () => { - scene = createPolyScene(host, { strategies: { disable: ["u"] } }); + scene = makeScene(host, { strategies: { disable: ["u"] } }); scene.add(makeParseResult([triangle()])); expect(host.querySelector("u")).toBeNull(); scene.setOptions({ strategies: { disable: [] } }); @@ -790,7 +802,7 @@ describe("createPolyScene", () => { }); it("skips mesh re-render when setOptions is called with equivalent strategies", () => { - scene = createPolyScene(host, { strategies: { disable: ["u"] } }); + scene = makeScene(host, { strategies: { disable: ["u"] } }); scene.add(makeParseResult([triangle()])); const firstLeaf = host.querySelector("i, s"); expect(firstLeaf).not.toBeNull(); @@ -802,47 +814,41 @@ describe("createPolyScene", () => { }); it("mounts only camera-facing voxel leaves by default", () => { - scene = createPolyScene(host, { - rotX: 0, - rotY: 0, - }); + scene = makeScene(host, {}, { rotX: 0, rotY: 0 }); const handle = scene.add(makeParseResult([triangle(), backTriangle()]), { merge: false }); expect(handle.polygons.length).toBe(2); const firstLeaf = host.querySelector(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u"); expect(host.querySelectorAll(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u").length).toBe(1); - scene.setOptions({ rotX: 180 }); + scene.camera.update({ rotX: 180 }); + scene.applyCamera(); const nextLeaf = host.querySelector(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u"); expect(nextLeaf).not.toBe(firstLeaf); expect(host.querySelectorAll(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u").length).toBe(1); }); it("does not remount culling leaves when camera rotation keeps the same visible normal set", () => { - scene = createPolyScene(host, { - rotX: 0, - rotY: 0, - }); + scene = makeScene(host, {}, { rotX: 0, rotY: 0 }); scene.add(makeParseResult([triangle(), backTriangle()]), { merge: false }); const firstLeaf = host.querySelector(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u"); expect(firstLeaf).not.toBeNull(); - scene.setOptions({ rotY: 10 }); + scene.camera.update({ rotY: 10 }); + scene.applyCamera(); expect(host.querySelector(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u")).toBe(firstLeaf); expect(host.querySelectorAll(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u").length).toBe(1); }); it("keeps caller-mounted children when camera culling remounts leaves", () => { - scene = createPolyScene(host, { - rotX: 0, - rotY: 0, - }); + scene = makeScene(host, {}, { rotX: 0, rotY: 0 }); const handle = scene.add(makeParseResult([triangle(), backTriangle()]), { merge: false }); const nested = document.createElement("div"); nested.className = "nested-helper"; handle.element.appendChild(nested); - scene.setOptions({ rotX: 180 }); + scene.camera.update({ rotX: 180 }); + scene.applyCamera(); expect(handle.element.contains(nested)).toBe(true); expect(handle.element.lastElementChild).toBe(nested); @@ -850,10 +856,7 @@ describe("createPolyScene", () => { }); it("patches culling deltas without removing leaves that stayed visible", () => { - scene = createPolyScene(host, { - rotX: 65, - rotY: 45, - }); + scene = makeScene(host, {}, { rotX: 65, rotY: 45 }); const handle = scene.add( makeParseResult([ triangle("#111111"), @@ -871,7 +874,8 @@ describe("createPolyScene", () => { }); observer.observe(handle.element, { childList: true }); - scene.setOptions({ rotY: 225 }); + scene.camera.update({ rotY: 225 }); + scene.applyCamera(); observer.disconnect(); expect(handle.element.querySelectorAll("i,b,s,u").length).toBe(2); @@ -880,24 +884,18 @@ describe("createPolyScene", () => { }); it("uses strict culling for low-normal meshes so voxel faces do not linger behind the camera", () => { - scene = createPolyScene(host, { - rotX: 65, - rotY: 179, - }); + scene = makeScene(host, {}, { rotX: 65, rotY: 179 }); scene.add(makeParseResult([triangle(), sideTriangle()]), { merge: false, stableDom: true }); expect(host.querySelectorAll(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u").length).toBe(2); - scene.setOptions({ rotY: 181 }); + scene.camera.update({ rotY: 181 }); + scene.applyCamera(); expect(host.querySelectorAll(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u").length).toBe(1); }); it("leaves high-normal meshes on the stable DOM path", () => { - scene = createPolyScene(host, { - rotX: 65, - rotY: 0, - textureLighting: "dynamic", - }); + scene = makeScene(host, { textureLighting: "dynamic" }, { rotX: 65, rotY: 0 }); const handle = scene.add(makeParseResult(highNormalTrianglePairs()), { merge: false }); expect(handle.element.querySelector(".polycss-bucket")).not.toBeNull(); const leafCount = handle.element.querySelectorAll("i,b,s,u").length; @@ -906,7 +904,8 @@ describe("createPolyScene", () => { const observer = new MutationObserver((items) => records.push(...items)); observer.observe(handle.element, { childList: true, subtree: true }); - scene.setOptions({ rotY: 180 }); + scene.camera.update({ rotY: 180 }); + scene.applyCamera(); observer.disconnect(); expect(records).toHaveLength(0); @@ -926,20 +925,21 @@ describe("createPolyScene", () => { // rotateX, rotate — will update; only the innermost translate3d reflects // the autoCenter state). describe("autoCenter recomputation diff", () => { - it("does not recompute autoCenter on a camera-only setOptions", () => { - scene = createPolyScene(host, { autoCenter: true }); + it("does not recompute autoCenter on a camera-only applyCamera", () => { + scene = makeScene(host, { autoCenter: true }); scene.add(makeParseResult([triangle()])); // Capture the translate3d before — autoCenter is on so it should be non-zero. const translateBefore = getSceneTranslatePart(host); expect(translateBefore).toMatch(/^translate3d/); expect(translateBefore).not.toBe("translate3d(0px, 0px, 0px)"); - scene.setOptions({ rotY: 90 }); + scene.camera.update({ rotY: 90 }); + scene.applyCamera(); // The translate3d (offset) must not change — recomputeAutoCenter was skipped. expect(getSceneTranslatePart(host)).toBe(translateBefore); }); it("does not recompute autoCenter on a lighting-only setOptions", () => { - scene = createPolyScene(host, { autoCenter: true }); + scene = makeScene(host, { autoCenter: true }); scene.add(makeParseResult([triangle()])); const translateBefore = getSceneTranslatePart(host); scene.setOptions({ @@ -949,23 +949,23 @@ describe("createPolyScene", () => { }); it("does not recompute autoCenter on textureLighting changes", () => { - scene = createPolyScene(host, { autoCenter: true, textureLighting: "dynamic" }); + scene = makeScene(host, { autoCenter: true, textureLighting: "dynamic" }); scene.add(makeParseResult([triangle()])); const translateBefore = getSceneTranslatePart(host); scene.setOptions({ textureLighting: "baked" }); expect(getSceneTranslatePart(host)).toBe(translateBefore); }); - it("does not recompute autoCenter on perspective changes", () => { - scene = createPolyScene(host, { autoCenter: true }); + it("does not recompute autoCenter on applyCamera (perspective does not apply)", () => { + scene = makeScene(host, { autoCenter: true }); scene.add(makeParseResult([triangle()])); const translateBefore = getSceneTranslatePart(host); - scene.setOptions({ perspective: 4000 }); + scene.applyCamera(); expect(getSceneTranslatePart(host)).toBe(translateBefore); }); it("DOES recompute autoCenter when autoCenter itself toggles", () => { - scene = createPolyScene(host, { autoCenter: false }); + scene = makeScene(host, { autoCenter: false }); scene.add(makeParseResult([triangle()])); // autoCenter off → translate3d should be zero (no offset). expect(getSceneTranslatePart(host)).toBe("translate3d(0px, 0px, 0px)"); @@ -980,7 +980,7 @@ describe("createPolyScene", () => { // that need to force a refresh should toggle off-then-on, or // change the underlying mesh (which triggers its own recompute // via add()/remove()). - scene = createPolyScene(host, { autoCenter: true }); + scene = makeScene(host, { autoCenter: true }); scene.add(makeParseResult([triangle()])); const translateBefore = getSceneTranslatePart(host); expect(translateBefore).not.toBe("translate3d(0px, 0px, 0px)"); @@ -989,13 +989,14 @@ describe("createPolyScene", () => { expect(getSceneTranslatePart(host)).toBe(translateBefore); }); - it("still updates the scene transform on a camera-only setOptions", () => { - // Sanity check: skipping recomputeAutoCenter must NOT skip the camera - // transform update — the scene element should still reflect new rotY. - scene = createPolyScene(host, { autoCenter: true, rotY: 0 }); + it("applyCamera updates the scene transform without affecting autoCenter offset", () => { + // Sanity check: applyCamera must update the scene transform — the scene + // element should still reflect new rotY — without triggering a recomputeAutoCenter. + scene = makeScene(host, { autoCenter: true }, { rotY: 0 }); scene.add(makeParseResult([triangle()])); const sceneEl = host.querySelector(".polycss-scene") as HTMLElement; - scene.setOptions({ rotY: 137 }); + scene.camera.update({ rotY: 137 }); + scene.applyCamera(); expect(sceneEl.style.transform).toContain("rotate(137deg)"); }); }); @@ -1003,7 +1004,7 @@ describe("createPolyScene", () => { describe("destroy", () => { it("removes the scene element from the host", () => { - scene = createPolyScene(host); + scene = makeScene(host); expect(host.querySelector(".polycss-scene")).not.toBeNull(); scene.destroy(); expect(host.querySelector(".polycss-scene")).toBeNull(); @@ -1011,7 +1012,7 @@ describe("createPolyScene", () => { }); it("disposes all registered meshes (calls parseResult.dispose())", () => { - scene = createPolyScene(host); + scene = makeScene(host); const pr1 = makeParseResult(); const pr2 = makeParseResult(); scene.add(pr1); @@ -1033,7 +1034,7 @@ describe("createPolyScene", () => { }; it("emits --plx/ly/lz on the mesh wrapper when dynamic + non-zero rotation", () => { - scene = createPolyScene(host, dynLight); + scene = makeScene(host, dynLight); scene.add(makeParseResult([triangle()]), { rotation: [0, 90, 0] }); const wrapper = host.querySelector(".polycss-mesh") as HTMLElement; // inverseRotateVec3([0,0,1], [0,90,0]) = rotateY(-90) on [0,0,1] = [-1,0,0] @@ -1043,7 +1044,7 @@ describe("createPolyScene", () => { }); it("updates the override synchronously when setTransform changes rotation", () => { - scene = createPolyScene(host, dynLight); + scene = makeScene(host, dynLight); const handle = scene.add(makeParseResult([triangle()]), { rotation: [0, 90, 0] }); const wrapper = host.querySelector(".polycss-mesh") as HTMLElement; // Rotate back to zero — override should be removed. @@ -1054,7 +1055,7 @@ describe("createPolyScene", () => { }); it("removes the override when rotation is set back to zero", () => { - scene = createPolyScene(host, dynLight); + scene = makeScene(host, dynLight); const handle = scene.add(makeParseResult([triangle()]), { rotation: [0, 90, 0] }); const wrapper = host.querySelector(".polycss-mesh") as HTMLElement; expect(wrapper.style.getPropertyValue("--plx")).not.toBe(""); @@ -1063,7 +1064,7 @@ describe("createPolyScene", () => { }); it("removes the override when scene switches to baked lighting", () => { - scene = createPolyScene(host, dynLight); + scene = makeScene(host, dynLight); scene.add(makeParseResult([triangle()]), { rotation: [0, 90, 0] }); const wrapper = host.querySelector(".polycss-mesh") as HTMLElement; expect(wrapper.style.getPropertyValue("--plx")).not.toBe(""); @@ -1074,7 +1075,7 @@ describe("createPolyScene", () => { }); it("does NOT emit override for a mesh with no rotation in a dynamic scene", () => { - scene = createPolyScene(host, dynLight); + scene = makeScene(host, dynLight); scene.add(makeParseResult([triangle()])); const wrapper = host.querySelector(".polycss-mesh") as HTMLElement; expect(wrapper.style.getPropertyValue("--plx")).toBe(""); @@ -1083,7 +1084,7 @@ describe("createPolyScene", () => { }); it("updates the override on all meshes when scene directionalLight changes", () => { - scene = createPolyScene(host, dynLight); + scene = makeScene(host, dynLight); scene.add(makeParseResult([triangle()]), { rotation: [0, 90, 0] }); const wrapper = host.querySelector(".polycss-mesh") as HTMLElement; // Change the world light to +X direction → inverseRotateVec3([1,0,0],[0,90,0]) @@ -1098,7 +1099,7 @@ describe("createPolyScene", () => { }); it("does NOT emit override when scene has no directionalLight", () => { - scene = createPolyScene(host, { textureLighting: "dynamic" }); + scene = makeScene(host, { textureLighting: "dynamic" }); scene.add(makeParseResult([triangle()]), { rotation: [0, 90, 0] }); const wrapper = host.querySelector(".polycss-mesh") as HTMLElement; expect(wrapper.style.getPropertyValue("--plx")).toBe(""); @@ -1107,7 +1108,7 @@ describe("createPolyScene", () => { describe("updatePolygon", () => { it("mutates the polygon's color when targeted by reference", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([triangle("#ff0000")]), { merge: false }); const poly = handle.polygons[0]; handle.updatePolygon(poly, { color: "#00ff00" }); @@ -1117,7 +1118,7 @@ describe("createPolyScene", () => { }); it("mutates the polygon's color when targeted by index", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add( makeParseResult([triangle("#ff0000"), triangle("#00ff00")]), { merge: false }, @@ -1128,7 +1129,7 @@ describe("createPolyScene", () => { }); it("merges partial fields onto the polygon (only updates what's passed)", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([triangle("#ff0000")]), { merge: false }); const originalVerts = handle.polygons[0].vertices; handle.updatePolygon(0, { color: "#00ff00" }); @@ -1137,7 +1138,7 @@ describe("createPolyScene", () => { }); it("re-renders the mesh DOM (leaf elements are fresh after update)", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([triangle("#ff0000")]), { merge: false }); const before = host.querySelector("u, b, i, s") as HTMLElement; handle.updatePolygon(0, { color: "#00ff00" }); @@ -1147,7 +1148,7 @@ describe("createPolyScene", () => { }); it("no-ops on a stale polygon reference (not in the current polygons array)", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([triangle("#ff0000")]), { merge: false }); const stale: Polygon = { vertices: triangle().vertices, color: "#abcdef" }; const elBefore = host.querySelector("u, b, i, s"); @@ -1158,7 +1159,7 @@ describe("createPolyScene", () => { }); it("no-ops when index is out of range", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([triangle("#ff0000")]), { merge: false }); expect(() => handle.updatePolygon(99, { color: "#000000" })).not.toThrow(); expect(() => handle.updatePolygon(-1, { color: "#000000" })).not.toThrow(); @@ -1166,7 +1167,7 @@ describe("createPolyScene", () => { }); it("can be called repeatedly to step through colors", () => { - scene = createPolyScene(host); + scene = makeScene(host); const handle = scene.add(makeParseResult([triangle("#ff0000")]), { merge: false }); handle.updatePolygon(0, { color: "#00ff00" }); handle.updatePolygon(0, { color: "#0000ff" }); @@ -1177,7 +1178,7 @@ describe("createPolyScene", () => { describe("autoCenter", () => { it("default (no autoCenter) leaves the scene translate3d at origin", () => { - scene = createPolyScene(host); + scene = makeScene(host); scene.add(makeParseResult()); // Without autoCenter the offset is [0,0,0], so the innermost translate3d is zero. expect(getSceneTranslatePart(host)).toBe("translate3d(0px, 0px, 0px)"); @@ -1193,7 +1194,7 @@ describe("createPolyScene", () => { ], color: "#ff0000", }; - scene = createPolyScene(host, { autoCenter: true }); + scene = makeScene(host, { autoCenter: true }); scene.add(makeParseResult([t])); // World-Y → CSS-X: cssX = 0.5 * 50 = 25 → translate by -25. // World-X → CSS-Y: cssY = 0.5 * 50 = 25 → translate by -25. @@ -1203,7 +1204,7 @@ describe("createPolyScene", () => { }); it("autoCenter recomputes when meshes change", () => { - scene = createPolyScene(host, { autoCenter: true }); + scene = makeScene(host, { autoCenter: true }); const handle = scene.add(makeParseResult([triangle()])); const t1 = getSceneTranslatePart(host); @@ -1232,12 +1233,12 @@ describe("createPolyScene", () => { }); it("autoCenter=true with no meshes leaves translate3d at origin", () => { - scene = createPolyScene(host, { autoCenter: true }); + scene = makeScene(host, { autoCenter: true }); expect(getSceneTranslatePart(host)).toBe("translate3d(0px, 0px, 0px)"); }); it("setOptions({autoCenter: true}) enables centering after the fact", () => { - scene = createPolyScene(host, { autoCenter: false }); + scene = makeScene(host, { autoCenter: false }); scene.add(makeParseResult([triangle()])); expect(getSceneTranslatePart(host)).toBe("translate3d(0px, 0px, 0px)"); scene.setOptions({ autoCenter: true }); @@ -1254,7 +1255,7 @@ describe("createPolyScene", () => { ], color: "#fff", }; - scene = createPolyScene(host, { autoCenter: true }); + scene = makeScene(host, { autoCenter: true }); scene.add(makeParseResult([tri])); expect(getSceneTranslatePart(host)).toContain("-50px"); }); @@ -1279,7 +1280,7 @@ describe("createPolyScene", () => { ], color: "#fff", }; - scene = createPolyScene(host, { autoCenter: true }); + scene = makeScene(host, { autoCenter: true }); scene.add(makeParseResult([chicken])); const before = getSceneTranslatePart(host); scene.add(makeParseResult([farAway]), { excludeFromAutoCenter: true }); @@ -1298,13 +1299,13 @@ describe("createPolyScene", () => { }; it("default (no castShadow) emits no .polycss-shadow elements", () => { - scene = createPolyScene(host, dynOpts); + scene = makeScene(host, dynOpts); scene.add(makeParseResult([triangle()])); expect(host.querySelectorAll(".polycss-shadow").length).toBe(0); }); it("castShadow:true in dynamic mode emits shadow leaves, one per non-textured polygon", () => { - scene = createPolyScene(host, dynOpts); + scene = makeScene(host, dynOpts); // Use spatially distinct triangles so the loose shadow-dedup pass // doesn't fold them into one shadow (two triangles at the same // location WOULD be deduped, which is the intended behavior). @@ -1317,13 +1318,13 @@ describe("createPolyScene", () => { }); it("castShadow:true in baked mode emits NO shadow leaves", () => { - scene = createPolyScene(host, { textureLighting: "baked" }); + scene = makeScene(host, { textureLighting: "baked" }); scene.add(makeParseResult([triangle()]), { castShadow: true }); expect(host.querySelectorAll(".polycss-shadow").length).toBe(0); }); it("shadow leaves have the polycss-shadow class", () => { - scene = createPolyScene(host, dynOpts); + scene = makeScene(host, dynOpts); scene.add(makeParseResult([triangle()]), { castShadow: true }); const shadows = host.querySelectorAll(".polycss-shadow"); expect(shadows.length).toBeGreaterThan(0); @@ -1333,7 +1334,7 @@ describe("createPolyScene", () => { }); it("shadow leaves are always with border-shape regardless of caster tag", () => { - scene = createPolyScene(host, dynOpts); + scene = makeScene(host, dynOpts); // Mix shapes at distinct 3D positions (otherwise the loose-tolerance // shadow dedup pass folds them into one shadow). Each emits a // shadow — a dedicated single-letter render strategy in the tag @@ -1356,7 +1357,7 @@ describe("createPolyScene", () => { }); it("shadow leaves transform contains var(--shadow-proj) followed by matrix3d", () => { - scene = createPolyScene(host, dynOpts); + scene = makeScene(host, dynOpts); scene.add(makeParseResult([triangle()]), { castShadow: true }); const shadow = host.querySelector(".polycss-shadow") as HTMLElement; expect(shadow).not.toBeNull(); @@ -1364,7 +1365,7 @@ describe("createPolyScene", () => { }); it("adding a casting mesh sets --shadow-ground-cssz on the scene element", () => { - scene = createPolyScene(host, dynOpts); + scene = makeScene(host, dynOpts); scene.add(makeParseResult([triangle()]), { castShadow: true }); const sceneEl = getSceneEl(host); const groundVar = sceneEl.style.getPropertyValue("--shadow-ground-cssz"); @@ -1372,7 +1373,7 @@ describe("createPolyScene", () => { }); it("removing the casting mesh clears --shadow-ground-cssz", () => { - scene = createPolyScene(host, dynOpts); + scene = makeScene(host, dynOpts); const handle = scene.add(makeParseResult([triangle()]), { castShadow: true }); const sceneEl = getSceneEl(host); expect(sceneEl.style.getPropertyValue("--shadow-ground-cssz")).not.toBe(""); @@ -1381,7 +1382,7 @@ describe("createPolyScene", () => { }); it("toggling castShadow via setTransform adds/removes shadow leaves", () => { - scene = createPolyScene(host, dynOpts); + scene = makeScene(host, dynOpts); const handle = scene.add(makeParseResult([triangle()]), { castShadow: false }); expect(host.querySelectorAll(".polycss-shadow").length).toBe(0); handle.setTransform({ castShadow: true }); @@ -1391,7 +1392,7 @@ describe("createPolyScene", () => { }); it("switching from dynamic to baked removes shadow leaves", () => { - scene = createPolyScene(host, dynOpts); + scene = makeScene(host, dynOpts); scene.add(makeParseResult([triangle()]), { castShadow: true }); expect(host.querySelectorAll(".polycss-shadow").length).toBeGreaterThan(0); scene.setOptions({ textureLighting: "baked" }); @@ -1399,7 +1400,7 @@ describe("createPolyScene", () => { }); it("switching from baked back to dynamic re-emits shadow leaves", () => { - scene = createPolyScene(host, { textureLighting: "baked" }); + scene = makeScene(host, { textureLighting: "baked" }); scene.add(makeParseResult([triangle()]), { castShadow: true }); expect(host.querySelectorAll(".polycss-shadow").length).toBe(0); scene.setOptions({ ...dynOpts }); @@ -1407,7 +1408,7 @@ describe("createPolyScene", () => { }); it("textured polygons (s) ALSO emit shadow leaves", () => { - scene = createPolyScene(host, dynOpts); + scene = makeScene(host, dynOpts); scene.add(makeParseResult([texturedTriangle()]), { castShadow: true }); // Shadows depend only on the polygon's outline, not its texture // content. Atlas () polygons cast shadows the same way as @@ -1417,7 +1418,7 @@ describe("createPolyScene", () => { }); it("--clx/--cly/--clz are set on the scene element in dynamic mode", () => { - scene = createPolyScene(host, dynOpts); + scene = makeScene(host, dynOpts); const sceneEl = getSceneEl(host); expect(sceneEl.style.getPropertyValue("--clx")).not.toBe(""); expect(sceneEl.style.getPropertyValue("--cly")).not.toBe(""); @@ -1425,7 +1426,7 @@ describe("createPolyScene", () => { }); it("--clx/--cly/--clz are removed when lighting switches to baked", () => { - scene = createPolyScene(host, dynOpts); + scene = makeScene(host, dynOpts); const sceneEl = getSceneEl(host); expect(sceneEl.style.getPropertyValue("--clx")).not.toBe(""); scene.setOptions({ textureLighting: "baked" }); @@ -1435,3 +1436,32 @@ describe("createPolyScene", () => { }); }); }); + +describe("scene.add — meshResolution option", () => { + let host: HTMLElement; + let scene: PolySceneHandle; + + beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + }); + + afterEach(() => { + scene?.destroy(); + if (host.parentNode) host.parentNode.removeChild(host); + }); + + it("scene.add with meshResolution='lossless' does not throw and produces leaf DOM", () => { + scene = makeScene(host); + const handle = scene.add(makeParseResult([triangle(), triangle()]), { meshResolution: "lossless" }); + expect(handle).toBeTruthy(); + expect(host.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); + }); + + it("scene.add with meshResolution='lossy' does not throw and produces leaf DOM", () => { + scene = makeScene(host); + const handle = scene.add(makeParseResult([triangle()]), { meshResolution: "lossy" }); + expect(handle).toBeTruthy(); + expect(host.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); + }); +}); diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 1a00f931..9a4849a5 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -20,6 +20,7 @@ * the anchor via their own matrix3d translations. */ import type { + MeshResolution, PolyAmbientLight, PolyDirectionalLight, ParseResult, @@ -29,6 +30,10 @@ import type { CameraCullNormalGroup, CameraCullRotation, } from "@layoutit/polycss-core"; +import type { + PolyPerspectiveCameraHandle, + PolyOrthographicCameraHandle, +} from "./createPolyCamera"; import { BASE_TILE, CAMERA_BACKFACE_CULL_EPS, @@ -40,6 +45,7 @@ import { isVoxelCameraCullableNormalGroups, mergePolygons, normalFacesCamera, + optimizeMeshPolygons, parseHexColor, polygonCssSurfaceNormal, } from "@layoutit/polycss-core"; @@ -68,27 +74,12 @@ const ASYNC_MOUNT_BATCH_SIZE = 750; const DEFAULT_SCENE_PERSPECTIVE = 8000; export interface PolySceneOptions { - perspective?: number | false; - rotX?: number; - rotY?: number; - zoom?: number; /** - * Camera pull-back distance in CSS pixels. Increasing distance moves the - * camera farther from the target (scene appears smaller), applied as an - * outermost `translateZ(-distance)` in the scene transform. Matches the - * `distance` field in core's `CameraState`. Default: 0 (no dolly offset). + * Camera handle created by `createPolyCamera`, `createPolyOrthographicCamera`, + * or `createPolyPerspectiveCamera`. Required — `createPolyScene` will throw if + * this field is missing. */ - distance?: number; - /** - * World-coordinate camera target — the world point that appears at the - * viewport centre. Matches React's `CameraState.target`. Defaults to - * `[0, 0, 0]` so existing scenes that don't set it keep working. - * - * Internally encoded as the innermost translate in the scene transform: - * `scale(zoom) rotateX(rotX) rotate(rotY) translate3d(-ty*tile, -tx*tile, -tz*tile)` - * (world→CSS axis swap: world-X→CSS-Y, world-Y→CSS-X, world-Z→CSS-Z). - */ - target?: Vec3; + camera: PolyPerspectiveCameraHandle | PolyOrthographicCameraHandle; directionalLight?: PolyDirectionalLight; ambientLight?: PolyAmbientLight; /** Textured polygon lighting mode. Defaults to "baked". */ @@ -145,6 +136,14 @@ export interface PolyMeshTransform { * triangle topology must remain stable from frame to frame. */ merge?: boolean; + /** + * Mesh optimization intent. Defaults to `"lossy"` (bounded geometric + * approximation when it reduces polygon count). Set `"lossless"` to preserve + * the authored surface — only exact coplanar merges are applied. + * When set, the optimizer runs with this resolution and supersedes the + * plain `mergePolygons` pass. + */ + meshResolution?: MeshResolution; /** * Keep polygon leaf DOM nodes stable across setPolygons() calls when the * mesh topology is unchanged. Intended for animated/deforming meshes. @@ -246,8 +245,8 @@ interface InternalPolyMeshHandle extends PolyMeshHandle { export interface PolySceneHandle { /** Add a mesh to the scene. Returns a handle for later removal. */ add(mesh: ParseResult, opts?: PolyMeshTransform): PolyMeshHandle; - /** Update scene-level config (rotation, lighting, etc.). */ - setOptions(partial: Partial): void; + /** Update scene-level config (lighting, autoCenter, strategies, etc.). Camera state is on `scene.camera`. */ + setOptions(partial: Partial>): void; /** Tear down the scene; revokes all blob URLs of registered meshes. */ destroy(): void; /** @@ -257,13 +256,23 @@ export interface PolySceneHandle { */ readonly host: HTMLElement; /** - * Snapshot of the current options (camera, lighting, merge, autoCenter, - * textureLighting, textureQuality, and perspective). Returned by reference, - * so callers must treat it as read-only — - * mutations won't propagate. Used by helpers that need to read the current - * camera state without duplicating it. + * The camera handle this scene is bound to. Controls update camera state + * via `scene.camera.update({...})` then call `scene.applyCamera()` to + * re-apply the transform. + */ + readonly camera: PolyPerspectiveCameraHandle | PolyOrthographicCameraHandle; + /** + * Re-applies the scene transform from the current camera state. Call this + * after mutating `scene.camera.update({...})` to make the change visible. + * Controls call this once per interaction event after updating camera state. + */ + applyCamera(): void; + /** + * Snapshot of the current non-camera scene options (lighting, autoCenter, + * textureQuality, strategies, shadow). Returned by reference — treat as + * read-only; use `setOptions` to update. */ - getOptions(): Readonly; + getOptions(): Readonly>; /** Snapshot of mesh handles currently in the scene (insertion order). * Used by selection helpers to enumerate hit-test candidates. */ meshes(): readonly PolyMeshHandle[]; @@ -272,11 +281,6 @@ export interface PolySceneHandle { findMeshByElement(element: Element | null): PolyMeshHandle | null; } -// Match React's PolyCamera default — 1000px is a strong fish-eye that -// distorts loaded meshes; 8000px gives the gentle iso look users expect. -const DEFAULT_PERSPECTIVE = 8000; -const DEFAULT_ROT_X = 65; -const DEFAULT_ROT_Y = 45; const DEFAULT_ZOOM = 1; const DEFAULT_TILE = BASE_TILE; @@ -313,16 +317,17 @@ function buildMeshTransform(t: PolyMeshTransform): string | undefined { return parts.length > 0 ? parts.join(" ") : undefined; } -function buildSceneTransform( - opts: PolySceneOptions, +function buildSceneTransformFromCamera( + camera: PolyPerspectiveCameraHandle | PolyOrthographicCameraHandle, autoCenterOffset: Vec3 = [0, 0, 0], layoutScale = 1, ): string { - const rotX = opts.rotX ?? DEFAULT_ROT_X; - const rotY = opts.rotY ?? DEFAULT_ROT_Y; - const zoom = (opts.zoom ?? DEFAULT_ZOOM) * layoutScale; - const distance = (opts.distance ?? 0) * layoutScale; - const target = opts.target ?? [0, 0, 0]; + const state = camera.state; + const rotX = state.rotX; + const rotY = state.rotY; + const zoom = (state.zoom ?? DEFAULT_ZOOM) * layoutScale; + const distance = (state.distance ?? 0) * layoutScale; + const target = state.target ?? [0, 0, 0]; // World→CSS axis swap: world[0]→CSS Y, world[1]→CSS X, world[2]→CSS Z. // Negate so the scene moves such that `target + autoCenterOffset` appears // at viewport centre. `autoCenterOffset` is the bbox-center of all meshes @@ -441,11 +446,19 @@ function quantizeNormalKey(p: Polygon): { key: string; vec: Vec3 } | null { export function createPolyScene( host: HTMLElement, - options: PolySceneOptions = {}, + options: PolySceneOptions, ): PolySceneHandle { if (!host || typeof host.appendChild !== "function") { throw new Error("createPolyScene: host must be an HTMLElement"); } + if (!options?.camera) { + throw new Error( + "createPolyScene: a camera handle is required. " + + "Use createPolyCamera({...}) or createPolyPerspectiveCamera({...}) and pass as { camera }." + ); + } + + const camera = options.camera; // Inject base styles into the host's owning document so .polycss-scene // has perspective + preserve-3d defaults. @@ -460,7 +473,10 @@ export function createPolyScene( if (computed.position === "static") host.style.position = "relative"; } - let currentOptions: PolySceneOptions = { ...options }; + // currentOptions holds non-camera scene options only. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { camera: _cameraOption, ...nonCameraOptions } = options; + let currentOptions: Omit = { ...nonCameraOptions }; const layoutScale = effectiveCssZoom(host); // Bbox-center of all live meshes (helpers opt out). Auto-managed by @@ -506,24 +522,22 @@ export function createPolyScene( } const meshes = new Set(); - function applySceneStyle(el: HTMLElement, opts: PolySceneOptions): void { + function applySceneStyle(el: HTMLElement, opts: Omit): void { applyCssZoomCompensation(el, layoutScale); - el.style.transform = buildSceneTransform(opts, autoCenterOffset, layoutScale); - if (typeof opts.perspective === "number") { - el.style.perspective = `${scaledCssPixels(opts.perspective, layoutScale)}px`; - } else if (opts.perspective === false) { - // Orthographic projection — true `perspective: none` triggers a Chrome - // compositor fast path that mis-rasterizes border-triangle leaves - // (0×0 layout box with asymmetric borders): holes and dropped fragments - // at initial paint. A very large finite perspective is visually - // indistinguishable from orthographic (no perceptible foreshortening at - // this distance) but routes Chrome through the normal compositor path. + el.style.transform = buildSceneTransformFromCamera(camera, autoCenterOffset, layoutScale); + // Apply CSS perspective from the camera's perspectiveStyle. The orthographic + // camera returns "none" — but true `perspective: none` triggers a Chrome + // compositor fast path that mis-rasterizes border-triangle leaves. + // A very large finite value is visually orthographic but routes Chrome + // through the normal compositor path. + const perspStyle = camera.perspectiveStyle; + if (perspStyle === "none") { el.style.perspective = `${scaledCssPixels(1000000, layoutScale)}px`; } else { - if (Math.abs(layoutScale - 1) < 1e-6) { - el.style.removeProperty("perspective"); - } else { - el.style.perspective = `${scaledCssPixels(DEFAULT_SCENE_PERSPECTIVE, layoutScale)}px`; + // perspStyle is e.g. "8000px" — strip "px", scale, re-apply. + const px = parseFloat(perspStyle); + if (Number.isFinite(px)) { + el.style.perspective = `${scaledCssPixels(px, layoutScale)}px`; } } applyDynamicLightVars(el, opts); @@ -543,7 +557,7 @@ export function createPolyScene( // in the same frame there; the shadow projection works against 3D positions // that have already been through the axis swap, so it needs the light in // that same swapped frame. - function applyDynamicLightVars(el: HTMLElement, opts: PolySceneOptions): void { + function applyDynamicLightVars(el: HTMLElement, opts: Omit): void { const dynamic = opts.textureLighting === "dynamic"; el.dataset.polycssLighting = opts.textureLighting ?? "baked"; const vars = [ @@ -674,8 +688,8 @@ export function createPolyScene( function cameraCullRotation(entry: MeshEntry): CameraCullRotation { return { - rotX: currentOptions.rotX ?? DEFAULT_ROT_X, - rotY: currentOptions.rotY ?? DEFAULT_ROT_Y, + rotX: camera.state.rotX, + rotY: camera.state.rotY, meshRotation: entry.handle.transform.rotation, }; } @@ -1276,8 +1290,15 @@ export function createPolyScene( const css = buildMeshTransform(transform); if (css) wrapper.style.transform = css; - const preparePolygons = (polygons: Polygon[], merge: boolean): Polygon[] => - merge ? mergePolygons(polygons) : polygons; + // When meshResolution is explicitly set, run the full optimizer (which + // subsumes mergePolygons) with that resolution intent. Otherwise fall back + // to the plain merge pass for backward compatibility. + const preparePolygons = (polygons: Polygon[], merge: boolean): Polygon[] => { + if (transformIn.meshResolution !== undefined) { + return optimizeMeshPolygons(polygons, { meshResolution: transformIn.meshResolution }); + } + return merge ? mergePolygons(polygons) : polygons; + }; const sourcePolygons = preparePolygons(parseResult.polygons, mergeOnUpdate); // Pivot rotations around the mesh's polygon bbox center, not the @@ -1474,12 +1495,10 @@ export function createPolyScene( return handle; } - function setOptions(partial: Partial): void { + function setOptions(partial: Partial>): void { const prevAutoCenter = !!currentOptions.autoCenter; const prevStrategies = currentOptions.strategies; const prevTextureLighting = currentOptions.textureLighting; - const prevRotX = currentOptions.rotX ?? DEFAULT_ROT_X; - const prevRotY = currentOptions.rotY ?? DEFAULT_ROT_Y; currentOptions = { ...currentOptions, ...partial }; applySceneStyle(sceneEl, currentOptions); const nextAutoCenter = !!currentOptions.autoCenter; @@ -1498,12 +1517,6 @@ export function createPolyScene( if (strategiesChanged) { for (const entry of meshes) renderEntry(entry); } - const cameraRotationChanged = - (partial.rotX !== undefined && (currentOptions.rotX ?? DEFAULT_ROT_X) !== prevRotX) || - (partial.rotY !== undefined && (currentOptions.rotY ?? DEFAULT_ROT_Y) !== prevRotY); - if (!strategiesChanged && cameraRotationChanged) { - for (const entry of meshes) syncMountedRenderedForCameraChange(entry); - } if (prevAutoCenter !== nextAutoCenter) recomputeAutoCenter(); // When lighting mode changes, re-emit or clear shadow leaves on all meshes // that have castShadow set. Shadow emission is only valid in dynamic mode. @@ -1521,10 +1534,15 @@ export function createPolyScene( } } - function getOptions(): Readonly { + function getOptions(): Readonly> { return currentOptions; } + function applyCamera(): void { + applySceneStyle(sceneEl, currentOptions); + for (const entry of meshes) syncMountedRenderedForCameraChange(entry); + } + function listMeshes(): readonly PolyMeshHandle[] { const out: PolyMeshHandle[] = []; for (const entry of meshes) out.push(entry.handle); @@ -1555,5 +1573,5 @@ export function createPolyScene( if (sceneEl.parentNode) sceneEl.parentNode.removeChild(sceneEl); } - return { add, setOptions, destroy, host, getOptions, meshes: listMeshes, findMeshByElement }; + return { add, setOptions, destroy, host, camera, applyCamera, getOptions, meshes: listMeshes, findMeshByElement }; } diff --git a/packages/polycss/src/api/createPolyShapes.test.ts b/packages/polycss/src/api/createPolyShapes.test.ts new file mode 100644 index 00000000..e60a8c57 --- /dev/null +++ b/packages/polycss/src/api/createPolyShapes.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import { + createPolyBox, + createPolyPlane, + createPolyRing, + createPolyOctahedron, + createPolySphere, + createPolyTetrahedron, + createPolyIcosahedron, + createPolyDodecahedron, + createPolyCylinder, + createPolyCone, + createPolyTorus, +} from "./createPolyShapes"; + +describe("createPolyBox", () => { + it("returns 6 faces for a default box", () => { + expect(createPolyBox().polygons).toHaveLength(6); + }); + + it("forwards size to boxPolygons", () => { + const result = createPolyBox({ size: 100 }); + expect(result.polygons).toHaveLength(6); + expect(result.polygons[0].vertices[0][0]).toBeCloseTo(50); + }); +}); + +describe("createPolyPlane", () => { + it("returns 1 polygon for the XY plane", () => { + const result = createPolyPlane({ axis: 2, size: 10 }); + expect(result.polygons).toHaveLength(1); + expect(result.polygons[0].vertices).toHaveLength(4); + }); +}); + +describe("createPolyRing", () => { + it("returns segments polygons at default 32 segments", () => { + const result = createPolyRing({ axis: 2, radius: 50 }); + expect(result.polygons).toHaveLength(32); + }); + + it("respects custom segment count", () => { + const result = createPolyRing({ axis: 2, radius: 50, segments: 8 }); + expect(result.polygons).toHaveLength(8); + }); +}); + +describe("createPolyOctahedron", () => { + it("returns 8 triangular faces", () => { + const result = createPolyOctahedron({ center: [0, 0, 0], size: 50 }); + expect(result.polygons).toHaveLength(8); + expect(result.polygons[0].vertices).toHaveLength(3); + }); +}); + +describe("createPolySphere", () => { + it("returns 80 triangular faces at default subdivisions 1", () => { + expect(createPolySphere().polygons).toHaveLength(80); + }); +}); + +describe("createPolyTetrahedron", () => { + it("returns 4 triangular faces", () => { + expect(createPolyTetrahedron().polygons).toHaveLength(4); + }); + + it("forwards size", () => { + const result = createPolyTetrahedron({ size: 200 }); + expect(result.polygons).toHaveLength(4); + }); +}); + +describe("createPolyIcosahedron", () => { + it("returns 20 triangular faces", () => { + expect(createPolyIcosahedron().polygons).toHaveLength(20); + }); +}); + +describe("createPolyDodecahedron", () => { + it("returns 12 pentagonal faces", () => { + const result = createPolyDodecahedron(); + expect(result.polygons).toHaveLength(12); + expect(result.polygons[0].vertices).toHaveLength(5); + }); +}); + +describe("createPolyCylinder", () => { + it("returns 3 × radialSegments polygons at default 12 segments (12 sides + 12 bottom + 12 top)", () => { + // default: 12 sides + 12 bottom cap + 12 top cap = 36 + expect(createPolyCylinder().polygons).toHaveLength(36); + }); + + it("respects radialSegments", () => { + const result = createPolyCylinder({ radialSegments: 6 }); + // 6 sides + 6 bottom + 6 top = 18 + expect(result.polygons).toHaveLength(18); + }); +}); + +describe("createPolyCone", () => { + it("returns 2 × radialSegments polygons at default 12 segments (12 sides + 12 base, no top cap)", () => { + // default: 12 sides + 12 bottom cap = 24 (radiusTop=0 → no top cap) + expect(createPolyCone().polygons).toHaveLength(24); + }); +}); + +describe("createPolyTorus", () => { + it("returns radialSegments × tubularSegments quads at defaults (12 × 16 = 192)", () => { + expect(createPolyTorus().polygons).toHaveLength(192); + }); + + it("respects custom segment counts", () => { + const result = createPolyTorus({ radialSegments: 4, tubularSegments: 6 }); + expect(result.polygons).toHaveLength(24); + }); +}); diff --git a/packages/polycss/src/api/createPolyShapes.ts b/packages/polycss/src/api/createPolyShapes.ts new file mode 100644 index 00000000..5a9af298 --- /dev/null +++ b/packages/polycss/src/api/createPolyShapes.ts @@ -0,0 +1,84 @@ +/** + * Primitive shape factories — thin wrappers over the polygon generators in + * @layoutit/polycss-core. Each returns a `ParseResult`-compatible object so + * it composes directly with `scene.add(createPolyBox({ size: 100 }))`. + */ +import type { ParseResult, Polygon } from "@layoutit/polycss-core"; +import { + boxPolygons, + planePolygons, + ringPolygons, + octahedronPolygons, + spherePolygons, + tetrahedronPolygons, + icosahedronPolygons, + dodecahedronPolygons, + cylinderPolygons, + conePolygons, + torusPolygons, + type BoxPolygonsOptions, + type PlanePolygonsOptions, + type RingPolygonsOptions, + type OctahedronPolygonsOptions, + type SpherePolygonsOptions, + type TetrahedronPolygonsOptions, + type IcosahedronPolygonsOptions, + type DodecahedronPolygonsOptions, + type CylinderPolygonsOptions, + type ConePolygonsOptions, + type TorusPolygonsOptions, +} from "@layoutit/polycss-core"; + +export type { BoxPolygonsOptions, PlanePolygonsOptions, RingPolygonsOptions, OctahedronPolygonsOptions, SpherePolygonsOptions, TetrahedronPolygonsOptions, IcosahedronPolygonsOptions, DodecahedronPolygonsOptions, CylinderPolygonsOptions, ConePolygonsOptions, TorusPolygonsOptions }; + +// Re-export ParseResult so callers can type the return value without a +// separate import from polycss-core. +export type { ParseResult as PolyShapeResult }; + +function shapeResult(polygons: Polygon[]): ParseResult { + return { polygons, objectUrls: [], warnings: [], dispose: () => { /* no URLs to revoke */ } }; +} + +export function createPolyBox(options: BoxPolygonsOptions = {}): ParseResult { + return shapeResult(boxPolygons(options)); +} + +export function createPolyPlane(options: PlanePolygonsOptions): ParseResult { + return shapeResult(planePolygons(options)); +} + +export function createPolyRing(options: RingPolygonsOptions): ParseResult { + return shapeResult(ringPolygons(options)); +} + +export function createPolyOctahedron(options: OctahedronPolygonsOptions): ParseResult { + return shapeResult(octahedronPolygons(options)); +} + +export function createPolySphere(options: SpherePolygonsOptions = {}): ParseResult { + return shapeResult(spherePolygons(options)); +} + +export function createPolyTetrahedron(options: TetrahedronPolygonsOptions = {}): ParseResult { + return shapeResult(tetrahedronPolygons(options)); +} + +export function createPolyIcosahedron(options: IcosahedronPolygonsOptions = {}): ParseResult { + return shapeResult(icosahedronPolygons(options)); +} + +export function createPolyDodecahedron(options: DodecahedronPolygonsOptions = {}): ParseResult { + return shapeResult(dodecahedronPolygons(options)); +} + +export function createPolyCylinder(options: CylinderPolygonsOptions = {}): ParseResult { + return shapeResult(cylinderPolygons(options)); +} + +export function createPolyCone(options: ConePolygonsOptions = {}): ParseResult { + return shapeResult(conePolygons(options)); +} + +export function createPolyTorus(options: TorusPolygonsOptions = {}): ParseResult { + return shapeResult(torusPolygons(options)); +} diff --git a/packages/polycss/src/api/createSelect.test.ts b/packages/polycss/src/api/createSelect.test.ts index 798036fe..c121ea8a 100644 --- a/packages/polycss/src/api/createSelect.test.ts +++ b/packages/polycss/src/api/createSelect.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ParseResult, Polygon } from "@layoutit/polycss-core"; import { createPolyScene, type PolySceneHandle, type PolyMeshHandle } from "./createPolyScene"; import { createSelect, type PolySelectionHandle } from "./createSelect"; +import { createPolyOrthographicCamera } from "./createPolyCamera"; const TRIANGLE: Polygon = { vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], @@ -30,7 +31,7 @@ describe("createSelect", () => { beforeEach(() => { host = document.createElement("div"); document.body.appendChild(host); - scene = createPolyScene(host); + scene = createPolyScene(host, { camera: createPolyOrthographicCamera() }); }); afterEach(() => { sel?.destroy(); diff --git a/packages/polycss/src/api/createTransformControls.test.ts b/packages/polycss/src/api/createTransformControls.test.ts index cdf110a1..889ea312 100644 --- a/packages/polycss/src/api/createTransformControls.test.ts +++ b/packages/polycss/src/api/createTransformControls.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ParseResult, Polygon } from "@layoutit/polycss-core"; import { createPolyScene, type PolySceneHandle } from "./createPolyScene"; import { createTransformControls } from "./createTransformControls"; +import { createPolyOrthographicCamera } from "./createPolyCamera"; const TRIANGLE: Polygon = { vertices: [[0, 0, 0], [1, 0, 0], [0, 1, 0]], @@ -110,7 +111,7 @@ describe("createTransformControls", () => { beforeEach(() => { host = document.createElement("div"); document.body.appendChild(host); - scene = createPolyScene(host); + scene = createPolyScene(host, { camera: createPolyOrthographicCamera() }); }); afterEach(() => { diff --git a/packages/polycss/src/api/createTransformControls.ts b/packages/polycss/src/api/createTransformControls.ts index 7b168539..ed5fc548 100644 --- a/packages/polycss/src/api/createTransformControls.ts +++ b/packages/polycss/src/api/createTransformControls.ts @@ -570,12 +570,12 @@ export function createTransformControls( // Strip the shaft for back-facing arrows so the visible-only-from- // outside silhouette stays clean. Both halves of a pair otherwise // share the same shaft volume at the gizmo origin. - const sceneOpts = scene.getOptions(); + const cameraState = scene.camera.state; const backFacing = isAxisBackFacing( spec.cssAxis, spec.sign, - sceneOpts.rotX ?? 65, - sceneOpts.rotY ?? 45, + cameraState.rotX ?? 65, + cameraState.rotY ?? 45, ); return arrowPolygons({ axis: WORLD_AXIS_FOR_CSS[spec.cssAxis], @@ -594,9 +594,9 @@ export function createTransformControls( // works in WORLD axes (a = (perp+1)%3, b = (perp+2)%3); since // WORLD_AXIS_FOR_CSS is involutive, the CSS axis we test for back- // facing is just WORLD_AXIS_FOR_CSS[worldA / worldB]. - const sceneOpts = scene.getOptions(); - const rotX = sceneOpts.rotX ?? 65; - const rotY = sceneOpts.rotY ?? 45; + const cameraState = scene.camera.state; + const rotX = cameraState.rotX ?? 65; + const rotY = cameraState.rotY ?? 45; const worldPerp = WORLD_AXIS_FOR_CSS[spec.perpAxis]; const worldA = ((worldPerp + 1) % 3) as 0 | 1 | 2; const worldB = ((worldPerp + 2) % 3) as 0 | 1 | 2; diff --git a/packages/polycss/src/elements/PolyCameraAlias.test.ts b/packages/polycss/src/elements/PolyCameraAlias.test.ts new file mode 100644 index 00000000..bcbdaf58 --- /dev/null +++ b/packages/polycss/src/elements/PolyCameraAlias.test.ts @@ -0,0 +1,61 @@ +/** + * Tests for the alias pointing to PolyCameraElement + * (which extends PolyOrthographicCameraElement). + * + * Covers: registration, instantiation, camera handle type, and perspectiveStyle. + */ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { PolyCameraElement } from "./PolyCameraElement"; +import { PolyOrthographicCameraElement } from "./PolyOrthographicCameraElement"; + +beforeAll(() => { + if (!customElements.get("poly-camera")) { + customElements.define("poly-camera", PolyCameraElement); + } +}); + +describe(" alias", () => { + let host: HTMLElement; + + beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + }); + + afterEach(() => { + if (host.parentNode) host.parentNode.removeChild(host); + }); + + it("is registered as ", () => { + expect(customElements.get("poly-camera")).toBeDefined(); + }); + + it(" resolves to PolyCameraElement", () => { + expect(customElements.get("poly-camera")).toBe(PolyCameraElement); + }); + + it("PolyCameraElement extends PolyOrthographicCameraElement", () => { + const el = document.createElement("poly-camera"); + expect(el).toBeInstanceOf(PolyOrthographicCameraElement); + }); + + it("instantiation produces a PolyCameraElement", () => { + const el = document.createElement("poly-camera"); + expect(el).toBeInstanceOf(PolyCameraElement); + }); + + it("camera handle has type 'orthographic' after connect", () => { + const el = document.createElement("poly-camera") as PolyCameraElement; + host.appendChild(el); + const cam = el.getCamera(); + expect(cam).not.toBeNull(); + expect(cam?.type).toBe("orthographic"); + }); + + it("camera handle has perspectiveStyle 'none'", () => { + const el = document.createElement("poly-camera") as PolyCameraElement; + host.appendChild(el); + const cam = el.getCamera(); + expect(cam?.perspectiveStyle).toBe("none"); + }); +}); diff --git a/packages/polycss/src/elements/PolyCameraElement.ts b/packages/polycss/src/elements/PolyCameraElement.ts new file mode 100644 index 00000000..cdd27c2d --- /dev/null +++ b/packages/polycss/src/elements/PolyCameraElement.ts @@ -0,0 +1,12 @@ +/** + * — ergonomic alias for . + * + * The default camera in polycss is orthographic. `` maps to + * `PolyOrthographicCameraElement` so the canonical tree shape works without + * spelling out "orthographic" when it isn't relevant to the scene being built. + * + * Use when depth foreshortening is needed. + */ +import { PolyOrthographicCameraElement } from "./PolyOrthographicCameraElement"; + +export class PolyCameraElement extends PolyOrthographicCameraElement {} diff --git a/packages/polycss/src/elements/PolyFirstPersonControlsElement.test.ts b/packages/polycss/src/elements/PolyFirstPersonControlsElement.test.ts new file mode 100644 index 00000000..6595c3e6 --- /dev/null +++ b/packages/polycss/src/elements/PolyFirstPersonControlsElement.test.ts @@ -0,0 +1,127 @@ +/** + * element tests. + * + * Covers: registration, attribute declaration, attachment to parent scene, + * attribute reflection via _readOptions, and disconnect cleanup. + */ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { PolySceneElement } from "./PolySceneElement"; +import { PolyFirstPersonControlsElement } from "./PolyFirstPersonControlsElement"; + +beforeAll(() => { + if (!customElements.get("poly-scene")) { + customElements.define("poly-scene", PolySceneElement); + } + if (!customElements.get("poly-first-person-controls")) { + customElements.define("poly-first-person-controls", PolyFirstPersonControlsElement); + } +}); + +let rafQueue: Array<(now: number) => void> = []; +let rafId = 0; + +function installManualRaf(): void { + rafQueue = []; + rafId = 0; + vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => { + rafQueue.push(cb); + return ++rafId; + }); + vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => { + rafQueue = []; + }); +} + +describe("PolyFirstPersonControlsElement", () => { + let host: HTMLElement; + + beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + installManualRaf(); + }); + + afterEach(() => { + if (host.parentNode) host.parentNode.removeChild(host); + vi.restoreAllMocks(); + }); + + // ── Registration ────────────────────────────────────────────────────────── + it("is registered as ", () => { + expect(customElements.get("poly-first-person-controls")).toBe(PolyFirstPersonControlsElement); + }); + + it("declares the documented observed attributes", () => { + const observed = PolyFirstPersonControlsElement.observedAttributes; + expect(observed).toContain("enabled"); + expect(observed).toContain("look-enabled"); + expect(observed).toContain("move-enabled"); + expect(observed).toContain("jump-enabled"); + expect(observed).toContain("crouch-enabled"); + expect(observed).toContain("look-sensitivity"); + expect(observed).toContain("invert-y"); + expect(observed).toContain("move-speed"); + expect(observed).toContain("jump-velocity"); + expect(observed).toContain("gravity"); + expect(observed).toContain("eye-height"); + expect(observed).toContain("crouch-height"); + expect(observed).toContain("ground-z"); + expect(observed).toContain("min-pitch"); + expect(observed).toContain("max-pitch"); + }); + + // ── Attachment ──────────────────────────────────────────────────────────── + it("creates controls when nested inside ", () => { + const sceneEl = document.createElement("poly-scene") as PolySceneElement; + const controlsEl = document.createElement("poly-first-person-controls") as PolyFirstPersonControlsElement; + sceneEl.appendChild(controlsEl); + host.appendChild(sceneEl); + // FPV starts the rAF movement loop on attach + expect(rafQueue.length).toBeGreaterThan(0); + }); + + it("no-ops when inserted with no parent ", () => { + const orphan = document.createElement("poly-first-person-controls") as PolyFirstPersonControlsElement; + host.appendChild(orphan); + expect(rafQueue.length).toBe(0); + }); + + // ── Attribute reflection ────────────────────────────────────────────────── + it("move-speed attribute round-trips", () => { + const sceneEl = document.createElement("poly-scene") as PolySceneElement; + const controlsEl = document.createElement("poly-first-person-controls") as PolyFirstPersonControlsElement; + controlsEl.setAttribute("move-speed", "12"); + sceneEl.appendChild(controlsEl); + host.appendChild(sceneEl); + expect(controlsEl.getAttribute("move-speed")).toBe("12"); + }); + + it("eye-height attribute round-trips", () => { + const sceneEl = document.createElement("poly-scene") as PolySceneElement; + const controlsEl = document.createElement("poly-first-person-controls") as PolyFirstPersonControlsElement; + controlsEl.setAttribute("eye-height", "2.0"); + sceneEl.appendChild(controlsEl); + host.appendChild(sceneEl); + expect(controlsEl.getAttribute("eye-height")).toBe("2.0"); + }); + + it("look-sensitivity attribute round-trips", () => { + const sceneEl = document.createElement("poly-scene") as PolySceneElement; + const controlsEl = document.createElement("poly-first-person-controls") as PolyFirstPersonControlsElement; + controlsEl.setAttribute("look-sensitivity", "0.3"); + sceneEl.appendChild(controlsEl); + host.appendChild(sceneEl); + expect(controlsEl.getAttribute("look-sensitivity")).toBe("0.3"); + }); + + // ── Disconnect ──────────────────────────────────────────────────────────── + it("disconnectedCallback destroys controls and stops the rAF loop", () => { + const sceneEl = document.createElement("poly-scene") as PolySceneElement; + const controlsEl = document.createElement("poly-first-person-controls") as PolyFirstPersonControlsElement; + sceneEl.appendChild(controlsEl); + host.appendChild(sceneEl); + expect(rafQueue.length).toBeGreaterThan(0); + sceneEl.removeChild(controlsEl); + expect(rafQueue.length).toBe(0); + }); +}); diff --git a/packages/polycss/src/elements/PolyFirstPersonControlsElement.ts b/packages/polycss/src/elements/PolyFirstPersonControlsElement.ts new file mode 100644 index 00000000..2e8f0a2d --- /dev/null +++ b/packages/polycss/src/elements/PolyFirstPersonControlsElement.ts @@ -0,0 +1,150 @@ +/** + * — declarative wrapper around `createPolyFirstPersonControls`. + * + * Behavior element: no rendered output. Sits inside and walks + * up via parent nodes to attach itself to the parent scene. + * The full options surface from createPolyFirstPersonControls is exposed via + * kebab-case attributes: + * + * + */ +import { PolySceneElement } from "./PolySceneElement"; +import { + createPolyFirstPersonControls, + type PolyFirstPersonControlsHandle, + type PolyFirstPersonControlsOptions, +} from "../api/createPolyFirstPersonControls"; +import { parseNumber, parseBoolAttr } from "./parseAttr"; + +const ELEMENT_BASE: typeof HTMLElement = + typeof HTMLElement !== "undefined" + ? HTMLElement + : (class {} as unknown as typeof HTMLElement); + +const OBSERVED_ATTRS = [ + "enabled", + "look-enabled", + "move-enabled", + "jump-enabled", + "crouch-enabled", + "look-sensitivity", + "invert-y", + "move-speed", + "jump-velocity", + "gravity", + "eye-height", + "crouch-height", + "ground-z", + "min-pitch", + "max-pitch", +] as const; + + +export class PolyFirstPersonControlsElement extends ELEMENT_BASE { + static get observedAttributes(): string[] { + return [...OBSERVED_ATTRS]; + } + + private _controls: PolyFirstPersonControlsHandle | null = null; + + private _readOptions(): PolyFirstPersonControlsOptions { + const opts: PolyFirstPersonControlsOptions = {}; + const enabled = parseBoolAttr(this.getAttribute("enabled")); + if (enabled !== undefined) opts.enabled = enabled; + const lookEnabled = parseBoolAttr(this.getAttribute("look-enabled")); + if (lookEnabled !== undefined) opts.lookEnabled = lookEnabled; + const moveEnabled = parseBoolAttr(this.getAttribute("move-enabled")); + if (moveEnabled !== undefined) opts.moveEnabled = moveEnabled; + const jumpEnabled = parseBoolAttr(this.getAttribute("jump-enabled")); + if (jumpEnabled !== undefined) opts.jumpEnabled = jumpEnabled; + const crouchEnabled = parseBoolAttr(this.getAttribute("crouch-enabled")); + if (crouchEnabled !== undefined) opts.crouchEnabled = crouchEnabled; + const lookSensitivity = parseNumber(this.getAttribute("look-sensitivity")); + if (lookSensitivity !== undefined) opts.lookSensitivity = lookSensitivity; + // invert-y is a boolean presence attribute + if (this.hasAttribute("invert-y")) { + const invertY = parseBoolAttr(this.getAttribute("invert-y")); + if (invertY !== undefined) opts.invertY = invertY; + } + const moveSpeed = parseNumber(this.getAttribute("move-speed")); + if (moveSpeed !== undefined) opts.moveSpeed = moveSpeed; + const jumpVelocity = parseNumber(this.getAttribute("jump-velocity")); + if (jumpVelocity !== undefined) opts.jumpVelocity = jumpVelocity; + const gravity = parseNumber(this.getAttribute("gravity")); + if (gravity !== undefined) opts.gravity = gravity; + const eyeHeight = parseNumber(this.getAttribute("eye-height")); + if (eyeHeight !== undefined) opts.eyeHeight = eyeHeight; + const crouchHeight = parseNumber(this.getAttribute("crouch-height")); + if (crouchHeight !== undefined) opts.crouchHeight = crouchHeight; + const groundZ = parseNumber(this.getAttribute("ground-z")); + if (groundZ !== undefined) opts.groundZ = groundZ; + const minPitch = parseNumber(this.getAttribute("min-pitch")); + if (minPitch !== undefined) opts.minPitch = minPitch; + const maxPitch = parseNumber(this.getAttribute("max-pitch")); + if (maxPitch !== undefined) opts.maxPitch = maxPitch; + return opts; + } + + private _findScene(): PolySceneElement | null { + let node: Node | null = this.parentNode; + while (node) { + if (node instanceof PolySceneElement) return node; + node = node.parentNode; + } + return null; + } + + private _attach(): void { + if (this._controls) return; + const sceneEl = this._findScene(); + const handle = sceneEl?.getScene(); + if (!handle) { + if (sceneEl) { + const onReady = (): void => { + sceneEl.removeEventListener("polycss:scene-ready", onReady); + this._attach(); + }; + sceneEl.addEventListener("polycss:scene-ready", onReady); + } + return; + } + this._controls = createPolyFirstPersonControls(handle, this._readOptions()); + } + + connectedCallback(): void { + this._attach(); + } + + disconnectedCallback(): void { + if (this._controls) { + this._controls.destroy(); + this._controls = null; + } + } + + attributeChangedCallback( + _name: string, + oldValue: string | null, + newValue: string | null, + ): void { + if (oldValue === newValue) return; + if (!this._controls) return; + this._controls.update(this._readOptions()); + } +} diff --git a/packages/polycss/src/elements/PolyMeshElement.test.ts b/packages/polycss/src/elements/PolyMeshElement.test.ts index b847dd50..226ac787 100644 --- a/packages/polycss/src/elements/PolyMeshElement.test.ts +++ b/packages/polycss/src/elements/PolyMeshElement.test.ts @@ -69,6 +69,37 @@ describe("PolyMeshElement", () => { expect(observed).toContain("scale"); expect(observed).toContain("rotation"); expect(observed).toContain("auto-center"); + expect(observed).toContain("mesh-resolution"); + }); + }); + + describe("mesh-resolution attribute", () => { + it("loads successfully with mesh-resolution='lossless'", async () => { + globalThis.fetch = mockFetch(TRIANGLE_OBJ); + const scene = document.createElement("poly-scene") as PolySceneElement; + const mesh = document.createElement("poly-mesh") as PolyMeshElement; + mesh.setAttribute("src", "tri.obj"); + mesh.setAttribute("mesh-resolution", "lossless"); + scene.appendChild(mesh); + host.appendChild(scene); + + await vi.waitFor(() => { + expect(scene.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); + }); + }); + + it("loads successfully with mesh-resolution='lossy' (explicit default)", async () => { + globalThis.fetch = mockFetch(TRIANGLE_OBJ); + const scene = document.createElement("poly-scene") as PolySceneElement; + const mesh = document.createElement("poly-mesh") as PolyMeshElement; + mesh.setAttribute("src", "tri.obj"); + mesh.setAttribute("mesh-resolution", "lossy"); + scene.appendChild(mesh); + host.appendChild(scene); + + await vi.waitFor(() => { + expect(scene.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); + }); }); }); diff --git a/packages/polycss/src/elements/PolyMeshElement.ts b/packages/polycss/src/elements/PolyMeshElement.ts index 4aa3e40e..ad94abc7 100644 --- a/packages/polycss/src/elements/PolyMeshElement.ts +++ b/packages/polycss/src/elements/PolyMeshElement.ts @@ -10,7 +10,7 @@ * `auto-center` is currently honored by recentering vertices in-place before * registering; this matches `` semantics. */ -import type { ParseResult, Polygon, Vec3 } from "@layoutit/polycss-core"; +import type { MeshResolution, ParseResult, Polygon, Vec3 } from "@layoutit/polycss-core"; import { computeSceneBbox, loadMesh } from "@layoutit/polycss-core"; import type { PolyMeshHandle } from "../api/createPolyScene"; import type { PolySceneElement } from "./PolySceneElement"; @@ -23,6 +23,7 @@ const ELEMENT_BASE: typeof HTMLElement = const OBSERVED_ATTRS = [ "src", "mtl", + "mesh-resolution", "position", "scale", "rotation", @@ -105,7 +106,7 @@ export class PolyMeshElement extends ELEMENT_BASE { newValue: string | null, ): void { if (oldValue === newValue) return; - if (name === "src" || name === "mtl") { + if (name === "src" || name === "mtl" || name === "mesh-resolution") { this._tearDown(); this._maybeLoad(); return; @@ -137,9 +138,15 @@ export class PolyMeshElement extends ELEMENT_BASE { const token = ++this._loadToken; const mtl = this.getAttribute("mtl") || undefined; + const meshResolutionAttr = this.getAttribute("mesh-resolution"); + const meshResolution: MeshResolution | undefined = + meshResolutionAttr === "lossless" ? "lossless" : undefined; let parsed: ParseResult; try { - parsed = await loadMesh(src, mtl ? { mtlUrl: mtl } : undefined); + const loadOpts = (mtl || meshResolution !== undefined) + ? { ...(mtl ? { mtlUrl: mtl } : {}), ...(meshResolution !== undefined ? { meshResolution } : {}) } + : undefined; + parsed = await loadMesh(src, loadOpts); } catch (err) { this.dispatchEvent( new CustomEvent("polycss:error", { detail: err, bubbles: true }), diff --git a/packages/polycss/src/elements/PolyNewElements.test.ts b/packages/polycss/src/elements/PolyNewElements.test.ts index fc0f2437..55ecd1fb 100644 --- a/packages/polycss/src/elements/PolyNewElements.test.ts +++ b/packages/polycss/src/elements/PolyNewElements.test.ts @@ -109,12 +109,12 @@ describe("PolyMapControlsElement", () => { sceneEl.appendChild(controlsEl); host.appendChild(sceneEl); const scene = sceneEl.getScene()!; - const beforeRotY = scene.getOptions().rotY; + const beforeRotY = scene.camera.state.rotY; sceneEl.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, isPrimary: true, clientX: 100, clientY: 100 })); sceneEl.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, isPrimary: true, clientX: 200, clientY: 100 })); sceneEl.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1, isPrimary: true, clientX: 200, clientY: 100 })); // Pan: rotY should be unchanged - expect(scene.getOptions().rotY).toBe(beforeRotY); + expect(scene.camera.state.rotY).toBe(beforeRotY); }); it("dolly attribute enables dolly mode (wheel changes distance not zoom)", () => { @@ -124,11 +124,11 @@ describe("PolyMapControlsElement", () => { sceneEl.appendChild(controlsEl); host.appendChild(sceneEl); const scene = sceneEl.getScene()!; - const beforeZoom = scene.getOptions().zoom; + const beforeZoom = scene.camera.state.zoom; sceneEl.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 200 })); // In dolly mode zoom must remain unchanged - expect(scene.getOptions().zoom).toBe(beforeZoom); - expect(scene.getOptions().distance ?? 0).toBeGreaterThan(0); + expect(scene.camera.state.zoom).toBe(beforeZoom); + expect(scene.camera.state.distance ?? 0).toBeGreaterThan(0); }); it("min-distance and max-distance attributes are passed to controls", () => { @@ -144,7 +144,7 @@ describe("PolyMapControlsElement", () => { for (let i = 0; i < 20; i++) { sceneEl.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 1000 })); } - const distance = scene.getOptions().distance ?? 0; + const distance = scene.camera.state.distance ?? 0; expect(distance).toBeLessThanOrEqual(10); expect(distance).toBeGreaterThanOrEqual(5); }); diff --git a/packages/polycss/src/elements/PolyOrbitControlsElement.test.ts b/packages/polycss/src/elements/PolyOrbitControlsElement.test.ts index 0fecb26d..29cac0e0 100644 --- a/packages/polycss/src/elements/PolyOrbitControlsElement.test.ts +++ b/packages/polycss/src/elements/PolyOrbitControlsElement.test.ts @@ -122,7 +122,7 @@ describe("PolyOrbitControlsElement", () => { for (let i = 0; i < 20; i++) { sceneEl.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1000 })); } - expect(scene.getOptions().zoom).toBe(2); + expect(scene.camera.state.zoom).toBe(2); }); // ── Live attribute changes ──────────────────────────────────────────────── @@ -155,11 +155,11 @@ describe("PolyOrbitControlsElement", () => { sceneEl.appendChild(controlsEl); host.appendChild(sceneEl); const scene = sceneEl.getScene()!; - const beforeZoom = scene.getOptions().zoom; + const beforeZoom = scene.camera.state.zoom; sceneEl.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 200 })); // In dolly mode zoom must remain unchanged - expect(scene.getOptions().zoom).toBe(beforeZoom); - expect(scene.getOptions().distance ?? 0).toBeGreaterThan(0); + expect(scene.camera.state.zoom).toBe(beforeZoom); + expect(scene.camera.state.distance ?? 0).toBeGreaterThan(0); }); it("max-distance attribute clamps the dolly distance", () => { @@ -173,7 +173,7 @@ describe("PolyOrbitControlsElement", () => { for (let i = 0; i < 20; i++) { sceneEl.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 1000 })); } - expect(scene.getOptions().distance ?? 0).toBeLessThanOrEqual(10); + expect(scene.camera.state.distance ?? 0).toBeLessThanOrEqual(10); }); // ── Disconnect ──────────────────────────────────────────────────────────── diff --git a/packages/polycss/src/elements/PolySceneElement.test.ts b/packages/polycss/src/elements/PolySceneElement.test.ts index f489df9b..1fa112e1 100644 --- a/packages/polycss/src/elements/PolySceneElement.test.ts +++ b/packages/polycss/src/elements/PolySceneElement.test.ts @@ -125,12 +125,13 @@ describe("PolySceneElement", () => { expect(sceneEl.style.transform).toContain("scale(1.5)"); }); - it("ignores invalid number attribute values", () => { + it("ignores invalid perspective value and falls back to orthographic stand-in", () => { const el = document.createElement("poly-scene") as PolySceneElement; el.setAttribute("perspective", "not-a-number"); host.appendChild(el); const sceneEl = el.querySelector(".polycss-scene") as HTMLElement; - expect(sceneEl.style.perspective).toBe(""); + // Invalid perspective → implicit orthographic camera → 1000000px stand-in + expect(sceneEl.style.perspective).toBe("1000000px"); }); it("parses directional + ambient light attributes independently", () => { diff --git a/packages/polycss/src/elements/PolySceneElement.ts b/packages/polycss/src/elements/PolySceneElement.ts index 25637c4a..8eece21f 100644 --- a/packages/polycss/src/elements/PolySceneElement.ts +++ b/packages/polycss/src/elements/PolySceneElement.ts @@ -6,6 +6,13 @@ * to find this element via `closest("poly-scene")` and call its `getScene()` * to register themselves. * + * Camera sourcing: first walks up the DOM tree looking for an + * ancestor that has a `getCamera()` method (a or + * element). If found, that camera is used. If no + * ancestor camera is found, an implicit orthographic camera is created from + * the scene element's own camera attributes (perspective, rot-x, rot-y, zoom, + * distance, target) for backward compatibility. + * * Attribute parsing — minimal-footprint string → typed conversion. Unknown * attributes are ignored (HTML semantics, not validation). */ @@ -15,6 +22,12 @@ import { type PolySceneOptions, type PolySceneHandle, } from "../api/createPolyScene"; +import { + createPolyOrthographicCamera, + createPolyPerspectiveCamera, + type PolyOrthographicCameraHandle, + type PolyPerspectiveCameraHandle, +} from "../api/createPolyCamera"; const ELEMENT_BASE: typeof HTMLElement = typeof HTMLElement !== "undefined" @@ -22,10 +35,12 @@ const ELEMENT_BASE: typeof HTMLElement = : (class {} as unknown as typeof HTMLElement); const OBSERVED_ATTRS = [ + // Camera attrs (used when no ancestor camera element is present) "perspective", "rot-x", "rot-y", "zoom", + // Scene-level attrs "directional-direction", "directional-color", "directional-intensity", @@ -66,12 +81,21 @@ function parseTextureQuality(value: string | null): PolySceneOptions["textureQua return parseNumber(value); } +type CameraElement = { + getCamera(): PolyPerspectiveCameraHandle | PolyOrthographicCameraHandle | null; +}; + +function isCameraElement(el: Element): el is Element & CameraElement { + return typeof (el as unknown as CameraElement).getCamera === "function"; +} + export class PolySceneElement extends ELEMENT_BASE { static get observedAttributes(): string[] { return [...OBSERVED_ATTRS]; } private _scene: PolySceneHandle | null = null; + private _implicitCamera: PolyPerspectiveCameraHandle | PolyOrthographicCameraHandle | null = null; /** * Returns the underlying PolySceneHandle. Children call this during their own @@ -81,18 +105,48 @@ export class PolySceneElement extends ELEMENT_BASE { return this._scene; } - private _readOptions(): PolySceneOptions { - const directionalLight = this._readDirectionalLight(); - const ambientLight = this._readAmbientLight(); - const opts: PolySceneOptions = {}; + private _findAncestorCamera(): (PolyPerspectiveCameraHandle | PolyOrthographicCameraHandle) | null { + let current: Element | null = this.parentElement; + while (current) { + if (isCameraElement(current)) { + const cam = current.getCamera(); + if (cam) return cam; + } + current = current.parentElement; + } + return null; + } + + private _buildImplicitCamera(): PolyPerspectiveCameraHandle | PolyOrthographicCameraHandle { const perspective = parsePerspective(this.getAttribute("perspective")); - if (perspective !== undefined) opts.perspective = perspective; const rotX = parseNumber(this.getAttribute("rot-x")); - if (rotX !== undefined) opts.rotX = rotX; const rotY = parseNumber(this.getAttribute("rot-y")); - if (rotY !== undefined) opts.rotY = rotY; const zoom = parseNumber(this.getAttribute("zoom")); - if (zoom !== undefined) opts.zoom = zoom; + const distance = parseNumber(this.getAttribute("distance")); + const target = parseVec3(this.getAttribute("target")); + const opts = { + ...(rotX !== undefined ? { rotX } : {}), + ...(rotY !== undefined ? { rotY } : {}), + ...(zoom !== undefined ? { zoom } : {}), + ...(distance !== undefined ? { distance } : {}), + ...(target !== undefined ? { target } : {}), + }; + if (perspective === false) { + // `perspective="false"` → orthographic + return createPolyOrthographicCamera(opts); + } + if (perspective !== undefined) { + // Explicit numeric perspective → perspective camera + return createPolyPerspectiveCamera({ ...opts, perspective }); + } + // No perspective attribute → default orthographic + return createPolyOrthographicCamera(opts); + } + + private _readNonCameraOptions(): Omit { + const directionalLight = this._readDirectionalLight(); + const ambientLight = this._readAmbientLight(); + const opts: Omit = {}; opts.textureLighting = parseTextureLighting(this.getAttribute("texture-lighting")) ?? "baked"; const textureQuality = parseTextureQuality(this.getAttribute("texture-quality")); if (textureQuality !== undefined) opts.textureQuality = textureQuality; @@ -127,7 +181,17 @@ export class PolySceneElement extends ELEMENT_BASE { connectedCallback(): void { if (this._scene) return; - this._scene = createPolyScene(this, this._readOptions()); + const ancestorCamera = this._findAncestorCamera(); + let camera: PolyPerspectiveCameraHandle | PolyOrthographicCameraHandle; + if (ancestorCamera) { + camera = ancestorCamera; + this._implicitCamera = null; + } else { + // No ancestor camera — create an implicit one from our own attributes. + this._implicitCamera = this._buildImplicitCamera(); + camera = this._implicitCamera; + } + this._scene = createPolyScene(this, { camera, ...this._readNonCameraOptions() }); // Notify any descendant / elements that the // scene is ready. They listen for this on connect via a custom event so // their own connectedCallback (which may fire BEFORE the scene's, when @@ -142,15 +206,37 @@ export class PolySceneElement extends ELEMENT_BASE { this._scene.destroy(); this._scene = null; } + this._implicitCamera = null; } attributeChangedCallback( - _name: string, + name: string, oldValue: string | null, newValue: string | null, ): void { if (oldValue === newValue) return; if (!this._scene) return; - this._scene.setOptions(this._readOptions()); + + // If we own an implicit camera, update it when camera-related attrs change. + if (this._implicitCamera) { + const cameraAttrs = ["rot-x", "rot-y", "zoom", "distance", "target"]; + if (cameraAttrs.includes(name)) { + const rotX = parseNumber(this.getAttribute("rot-x")); + const rotY = parseNumber(this.getAttribute("rot-y")); + const zoom = parseNumber(this.getAttribute("zoom")); + const distance = parseNumber(this.getAttribute("distance")); + const target = parseVec3(this.getAttribute("target")); + if (rotX !== undefined) this._implicitCamera.update({ rotX }); + if (rotY !== undefined) this._implicitCamera.update({ rotY }); + if (zoom !== undefined) this._implicitCamera.update({ zoom }); + if (distance !== undefined) this._implicitCamera.update({ distance }); + if (target !== undefined) this._implicitCamera.update({ target }); + this._scene.applyCamera(); + return; + } + } + + // Non-camera attrs → update scene options. + this._scene.setOptions(this._readNonCameraOptions()); } } diff --git a/packages/polycss/src/elements/PolyShapeElements.test.ts b/packages/polycss/src/elements/PolyShapeElements.test.ts new file mode 100644 index 00000000..ce5a8137 --- /dev/null +++ b/packages/polycss/src/elements/PolyShapeElements.test.ts @@ -0,0 +1,190 @@ +/** + * Tests for primitive shape custom elements — verify registration and that + * mounting inside a produces leaf DOM nodes. + */ +import { beforeAll, beforeEach, afterEach, describe, expect, it } from "vitest"; +import { PolySceneElement } from "./PolySceneElement"; +import { + PolyBoxElement, + PolyPlaneElement, + PolyRingElement, + PolyOctahedronElement, + PolySphereElement, + PolyTetrahedronElement, + PolyIcosahedronElement, + PolyDodecahedronElement, + PolyCylinderElement, + PolyConeElement, + PolyTorusElement, +} from "./PolyShapeElements"; + +beforeAll(() => { + if (!customElements.get("poly-scene")) { + customElements.define("poly-scene", PolySceneElement); + } + if (!customElements.get("poly-box")) { + customElements.define("poly-box", PolyBoxElement); + } + if (!customElements.get("poly-plane")) { + customElements.define("poly-plane", PolyPlaneElement); + } + if (!customElements.get("poly-ring")) { + customElements.define("poly-ring", PolyRingElement); + } + if (!customElements.get("poly-octahedron")) { + customElements.define("poly-octahedron", PolyOctahedronElement); + } + if (!customElements.get("poly-tetrahedron")) { + customElements.define("poly-tetrahedron", PolyTetrahedronElement); + } + if (!customElements.get("poly-sphere")) { + customElements.define("poly-sphere", PolySphereElement); + } + if (!customElements.get("poly-icosahedron")) { + customElements.define("poly-icosahedron", PolyIcosahedronElement); + } + if (!customElements.get("poly-dodecahedron")) { + customElements.define("poly-dodecahedron", PolyDodecahedronElement); + } + if (!customElements.get("poly-cylinder")) { + customElements.define("poly-cylinder", PolyCylinderElement); + } + if (!customElements.get("poly-cone")) { + customElements.define("poly-cone", PolyConeElement); + } + if (!customElements.get("poly-torus")) { + customElements.define("poly-torus", PolyTorusElement); + } +}); + +let host: HTMLElement; + +beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); +}); + +afterEach(() => { + if (host.parentNode) host.parentNode.removeChild(host); +}); + +function mountInScene(tag: string, attrs: Record = {}): PolySceneElement { + // Append scene to host FIRST so its connectedCallback fires before children. + const scene = document.createElement("poly-scene") as PolySceneElement; + host.appendChild(scene); + // Then add the shape element — it finds a ready scene via getScene(). + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + el.setAttribute(k, v); + } + scene.appendChild(el); + return scene; +} + +describe("registration", () => { + it("registers poly-box", () => { + expect(customElements.get("poly-box")).toBe(PolyBoxElement); + }); + it("registers poly-plane", () => { + expect(customElements.get("poly-plane")).toBe(PolyPlaneElement); + }); + it("registers poly-ring", () => { + expect(customElements.get("poly-ring")).toBe(PolyRingElement); + }); + it("registers poly-octahedron", () => { + expect(customElements.get("poly-octahedron")).toBe(PolyOctahedronElement); + }); + it("registers poly-tetrahedron", () => { + expect(customElements.get("poly-tetrahedron")).toBe(PolyTetrahedronElement); + }); + it("registers poly-sphere", () => { + expect(customElements.get("poly-sphere")).toBe(PolySphereElement); + }); + it("registers poly-icosahedron", () => { + expect(customElements.get("poly-icosahedron")).toBe(PolyIcosahedronElement); + }); + it("registers poly-dodecahedron", () => { + expect(customElements.get("poly-dodecahedron")).toBe(PolyDodecahedronElement); + }); + it("registers poly-cylinder", () => { + expect(customElements.get("poly-cylinder")).toBe(PolyCylinderElement); + }); + it("registers poly-cone", () => { + expect(customElements.get("poly-cone")).toBe(PolyConeElement); + }); + it("registers poly-torus", () => { + expect(customElements.get("poly-torus")).toBe(PolyTorusElement); + }); +}); + +describe("leaf DOM production", () => { + it("poly-box produces leaf nodes inside poly-scene", () => { + const scene = mountInScene("poly-box", { size: "100", color: "#ff6644" }); + expect(scene.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); + }); + + it("poly-plane produces leaf nodes inside poly-scene", () => { + const scene = mountInScene("poly-plane", { axis: "2", size: "50", color: "#cccccc" }); + expect(scene.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); + }); + + it("poly-ring produces leaf nodes inside poly-scene", () => { + const scene = mountInScene("poly-ring", { axis: "2", radius: "50", segments: "8" }); + expect(scene.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); + }); + + it("poly-octahedron produces leaf nodes inside poly-scene", () => { + const scene = mountInScene("poly-octahedron", { size: "50", color: "#aabbcc" }); + expect(scene.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); + }); + + it("poly-tetrahedron produces leaf nodes inside poly-scene", () => { + const scene = mountInScene("poly-tetrahedron", { size: "100" }); + expect(scene.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); + }); + + it("poly-sphere produces leaf nodes inside poly-scene", () => { + const scene = mountInScene("poly-sphere", { radius: "50", subdivisions: "1" }); + expect(scene.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); + }); + + it("poly-icosahedron produces leaf nodes inside poly-scene", () => { + const scene = mountInScene("poly-icosahedron", { size: "80" }); + expect(scene.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); + }); + + it("poly-dodecahedron produces leaf nodes inside poly-scene", () => { + const scene = mountInScene("poly-dodecahedron", { size: "80" }); + expect(scene.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); + }); + + it("poly-cylinder produces leaf nodes inside poly-scene", () => { + const scene = mountInScene("poly-cylinder", { radius: "40", height: "80", "radial-segments": "6" }); + expect(scene.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); + }); + + it("poly-cone produces leaf nodes inside poly-scene", () => { + const scene = mountInScene("poly-cone", { radius: "40", height: "80", "radial-segments": "6" }); + expect(scene.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); + }); + + it("poly-torus produces leaf nodes inside poly-scene", () => { + const scene = mountInScene("poly-torus", { radius: "40", tube: "10", "radial-segments": "4", "tubular-segments": "6" }); + expect(scene.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); + }); + + it("noop when no poly-scene ancestor exists", () => { + const el = document.createElement("poly-box"); + el.setAttribute("size", "100"); + host.appendChild(el); + expect(host.querySelectorAll("i,b,s,u").length).toBe(0); + }); + + it("disposes mesh on disconnect", () => { + const scene = mountInScene("poly-box", { size: "100" }); + const el = scene.querySelector("poly-box")!; + expect(scene.querySelectorAll("i,b,s,u").length).toBeGreaterThan(0); + scene.removeChild(el); + expect(scene.querySelectorAll(".polycss-mesh").length).toBe(0); + }); +}); diff --git a/packages/polycss/src/elements/PolyShapeElements.ts b/packages/polycss/src/elements/PolyShapeElements.ts new file mode 100644 index 00000000..0f9cd953 --- /dev/null +++ b/packages/polycss/src/elements/PolyShapeElements.ts @@ -0,0 +1,300 @@ +/** + * Primitive shape custom elements — , , , + * , , , , + * , , . + * + * Each element reads shape-specific attributes, calls the matching polygon + * generator from @layoutit/polycss-core, then registers the result with the + * nearest ancestor using the same mechanism as . + * + * Attribute naming follows kebab-case conventions: `radial-segments`, + * `tubular-segments`, `radius-top`, `half-thickness`, etc. + */ +import type { Polygon, Vec3 } from "@layoutit/polycss-core"; +import { + boxPolygons, + planePolygons, + ringPolygons, + octahedronPolygons, + spherePolygons, + tetrahedronPolygons, + icosahedronPolygons, + dodecahedronPolygons, + cylinderPolygons, + conePolygons, + torusPolygons, + computeSceneBbox, +} from "@layoutit/polycss-core"; +import type { PolyMeshHandle, PolySceneHandle } from "../api/createPolyScene"; +import type { PolySceneElement } from "./PolySceneElement"; + +const ELEMENT_BASE: typeof HTMLElement = + typeof HTMLElement !== "undefined" + ? HTMLElement + : (class {} as unknown as typeof HTMLElement); + +function findScene(el: HTMLElement): PolySceneElement | null { + const found = el.closest("poly-scene") as unknown as + | (PolySceneElement & { getScene?: () => unknown }) + | null; + return found ?? null; +} + +function numAttr(el: HTMLElement, name: string, fallback: number): number { + const raw = el.getAttribute(name); + if (!raw) return fallback; + const n = parseFloat(raw); + return Number.isFinite(n) ? n : fallback; +} + +function strAttr(el: HTMLElement, name: string, fallback?: string): string | undefined { + return el.getAttribute(name) ?? fallback; +} + +function parseVec3Attr(el: HTMLElement, name: string): Vec3 | undefined { + const raw = el.getAttribute(name); + if (!raw) return undefined; + const parts = raw.split(",").map((s) => parseFloat(s.trim())); + if (parts.length !== 3 || parts.some((p) => !Number.isFinite(p))) return undefined; + return [parts[0], parts[1], parts[2]]; +} + +function recenter(polygons: Polygon[]): Polygon[] { + if (polygons.length === 0) return polygons; + 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; + if (cx === 0 && cy === 0 && cz === 0) return polygons; + const shift = (v: Vec3): Vec3 => [v[0] - cx, v[1] - cy, v[2] - cz]; + return polygons.map((p) => ({ ...p, vertices: p.vertices.map(shift) })); +} + +/** + * Base class for all primitive shape elements. Subclasses implement + * `buildPolygons()` which returns a Polygon[] from this element's attributes. + */ +abstract class PolyShapeElement extends ELEMENT_BASE { + private _handle: PolyMeshHandle | null = null; + + abstract buildPolygons(): Polygon[]; + + connectedCallback(): void { + this._mount(); + } + + disconnectedCallback(): void { + this._tearDown(); + } + + private _tearDown(): void { + if (this._handle) { + try { this._handle.dispose(); } catch { /* ignore */ } + this._handle = null; + } + } + + private _mount(): void { + const sceneEl = findScene(this); + if (!sceneEl) return; + const scene = (sceneEl as unknown as { getScene?: () => PolySceneHandle | null }).getScene?.(); + if (!scene) return; + + let polygons = this.buildPolygons(); + if (this.hasAttribute("auto-center")) { + polygons = recenter(polygons); + } + + // Assemble a minimal ParseResult (no object URLs, no animation). + const parseResult = { + polygons, + objectUrls: [] as string[], + warnings: [] as string[], + dispose: () => { /* no URLs to revoke */ }, + }; + + this._handle = scene.add( + parseResult, + { + position: parseVec3Attr(this, "position"), + scale: (() => { + const raw = this.getAttribute("scale"); + if (!raw) return undefined; + if (raw.includes(",")) return parseVec3Attr(this, "scale"); + const n = parseFloat(raw); + return Number.isFinite(n) ? n : undefined; + })(), + rotation: parseVec3Attr(this, "rotation"), + }, + ); + } +} + +// ── Fixed-geometry primitives ──────────────────────────────────────────────── + +export class PolyBoxElement extends PolyShapeElement { + static get observedAttributes(): string[] { + return ["size", "color", "auto-center", "position", "scale", "rotation"]; + } + + buildPolygons(): Polygon[] { + const sizeAttr = this.getAttribute("size"); + const size = sizeAttr ? parseFloat(sizeAttr) : undefined; + return boxPolygons({ + size: size !== undefined && Number.isFinite(size) ? size : undefined, + color: strAttr(this, "color"), + }); + } +} + +export class PolyPlaneElement extends PolyShapeElement { + static get observedAttributes(): string[] { + return ["axis", "size", "color", "auto-center", "position", "scale", "rotation"]; + } + + buildPolygons(): Polygon[] { + const axisRaw = numAttr(this, "axis", 2); + const axis = (axisRaw === 0 || axisRaw === 1 || axisRaw === 2 ? axisRaw : 2) as 0 | 1 | 2; + return planePolygons({ + axis, + size: numAttr(this, "size", 0.4), + color: strAttr(this, "color"), + }); + } +} + +export class PolyRingElement extends PolyShapeElement { + static get observedAttributes(): string[] { + return ["axis", "radius", "segments", "half-thickness", "color", "auto-center", "position", "scale", "rotation"]; + } + + buildPolygons(): Polygon[] { + const axisRaw = numAttr(this, "axis", 2); + const axis = (axisRaw === 0 || axisRaw === 1 || axisRaw === 2 ? axisRaw : 2) as 0 | 1 | 2; + const halfThicknessAttr = this.getAttribute("half-thickness"); + return ringPolygons({ + axis, + radius: numAttr(this, "radius", 50), + segments: numAttr(this, "segments", 32), + halfThickness: halfThicknessAttr ? parseFloat(halfThicknessAttr) : undefined, + color: strAttr(this, "color"), + }); + } +} + +export class PolyOctahedronElement extends PolyShapeElement { + static get observedAttributes(): string[] { + return ["size", "color", "center", "auto-center", "position", "scale", "rotation"]; + } + + buildPolygons(): Polygon[] { + return octahedronPolygons({ + center: parseVec3Attr(this, "center") ?? [0, 0, 0], + size: numAttr(this, "size", 50), + color: strAttr(this, "color"), + }); + } +} + +export class PolyTetrahedronElement extends PolyShapeElement { + static get observedAttributes(): string[] { + return ["size", "color", "auto-center", "position", "scale", "rotation"]; + } + + buildPolygons(): Polygon[] { + return tetrahedronPolygons({ + size: numAttr(this, "size", 100), + color: strAttr(this, "color"), + }); + } +} + +export class PolyIcosahedronElement extends PolyShapeElement { + static get observedAttributes(): string[] { + return ["size", "color", "auto-center", "position", "scale", "rotation"]; + } + + buildPolygons(): Polygon[] { + return icosahedronPolygons({ + size: numAttr(this, "size", 100), + color: strAttr(this, "color"), + }); + } +} + +export class PolyDodecahedronElement extends PolyShapeElement { + static get observedAttributes(): string[] { + return ["size", "color", "auto-center", "position", "scale", "rotation"]; + } + + buildPolygons(): Polygon[] { + return dodecahedronPolygons({ + size: numAttr(this, "size", 100), + color: strAttr(this, "color"), + }); + } +} + +export class PolySphereElement extends PolyShapeElement { + static get observedAttributes(): string[] { + return ["radius", "subdivisions", "color", "auto-center", "position", "scale", "rotation"]; + } + + buildPolygons(): Polygon[] { + return spherePolygons({ + radius: numAttr(this, "radius", 50), + subdivisions: numAttr(this, "subdivisions", 1), + color: strAttr(this, "color"), + }); + } +} + +// ── Parametric primitives ───────────────────────────────────────────────────── + +export class PolyCylinderElement extends PolyShapeElement { + static get observedAttributes(): string[] { + return ["radius", "radius-top", "height", "radial-segments", "color", "auto-center", "position", "scale", "rotation"]; + } + + buildPolygons(): Polygon[] { + const radiusTopAttr = this.getAttribute("radius-top"); + return cylinderPolygons({ + radius: numAttr(this, "radius", 50), + radiusTop: radiusTopAttr ? parseFloat(radiusTopAttr) : undefined, + height: numAttr(this, "height", 100), + radialSegments: numAttr(this, "radial-segments", 12), + color: strAttr(this, "color"), + }); + } +} + +export class PolyConeElement extends PolyShapeElement { + static get observedAttributes(): string[] { + return ["radius", "height", "radial-segments", "color", "auto-center", "position", "scale", "rotation"]; + } + + buildPolygons(): Polygon[] { + return conePolygons({ + radius: numAttr(this, "radius", 50), + height: numAttr(this, "height", 100), + radialSegments: numAttr(this, "radial-segments", 12), + color: strAttr(this, "color"), + }); + } +} + +export class PolyTorusElement extends PolyShapeElement { + static get observedAttributes(): string[] { + return ["radius", "tube", "radial-segments", "tubular-segments", "color", "auto-center", "position", "scale", "rotation"]; + } + + buildPolygons(): Polygon[] { + return torusPolygons({ + radius: numAttr(this, "radius", 50), + tube: numAttr(this, "tube", 15), + radialSegments: numAttr(this, "radial-segments", 12), + tubularSegments: numAttr(this, "tubular-segments", 16), + color: strAttr(this, "color"), + }); + } +} diff --git a/packages/polycss/src/elements/index.ts b/packages/polycss/src/elements/index.ts index f5922287..e4989ba3 100644 --- a/packages/polycss/src/elements/index.ts +++ b/packages/polycss/src/elements/index.ts @@ -16,10 +16,25 @@ import { PolyAxesHelperElement } from "./PolyAxesHelperElement"; import { PolyDirectionalLightHelperElement } from "./PolyDirectionalLightHelperElement"; import { PolyOrbitControlsElement } from "./PolyOrbitControlsElement"; import { PolyMapControlsElement } from "./PolyMapControlsElement"; +import { PolyFirstPersonControlsElement } from "./PolyFirstPersonControlsElement"; import { PolyPerspectiveCameraElement } from "./PolyPerspectiveCameraElement"; import { PolyOrthographicCameraElement } from "./PolyOrthographicCameraElement"; +import { PolyCameraElement } from "./PolyCameraElement"; import { PolyTransformControlsElement } from "./PolyTransformControlsElement"; import { PolySelectElement } from "./PolySelectElement"; +import { + PolyBoxElement, + PolyPlaneElement, + PolyRingElement, + PolyOctahedronElement, + PolySphereElement, + PolyTetrahedronElement, + PolyIcosahedronElement, + PolyDodecahedronElement, + PolyCylinderElement, + PolyConeElement, + PolyTorusElement, +} from "./PolyShapeElements"; if (typeof customElements !== "undefined") { if (!customElements.get("poly-scene")) { @@ -46,18 +61,57 @@ if (typeof customElements !== "undefined") { if (!customElements.get("poly-map-controls")) { customElements.define("poly-map-controls", PolyMapControlsElement); } + if (!customElements.get("poly-first-person-controls")) { + customElements.define("poly-first-person-controls", PolyFirstPersonControlsElement); + } if (!customElements.get("poly-perspective-camera")) { customElements.define("poly-perspective-camera", PolyPerspectiveCameraElement); } if (!customElements.get("poly-orthographic-camera")) { customElements.define("poly-orthographic-camera", PolyOrthographicCameraElement); } + if (!customElements.get("poly-camera")) { + customElements.define("poly-camera", PolyCameraElement); + } if (!customElements.get("poly-transform-controls")) { customElements.define("poly-transform-controls", PolyTransformControlsElement); } if (!customElements.get("poly-select")) { customElements.define("poly-select", PolySelectElement); } + if (!customElements.get("poly-box")) { + customElements.define("poly-box", PolyBoxElement); + } + if (!customElements.get("poly-plane")) { + customElements.define("poly-plane", PolyPlaneElement); + } + if (!customElements.get("poly-ring")) { + customElements.define("poly-ring", PolyRingElement); + } + if (!customElements.get("poly-octahedron")) { + customElements.define("poly-octahedron", PolyOctahedronElement); + } + if (!customElements.get("poly-tetrahedron")) { + customElements.define("poly-tetrahedron", PolyTetrahedronElement); + } + if (!customElements.get("poly-sphere")) { + customElements.define("poly-sphere", PolySphereElement); + } + if (!customElements.get("poly-icosahedron")) { + customElements.define("poly-icosahedron", PolyIcosahedronElement); + } + if (!customElements.get("poly-dodecahedron")) { + customElements.define("poly-dodecahedron", PolyDodecahedronElement); + } + if (!customElements.get("poly-cylinder")) { + customElements.define("poly-cylinder", PolyCylinderElement); + } + if (!customElements.get("poly-cone")) { + customElements.define("poly-cone", PolyConeElement); + } + if (!customElements.get("poly-torus")) { + customElements.define("poly-torus", PolyTorusElement); + } } export { @@ -68,8 +122,21 @@ export { PolyDirectionalLightHelperElement, PolyOrbitControlsElement, PolyMapControlsElement, + PolyFirstPersonControlsElement, PolyPerspectiveCameraElement, PolyOrthographicCameraElement, + PolyCameraElement, PolyTransformControlsElement, PolySelectElement, + PolyBoxElement, + PolyPlaneElement, + PolyRingElement, + PolyOctahedronElement, + PolySphereElement, + PolyTetrahedronElement, + PolyIcosahedronElement, + PolyDodecahedronElement, + PolyCylinderElement, + PolyConeElement, + PolyTorusElement, }; diff --git a/packages/polycss/src/index.ts b/packages/polycss/src/index.ts index 2aa86d01..d62cf156 100644 --- a/packages/polycss/src/index.ts +++ b/packages/polycss/src/index.ts @@ -20,7 +20,7 @@ export type { } from "./api/createPolyScene"; // ── Camera factories ────────────────────────────────────────────── -export { createPolyPerspectiveCamera, createPolyOrthographicCamera } from "./api/createPolyCamera"; +export { createPolyPerspectiveCamera, createPolyOrthographicCamera, createPolyCamera } from "./api/createPolyCamera"; export type { PolyCameraOptions, PolyPerspectiveCameraOptions, @@ -77,8 +77,10 @@ export { PolyMeshElement } from "./elements/PolyMeshElement"; export { PolyPolygonElement } from "./elements/PolyPolygonElement"; export { PolyOrbitControlsElement } from "./elements/PolyOrbitControlsElement"; export { PolyMapControlsElement } from "./elements/PolyMapControlsElement"; +export { PolyFirstPersonControlsElement } from "./elements/PolyFirstPersonControlsElement"; export { PolyPerspectiveCameraElement } from "./elements/PolyPerspectiveCameraElement"; export { PolyOrthographicCameraElement } from "./elements/PolyOrthographicCameraElement"; +export { PolyCameraElement } from "./elements/PolyCameraElement"; export { PolyTransformControlsElement } from "./elements/PolyTransformControlsElement"; export { PolySelectElement } from "./elements/PolySelectElement"; @@ -89,6 +91,37 @@ export type { TextureQuality, } from "./render/textureAtlas"; +// ── Primitive shape factories ───────────────────────────────────── +export { + createPolyBox, + createPolyPlane, + createPolyRing, + createPolyOctahedron, + createPolySphere, + createPolyTetrahedron, + createPolyIcosahedron, + createPolyDodecahedron, + createPolyCylinder, + createPolyCone, + createPolyTorus, +} from "./api/createPolyShapes"; +export type { PolyShapeResult } from "./api/createPolyShapes"; + +// ── Primitive shape element classes ────────────────────────────── +export { + PolyBoxElement, + PolyPlaneElement, + PolyRingElement, + PolyOctahedronElement, + PolySphereElement, + PolyTetrahedronElement, + PolyIcosahedronElement, + PolyDodecahedronElement, + PolyCylinderElement, + PolyConeElement, + PolyTorusElement, +} from "./elements/PolyShapeElements"; + // ── Style injection ─────────────────────────────────────────────── export { injectPolyBaseStyles } from "./styles/styles"; diff --git a/packages/react/src/camera/PolyCamera.behavior.test.tsx b/packages/react/src/camera/PolyCamera.behavior.test.tsx index d1c5cc42..523f7a59 100644 --- a/packages/react/src/camera/PolyCamera.behavior.test.tsx +++ b/packages/react/src/camera/PolyCamera.behavior.test.tsx @@ -10,8 +10,8 @@ function renderToDiv(element: React.ReactElement): HTMLElement { return container; } -// PolyCamera is an alias for PolyPerspectiveCamera. Orthographic rendering -// is available via PolyOrthographicCamera. +// PolyCamera is an alias for PolyOrthographicCamera. Perspective rendering +// is available via PolyPerspectiveCamera. describe("PolyCamera behavior", () => { describe("renders camera wrapper", () => { it("has the polycss-camera class", () => { @@ -24,25 +24,15 @@ describe("PolyCamera behavior", () => { }); }); - describe("perspective", () => { - it("applies default perspective of 8000px when no perspective prop given", () => { + describe("projection", () => { + it("sets perspective to none (orthographic, the default)", () => { const container = renderToDiv(
); const camera = container.querySelector(".polycss-camera") as HTMLElement; - expect(camera.style.perspective).toBe("8000px"); - }); - - it("applies a custom numeric perspective value", () => { - const container = renderToDiv( - -
- - ); - const camera = container.querySelector(".polycss-camera") as HTMLElement; - expect(camera.style.perspective).toBe("3000px"); + expect(camera.style.perspective).toBe("none"); }); }); diff --git a/packages/react/src/camera/PolyCamera.test.tsx b/packages/react/src/camera/PolyCamera.test.tsx index b481c499..51f87a1a 100644 --- a/packages/react/src/camera/PolyCamera.test.tsx +++ b/packages/react/src/camera/PolyCamera.test.tsx @@ -10,9 +10,9 @@ function renderToDiv(element: React.ReactElement): HTMLElement { return container; } -// PolyCamera is an alias for PolyPerspectiveCamera — these tests confirm -// the alias renders identically. -describe("PolyCamera (alias for PolyPerspectiveCamera)", () => { +// PolyCamera is an alias for PolyOrthographicCamera — these tests confirm +// the alias renders identically (orthographic projection, perspective: none). +describe("PolyCamera (alias for PolyOrthographicCamera)", () => { it("renders with polycss-camera class", () => { const container = renderToDiv( @@ -36,18 +36,7 @@ describe("PolyCamera (alias for PolyPerspectiveCamera)", () => { expect(child?.textContent).toBe("hello"); }); - it("applies custom perspective", () => { - const container = renderToDiv( - -
- - ); - - const camera = container.querySelector(".polycss-camera") as HTMLElement; - expect(camera.style.perspective).toBe("5000px"); - }); - - it("applies default perspective of 8000px", () => { + it("sets perspective to none (orthographic projection)", () => { const container = renderToDiv(
@@ -55,7 +44,7 @@ describe("PolyCamera (alias for PolyPerspectiveCamera)", () => { ); const camera = container.querySelector(".polycss-camera") as HTMLElement; - expect(camera.style.perspective).toBe("8000px"); + expect(camera.style.perspective).toBe("none"); }); it("applies custom className", () => { diff --git a/packages/react/src/camera/PolyCamera.tsx b/packages/react/src/camera/PolyCamera.tsx index 22576ae8..7297c838 100644 --- a/packages/react/src/camera/PolyCamera.tsx +++ b/packages/react/src/camera/PolyCamera.tsx @@ -1,8 +1,10 @@ /** - * PolyCamera — alias for PolyPerspectiveCamera. + * PolyCamera — alias for PolyOrthographicCamera. * - * Kept for compatibility; prefer the explicit PolyPerspectiveCamera or - * PolyOrthographicCamera names which mirror three.js conventions. + * The default camera in polycss is orthographic — the engine's structural + * advantages (integer-pixel atlas slicing, DOM-as-render-tree) are most + * visible in iso/voxel/diagrammatic scenes. Use PolyPerspectiveCamera + * explicitly when depth foreshortening is needed. */ -export { PolyPerspectiveCamera as PolyCamera } from "./PolyPerspectiveCamera"; -export type { PolyPerspectiveCameraProps as PolyCameraProps } from "./PolyPerspectiveCamera"; +export { PolyOrthographicCamera as PolyCamera } from "./PolyOrthographicCamera"; +export type { PolyOrthographicCameraProps as PolyCameraProps } from "./PolyOrthographicCamera"; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e59c8595..ef3183b7 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -43,6 +43,32 @@ export type { export { Poly } from "./shapes"; export type { PolyProps, TransformProps, DOMPassthroughProps } from "./shapes"; +export { + PolyBox, + PolyPlane, + PolyRing, + PolyOctahedron, + PolySphere, + PolyTetrahedron, + PolyIcosahedron, + PolyDodecahedron, + PolyCylinder, + PolyCone, + PolyTorus, +} from "./shapes"; +export type { + PolyBoxProps, + PolyPlaneProps, + PolyRingProps, + PolyOctahedronProps, + PolySphereProps, + PolyTetrahedronProps, + PolyIcosahedronProps, + PolyDodecahedronProps, + PolyCylinderProps, + PolyConeProps, + PolyTorusProps, +} from "./shapes"; export { PolyFirstPersonControls, PolyOrbitControls, PolyMapControls, PolyTransformControls } from "./controls"; export type { @@ -116,6 +142,13 @@ export type { ArrowPolygonsOptions, RingPolygonsOptions, OctahedronPolygonsOptions, + TetrahedronPolygonsOptions, + IcosahedronPolygonsOptions, + DodecahedronPolygonsOptions, + CylinderPolygonsOptions, + ConePolygonsOptions, + TorusPolygonsOptions, + PlanePolygonsOptions, LoadMeshOptions, VoxParseOptions, SolidTextureSampleOptions, @@ -172,6 +205,13 @@ export { arrowPolygons, ringPolygons, octahedronPolygons, + tetrahedronPolygons, + icosahedronPolygons, + dodecahedronPolygons, + cylinderPolygons, + conePolygons, + torusPolygons, + planePolygons, buildSceneContext, computeSceneBbox, BASE_TILE, diff --git a/packages/react/src/scene/PolyMesh.test.tsx b/packages/react/src/scene/PolyMesh.test.tsx index aea5c322..7adb7401 100644 --- a/packages/react/src/scene/PolyMesh.test.tsx +++ b/packages/react/src/scene/PolyMesh.test.tsx @@ -510,3 +510,23 @@ describe("PolyMesh — updatePolygon", () => { expect(ref.current!.getPolygons()[0].color).toBe("#ffff00"); }); }); + +describe("PolyMesh — meshResolution prop", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + }); + + it("accepts meshResolution='lossless' and mounts leaf DOM without throwing", () => { + const container = renderMesh({ polygons: [TRIANGLE], meshResolution: "lossless" }); + const leaves = container.querySelectorAll("i,b,s,u"); + expect(leaves.length).toBeGreaterThan(0); + }); + + it("accepts meshResolution='lossy' and mounts leaf DOM without throwing", () => { + const container = renderMesh({ polygons: [TRIANGLE, QUAD], meshResolution: "lossy" }); + const leaves = container.querySelectorAll("i,b,s,u"); + expect(leaves.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 5af0230e..816fb33b 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -26,6 +26,7 @@ import { } from "react"; import type { CSSProperties, ReactNode, PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent, WheelEvent as ReactWheelEvent } from "react"; import type { + MeshResolution, Polygon, PolyTextureLightingMode, Vec3, @@ -101,6 +102,10 @@ export interface PolyMeshProps extends TransformProps, InteractionProps { errorFallback?: (error: Error) => ReactNode; /** Parser options forwarded to parseObj/parseGltf. */ parseOptions?: UseMeshOptions; + /** Mesh optimization intent. Defaults to "lossy"; set "lossless" to keep + * authored surface fidelity. Top-level prop wins over `parseOptions.meshResolution` + * when both are present. */ + meshResolution?: MeshResolution; /** * When `true` and the scene is in dynamic lighting mode, emits a flat * shadow leaf (``) sibling for each polygon. @@ -173,6 +178,7 @@ export const PolyMesh = forwardRef(function PolyM fallback, errorFallback, parseOptions, + meshResolution, position, scale, rotation, @@ -193,11 +199,18 @@ export const PolyMesh = forwardRef(function PolyM }: PolyMeshProps, forwardedRef, ) { - // Compose mtl prop into the parser options threaded to useMesh. + // Compose mtl + meshResolution props into the parser options threaded to + // useMesh. The top-level meshResolution prop wins over parseOptions.meshResolution + // when both are present — top-level is the discoverable route; parseOptions is + // for niche parser flags. const mergedOptions = useMemo(() => { - if (!mtl && !parseOptions) return undefined; - return { ...(parseOptions ?? {}), ...(mtl ? { mtlUrl: mtl } : {}) }; - }, [mtl, parseOptions]); + if (!mtl && !parseOptions && meshResolution === undefined) return undefined; + return { + ...(parseOptions ?? {}), + ...(mtl ? { mtlUrl: mtl } : {}), + ...(meshResolution !== undefined ? { meshResolution } : {}), + }; + }, [mtl, parseOptions, meshResolution]); // Either fetch via useMesh, or use the supplied polygons array. // useMesh tolerates an empty src (sits idle) so we always call it for diff --git a/packages/react/src/shapes/PolyShapes.test.tsx b/packages/react/src/shapes/PolyShapes.test.tsx new file mode 100644 index 00000000..188a105f --- /dev/null +++ b/packages/react/src/shapes/PolyShapes.test.tsx @@ -0,0 +1,123 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { PolyCamera } from "../camera/PolyCamera"; +import { PolyScene } from "../scene/PolyScene"; +import { + PolyBox, + PolyPlane, + PolyRing, + PolyOctahedron, + PolySphere, + PolyTetrahedron, + PolyIcosahedron, + PolyDodecahedron, + PolyCylinder, + PolyCone, + PolyTorus, +} from "./PolyShapes"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.innerHTML = ""; +}); + +function renderShape(shape: React.ReactElement): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => + root.render( + React.createElement( + PolyCamera, + {}, + React.createElement(PolyScene, {}, shape), + ), + ), + ); + return container; +} + +function hasLeaves(container: HTMLElement): boolean { + return container.querySelectorAll("i,b,s,u").length > 0; +} + +describe("PolyBox", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyPlane", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyRing", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyOctahedron", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyTetrahedron", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolySphere", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyIcosahedron", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyDodecahedron", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyCylinder", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyCone", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyTorus", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape( + , + ); + expect(hasLeaves(container)).toBe(true); + }); +}); diff --git a/packages/react/src/shapes/PolyShapes.tsx b/packages/react/src/shapes/PolyShapes.tsx new file mode 100644 index 00000000..5457a2d6 --- /dev/null +++ b/packages/react/src/shapes/PolyShapes.tsx @@ -0,0 +1,231 @@ +/** + * Primitive shape components — thin wrappers over the polygon generators in + * @layoutit/polycss-core. Each component calls the matching `xPolygons()` + * generator and passes the result to ``. + * + * Props are flat (shape geometry + common PolyMesh props). No geometry/material + * split — a Polygon carries its own color/texture. + */ +import { useMemo } from "react"; +import { + boxPolygons, + planePolygons, + ringPolygons, + octahedronPolygons, + spherePolygons, + tetrahedronPolygons, + icosahedronPolygons, + dodecahedronPolygons, + cylinderPolygons, + conePolygons, + torusPolygons, + type BoxPolygonsOptions, + type PlanePolygonsOptions, + type RingPolygonsOptions, + type OctahedronPolygonsOptions, + type SpherePolygonsOptions, + type TetrahedronPolygonsOptions, + type IcosahedronPolygonsOptions, + type DodecahedronPolygonsOptions, + type CylinderPolygonsOptions, + type ConePolygonsOptions, + type TorusPolygonsOptions, +} from "@layoutit/polycss-core"; +import { PolyMesh } from "../scene/PolyMesh"; +import type { PolyMeshProps } from "../scene/PolyMesh"; + +// Shared mesh props — strip the polygon-source props so shapes control them. +type ShapeMeshProps = Omit; + +// ── Fixed-geometry primitives ───────────────────────────────────────────────── + +export interface PolyBoxProps extends ShapeMeshProps, BoxPolygonsOptions {} + +export function PolyBox({ + size, + center, + min, + max, + color, + texture, + material, + uvs, + data, + faces, + ...meshProps +}: PolyBoxProps) { + const polygons = useMemo( + () => boxPolygons({ size, center, min, max, color, texture, material, uvs, data, faces }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [size, center, min, max, color, texture, material, uvs, data, faces], + ); + return ; +} + +export interface PolyPlaneProps extends ShapeMeshProps, PlanePolygonsOptions {} + +export function PolyPlane({ + axis, + size, + offset, + along, + color, + ...meshProps +}: PolyPlaneProps) { + const polygons = useMemo( + () => planePolygons({ axis, size, offset, along, color }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [axis, size, offset, along, color], + ); + return ; +} + +export interface PolyRingProps extends ShapeMeshProps, RingPolygonsOptions {} + +export function PolyRing({ + axis, + radius, + halfThickness, + segments, + color, + ...meshProps +}: PolyRingProps) { + const polygons = useMemo( + () => ringPolygons({ axis, radius, halfThickness, segments, color }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [axis, radius, halfThickness, segments, color], + ); + return ; +} + +export interface PolyOctahedronProps extends ShapeMeshProps, OctahedronPolygonsOptions {} + +export function PolyOctahedron({ + center, + size, + color, + ...meshProps +}: PolyOctahedronProps) { + const polygons = useMemo( + () => octahedronPolygons({ center, size, color }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [center, size, color], + ); + return ; +} + +export interface PolyTetrahedronProps extends ShapeMeshProps, TetrahedronPolygonsOptions {} + +export function PolyTetrahedron({ + size, + color, + ...meshProps +}: PolyTetrahedronProps) { + const polygons = useMemo( + () => tetrahedronPolygons({ size, color }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [size, color], + ); + return ; +} + +export interface PolyIcosahedronProps extends ShapeMeshProps, IcosahedronPolygonsOptions {} + +export function PolyIcosahedron({ + size, + color, + ...meshProps +}: PolyIcosahedronProps) { + const polygons = useMemo( + () => icosahedronPolygons({ size, color }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [size, color], + ); + return ; +} + +export interface PolyDodecahedronProps extends ShapeMeshProps, DodecahedronPolygonsOptions {} + +export function PolyDodecahedron({ + size, + color, + ...meshProps +}: PolyDodecahedronProps) { + const polygons = useMemo( + () => dodecahedronPolygons({ size, color }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [size, color], + ); + return ; +} + +export interface PolySphereProps extends ShapeMeshProps, SpherePolygonsOptions {} + +export function PolySphere({ + radius, + subdivisions, + color, + ...meshProps +}: PolySphereProps) { + const polygons = useMemo( + () => spherePolygons({ radius, subdivisions, color }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [radius, subdivisions, color], + ); + return ; +} + +// ── Parametric primitives ───────────────────────────────────────────────────── + +export interface PolyCylinderProps extends ShapeMeshProps, CylinderPolygonsOptions {} + +export function PolyCylinder({ + radius, + radiusTop, + height, + radialSegments, + color, + ...meshProps +}: PolyCylinderProps) { + const polygons = useMemo( + () => cylinderPolygons({ radius, radiusTop, height, radialSegments, color }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [radius, radiusTop, height, radialSegments, color], + ); + return ; +} + +export interface PolyConeProps extends ShapeMeshProps, ConePolygonsOptions {} + +export function PolyCone({ + radius, + height, + radialSegments, + color, + ...meshProps +}: PolyConeProps) { + const polygons = useMemo( + () => conePolygons({ radius, height, radialSegments, color }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [radius, height, radialSegments, color], + ); + return ; +} + +export interface PolyTorusProps extends ShapeMeshProps, TorusPolygonsOptions {} + +export function PolyTorus({ + radius, + tube, + radialSegments, + tubularSegments, + color, + ...meshProps +}: PolyTorusProps) { + const polygons = useMemo( + () => torusPolygons({ radius, tube, radialSegments, tubularSegments, color }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [radius, tube, radialSegments, tubularSegments, color], + ); + return ; +} diff --git a/packages/react/src/shapes/index.ts b/packages/react/src/shapes/index.ts index 280c61d4..d0c55606 100644 --- a/packages/react/src/shapes/index.ts +++ b/packages/react/src/shapes/index.ts @@ -1,2 +1,28 @@ export { Poly } from "./Poly"; export type { PolyProps, TransformProps, DOMPassthroughProps } from "./types"; +export { + PolyBox, + PolyPlane, + PolyRing, + PolyOctahedron, + PolySphere, + PolyTetrahedron, + PolyIcosahedron, + PolyDodecahedron, + PolyCylinder, + PolyCone, + PolyTorus, +} from "./PolyShapes"; +export type { + PolyBoxProps, + PolyPlaneProps, + PolyRingProps, + PolyOctahedronProps, + PolySphereProps, + PolyTetrahedronProps, + PolyIcosahedronProps, + PolyDodecahedronProps, + PolyCylinderProps, + PolyConeProps, + PolyTorusProps, +} from "./PolyShapes"; diff --git a/packages/vue/README.md b/packages/vue/README.md index 608fb7d8..838f2302 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -39,7 +39,7 @@ Root of every Vue polycss render tree. Renders polygons and meshes inside a `` (or `` for pan-first map-style input) inside ``: it receives the camera context. Mirrors Three.js's split between camera state and input. @@ -55,7 +55,7 @@ Loads a mesh from a URL and renders its polygons. Manages blob-URL lifecycle aut | `position` | `Vec3` | `[x, y, z]` offset in scene space | | `scale` | `number \| Vec3` | Uniform or per-axis scale | | `rotation` | `Vec3` | Euler angles in degrees `[x, y, z]` | -| `atlas-scale` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size | +| `textureQuality` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size | | `auto-center` | `boolean` | Shift mesh so its bbox center is at origin | | `mtl` | `string` | Companion `.mtl` URL for OBJ models | @@ -75,7 +75,7 @@ Single polygon. Renders one atlas-backed `` for UV-textured and flat-color fa | `position` | `Vec3` | Local offset | | `scale` | `number \| Vec3` | Scale | | `rotation` | `Vec3` | Euler rotation in degrees | -| `atlas-scale` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size | +| `textureQuality` | `number \| "auto"` | Atlas bitmap budget and compositor sprite size | ### `` diff --git a/packages/vue/src/camera/PolyCamera.test.ts b/packages/vue/src/camera/PolyCamera.test.ts index 28727007..33b00a55 100644 --- a/packages/vue/src/camera/PolyCamera.test.ts +++ b/packages/vue/src/camera/PolyCamera.test.ts @@ -1,7 +1,6 @@ /** * PolyCamera (Vue) — feature tests for the camera wrapper component. - * Mirrors the deleted voxcss VoxCamera.test.ts pattern + the React - * PolyCamera.behavior.test.tsx style. + * PolyCamera is an alias for PolyOrthographicCamera (orthographic projection). */ import { describe, it, expect } from "vitest"; import { createApp, h } from "vue"; @@ -33,19 +32,12 @@ describe("PolyCamera (Vue)", () => { }); }); - describe("perspective", () => { - it("applies default perspective of 8000px when perspective is not set", () => { + describe("projection", () => { + it("sets perspective to none (orthographic, the default)", () => { const container = renderCamera(); const camera = container.querySelector(".polycss-camera") as HTMLElement; - expect(camera.style.perspective).toBe("8000px"); + expect(camera.style.perspective).toBe("none"); }); - - it("applies a custom numeric perspective value", () => { - const container = renderCamera({ perspective: 3000 }); - const camera = container.querySelector(".polycss-camera") as HTMLElement; - expect(camera.style.perspective).toBe("3000px"); - }); - }); describe("children", () => { diff --git a/packages/vue/src/camera/PolyCamera.ts b/packages/vue/src/camera/PolyCamera.ts index 22576ae8..7297c838 100644 --- a/packages/vue/src/camera/PolyCamera.ts +++ b/packages/vue/src/camera/PolyCamera.ts @@ -1,8 +1,10 @@ /** - * PolyCamera — alias for PolyPerspectiveCamera. + * PolyCamera — alias for PolyOrthographicCamera. * - * Kept for compatibility; prefer the explicit PolyPerspectiveCamera or - * PolyOrthographicCamera names which mirror three.js conventions. + * The default camera in polycss is orthographic — the engine's structural + * advantages (integer-pixel atlas slicing, DOM-as-render-tree) are most + * visible in iso/voxel/diagrammatic scenes. Use PolyPerspectiveCamera + * explicitly when depth foreshortening is needed. */ -export { PolyPerspectiveCamera as PolyCamera } from "./PolyPerspectiveCamera"; -export type { PolyPerspectiveCameraProps as PolyCameraProps } from "./PolyPerspectiveCamera"; +export { PolyOrthographicCamera as PolyCamera } from "./PolyOrthographicCamera"; +export type { PolyOrthographicCameraProps as PolyCameraProps } from "./PolyOrthographicCamera"; diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index df2d103b..a5f45b21 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -26,6 +26,19 @@ export { usePolyMaterial } from "./scene/usePolyMaterial"; export { Poly } from "./shapes"; export type { PolyProps, PolyContext } from "./shapes"; +export { + PolyBox, + PolyPlane, + PolyRing, + PolyOctahedron, + PolySphere, + PolyTetrahedron, + PolyIcosahedron, + PolyDodecahedron, + PolyCylinder, + PolyCone, + PolyTorus, +} from "./shapes"; export { PolyOrbitControls, PolyMapControls, PolyTransformControls, PolyFirstPersonControls } from "./controls"; export type { @@ -106,6 +119,13 @@ export type { ArrowPolygonsOptions, RingPolygonsOptions, OctahedronPolygonsOptions, + TetrahedronPolygonsOptions, + IcosahedronPolygonsOptions, + DodecahedronPolygonsOptions, + CylinderPolygonsOptions, + ConePolygonsOptions, + TorusPolygonsOptions, + PlanePolygonsOptions, LoadMeshOptions, VoxParseOptions, SolidTextureSampleOptions, @@ -162,6 +182,13 @@ export { arrowPolygons, ringPolygons, octahedronPolygons, + tetrahedronPolygons, + icosahedronPolygons, + dodecahedronPolygons, + cylinderPolygons, + conePolygons, + torusPolygons, + planePolygons, buildSceneContext, computeSceneBbox, BASE_TILE, diff --git a/packages/vue/src/scene/PolyMesh.test.ts b/packages/vue/src/scene/PolyMesh.test.ts index a741c1b2..fd2503f4 100644 --- a/packages/vue/src/scene/PolyMesh.test.ts +++ b/packages/vue/src/scene/PolyMesh.test.ts @@ -360,3 +360,23 @@ describe("PolyMesh (Vue) — loading and error states", () => { expect(meshError).toBeTruthy(); }); }); + +describe("PolyMesh (Vue) — meshResolution prop", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + }); + + it("accepts meshResolution='lossless' and mounts leaf DOM without throwing", () => { + const { container } = renderMesh({ polygons: [TRIANGLE], meshResolution: "lossless" }); + const leaves = container.querySelectorAll("i,b,s,u"); + expect(leaves.length).toBeGreaterThan(0); + }); + + it("accepts meshResolution='lossy' and mounts leaf DOM without throwing", () => { + const { container } = renderMesh({ polygons: [TRIANGLE, QUAD], meshResolution: "lossy" }); + const leaves = container.querySelectorAll("i,b,s,u"); + expect(leaves.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index b9c5264b..bb19b869 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -18,7 +18,7 @@ */ import { defineComponent, h, computed, inject, onMounted, onBeforeUnmount, ref, watch } from "vue"; import type { PropType, VNode, CSSProperties } from "vue"; -import type { Polygon, PolyTextureLightingMode, Vec3 } from "@layoutit/polycss-core"; +import type { MeshResolution, Polygon, PolyTextureLightingMode, Vec3 } from "@layoutit/polycss-core"; import { computeSceneBbox, inverseRotateVec3, findOverlappingPolygonDuplicates, parseHexColor } from "@layoutit/polycss-core"; import { usePolyMesh } from "./useMesh"; import { @@ -83,6 +83,10 @@ export interface PolyMeshProps extends InteractionProps { * Defaults to `false`. */ castShadow?: boolean; + /** Mesh optimization intent. Defaults to "lossy"; set "lossless" to keep + * authored surface fidelity. Top-level prop wins over any meshResolution + * that might be set inside parseOptions. */ + meshResolution?: MeshResolution; class?: string; position?: Vec3; scale?: number | Vec3; @@ -147,6 +151,7 @@ export const PolyMesh = defineComponent({ textureLighting: { type: String as PropType, default: undefined }, textureQuality: { type: [Number, String] as PropType, default: undefined }, castShadow: { type: Boolean as PropType, default: false }, + meshResolution: { type: String as PropType, default: undefined }, class: { type: String }, position: { type: Array as unknown as PropType, default: undefined }, scale: { type: [Number, Array] as unknown as PropType, default: undefined }, @@ -167,8 +172,16 @@ export const PolyMesh = defineComponent({ setup(props, { slots, attrs, expose }) { // useMesh requires a Ref. Computed ref wraps the src prop. const srcRef = computed(() => props.src ?? ""); - const meshOptions = computed(() => (props.mtl ? { mtlUrl: props.mtl } : undefined)); - const fetched = usePolyMesh(srcRef, meshOptions.value); + // Merge mtl + meshResolution into the options passed to usePolyMesh. + // Top-level meshResolution wins over any meshResolution that could come + // from a future parseOptions prop (matches React behavior). + const meshOptions = computed(() => { + const opts: Record = {}; + if (props.mtl) opts.mtlUrl = props.mtl; + if (props.meshResolution !== undefined) opts.meshResolution = props.meshResolution; + return Object.keys(opts).length > 0 ? opts : undefined; + }); + const fetched = usePolyMesh(srcRef, meshOptions.value as import("./useMesh").UseMeshOptions | undefined); const propPolygons = computed(() => props.src ? fetched.polygons.value : (props.polygons ?? []) diff --git a/packages/vue/src/shapes/PolyShapes.test.ts b/packages/vue/src/shapes/PolyShapes.test.ts new file mode 100644 index 00000000..8a4dfdc6 --- /dev/null +++ b/packages/vue/src/shapes/PolyShapes.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { createApp, h } from "vue"; +import { PolyCamera } from "../camera/PolyCamera"; +import { PolyScene } from "../scene/PolyScene"; +import { + PolyBox, + PolyPlane, + PolyRing, + PolyOctahedron, + PolySphere, + PolyTetrahedron, + PolyIcosahedron, + PolyDodecahedron, + PolyCylinder, + PolyCone, + PolyTorus, +} from "./PolyShapes"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.innerHTML = ""; +}); + +function renderShape(shapeVNode: ReturnType): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + const app = createApp({ + setup() { + return () => + h(PolyCamera, {}, { + default: () => + h(PolyScene, {}, { + default: () => shapeVNode, + }), + }); + }, + }); + app.mount(container); + return container; +} + +function hasLeaves(container: HTMLElement): boolean { + return container.querySelectorAll("i,b,s,u").length > 0; +} + +describe("PolyBox (Vue)", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(h(PolyBox, { size: 100, color: "#ff6644" })); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyPlane (Vue)", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(h(PolyPlane, { axis: 2, size: 50, color: "#cccccc" })); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyRing (Vue)", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(h(PolyRing, { axis: 2, radius: 50, segments: 8 })); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyOctahedron (Vue)", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape( + h(PolyOctahedron, { center: [0, 0, 0], size: 50, color: "#aabbcc" }), + ); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyTetrahedron (Vue)", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(h(PolyTetrahedron, { size: 100, color: "#aabb00" })); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolySphere (Vue)", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(h(PolySphere, { radius: 50, subdivisions: 1, color: "#3b82f6" })); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyIcosahedron (Vue)", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(h(PolyIcosahedron, { size: 80, color: "#8800ff" })); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyDodecahedron (Vue)", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape(h(PolyDodecahedron, { size: 80, color: "#ff88cc" })); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyCylinder (Vue)", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape( + h(PolyCylinder, { radius: 40, height: 80, radialSegments: 6 }), + ); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyCone (Vue)", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape( + h(PolyCone, { radius: 40, height: 80, radialSegments: 6 }), + ); + expect(hasLeaves(container)).toBe(true); + }); +}); + +describe("PolyTorus (Vue)", () => { + it("renders leaf DOM inside PolyCamera > PolyScene", () => { + const container = renderShape( + h(PolyTorus, { radius: 40, tube: 10, radialSegments: 4, tubularSegments: 6 }), + ); + expect(hasLeaves(container)).toBe(true); + }); +}); diff --git a/packages/vue/src/shapes/PolyShapes.ts b/packages/vue/src/shapes/PolyShapes.ts new file mode 100644 index 00000000..3bc54bf3 --- /dev/null +++ b/packages/vue/src/shapes/PolyShapes.ts @@ -0,0 +1,401 @@ +/** + * Primitive shape components — thin wrappers over the polygon generators in + * @layoutit/polycss-core. Each component calls the matching `xPolygons()` + * generator and passes the result to ``. + * + * Props are flat (shape geometry + common PolyMesh props). No geometry/material + * split — a Polygon carries its own color/texture. + */ +import { computed, defineComponent, h } from "vue"; +import type { PropType } from "vue"; +import { + boxPolygons, + planePolygons, + ringPolygons, + octahedronPolygons, + spherePolygons, + tetrahedronPolygons, + icosahedronPolygons, + dodecahedronPolygons, + cylinderPolygons, + conePolygons, + torusPolygons, +} from "@layoutit/polycss-core"; +import type { Vec3 } from "@layoutit/polycss-core"; +import { PolyMesh } from "../scene/PolyMesh"; + +// ── Shared mesh prop pass-through helpers ──────────────────────────────────── +// We spread the mesh-compatible props without src/mtl/polygons (those are +// controlled by the shape component). Vue doesn't allow direct key exclusion +// from interfaces, so we pick explicit passes through `attrs` where needed. + +// ── Fixed-geometry primitives ───────────────────────────────────────────────── + +export const PolyBox = defineComponent({ + name: "PolyBox", + props: { + // BoxPolygonsOptions + size: { type: [Number, Array] as PropType, default: undefined }, + center: { type: Array as unknown as PropType, default: undefined }, + min: { type: Array as unknown as PropType, default: undefined }, + max: { type: Array as unknown as PropType, default: undefined }, + color: { type: String, default: undefined }, + texture: { type: String, default: undefined }, + // Common mesh props + 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 }, + autoCenter: { type: Boolean, default: false }, + id: { type: String, default: undefined }, + }, + setup(props) { + const polygons = computed(() => + boxPolygons({ + size: props.size as number | Vec3 | undefined, + center: props.center, + min: props.min, + max: props.max, + color: props.color, + texture: props.texture, + }), + ); + return () => + h(PolyMesh, { + polygons: polygons.value, + position: props.position, + scale: props.scale, + rotation: props.rotation, + autoCenter: props.autoCenter, + id: props.id, + }); + }, +}); + +export const PolyPlane = defineComponent({ + name: "PolyPlane", + props: { + axis: { type: Number as PropType<0 | 1 | 2>, required: true }, + size: { type: Number, default: undefined }, + along: { type: Number, default: undefined }, + color: { type: String, default: undefined }, + // Common mesh props + 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 }, + autoCenter: { type: Boolean, default: false }, + id: { type: String, default: undefined }, + }, + setup(props) { + const polygons = computed(() => + planePolygons({ + axis: props.axis, + size: props.size, + along: props.along, + color: props.color, + }), + ); + return () => + h(PolyMesh, { + polygons: polygons.value, + position: props.position, + scale: props.scale, + rotation: props.rotation, + autoCenter: props.autoCenter, + id: props.id, + }); + }, +}); + +export const PolyRing = defineComponent({ + name: "PolyRing", + props: { + axis: { type: Number as PropType<0 | 1 | 2>, required: true }, + radius: { type: Number, required: true }, + halfThickness: { type: Number, default: undefined }, + segments: { type: Number, default: undefined }, + color: { type: String, default: undefined }, + // Common mesh props + 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 }, + autoCenter: { type: Boolean, default: false }, + id: { type: String, default: undefined }, + }, + setup(props) { + const polygons = computed(() => + ringPolygons({ + axis: props.axis, + radius: props.radius, + halfThickness: props.halfThickness, + segments: props.segments, + color: props.color, + }), + ); + return () => + h(PolyMesh, { + polygons: polygons.value, + position: props.position, + scale: props.scale, + rotation: props.rotation, + autoCenter: props.autoCenter, + id: props.id, + }); + }, +}); + +export const PolyOctahedron = defineComponent({ + name: "PolyOctahedron", + props: { + center: { type: Array as unknown as PropType, required: true }, + size: { type: Number, required: true }, + color: { type: String, default: undefined }, + // Common mesh props + 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 }, + autoCenter: { type: Boolean, default: false }, + id: { type: String, default: undefined }, + }, + setup(props) { + const polygons = computed(() => + octahedronPolygons({ + center: props.center, + size: props.size, + color: props.color, + }), + ); + return () => + h(PolyMesh, { + polygons: polygons.value, + position: props.position, + scale: props.scale, + rotation: props.rotation, + autoCenter: props.autoCenter, + id: props.id, + }); + }, +}); + +export const PolyTetrahedron = defineComponent({ + name: "PolyTetrahedron", + props: { + size: { type: Number, default: undefined }, + color: { type: String, default: undefined }, + // Common mesh props + 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 }, + autoCenter: { type: Boolean, default: false }, + id: { type: String, default: undefined }, + }, + setup(props) { + const polygons = computed(() => + tetrahedronPolygons({ size: props.size, color: props.color }), + ); + return () => + h(PolyMesh, { + polygons: polygons.value, + position: props.position, + scale: props.scale, + rotation: props.rotation, + autoCenter: props.autoCenter, + id: props.id, + }); + }, +}); + +export const PolyIcosahedron = defineComponent({ + name: "PolyIcosahedron", + props: { + size: { type: Number, default: undefined }, + color: { type: String, default: undefined }, + // Common mesh props + 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 }, + autoCenter: { type: Boolean, default: false }, + id: { type: String, default: undefined }, + }, + setup(props) { + const polygons = computed(() => + icosahedronPolygons({ size: props.size, color: props.color }), + ); + return () => + h(PolyMesh, { + polygons: polygons.value, + position: props.position, + scale: props.scale, + rotation: props.rotation, + autoCenter: props.autoCenter, + id: props.id, + }); + }, +}); + +export const PolyDodecahedron = defineComponent({ + name: "PolyDodecahedron", + props: { + size: { type: Number, default: undefined }, + color: { type: String, default: undefined }, + // Common mesh props + 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 }, + autoCenter: { type: Boolean, default: false }, + id: { type: String, default: undefined }, + }, + setup(props) { + const polygons = computed(() => + dodecahedronPolygons({ size: props.size, color: props.color }), + ); + return () => + h(PolyMesh, { + polygons: polygons.value, + position: props.position, + scale: props.scale, + rotation: props.rotation, + autoCenter: props.autoCenter, + id: props.id, + }); + }, +}); + +export const PolySphere = defineComponent({ + name: "PolySphere", + props: { + radius: { type: Number, default: undefined }, + subdivisions: { type: Number, default: undefined }, + color: { type: String, default: undefined }, + // Common mesh props + 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 }, + autoCenter: { type: Boolean, default: false }, + id: { type: String, default: undefined }, + }, + setup(props) { + const polygons = computed(() => + spherePolygons({ radius: props.radius, subdivisions: props.subdivisions, color: props.color }), + ); + return () => + h(PolyMesh, { + polygons: polygons.value, + position: props.position, + scale: props.scale, + rotation: props.rotation, + autoCenter: props.autoCenter, + id: props.id, + }); + }, +}); + +// ── Parametric primitives ───────────────────────────────────────────────────── + +export const PolyCylinder = defineComponent({ + name: "PolyCylinder", + props: { + radius: { type: Number, default: undefined }, + radiusTop: { type: Number, default: undefined }, + height: { type: Number, default: undefined }, + radialSegments: { type: Number, default: undefined }, + color: { type: String, default: undefined }, + // Common mesh props + 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 }, + autoCenter: { type: Boolean, default: false }, + id: { type: String, default: undefined }, + }, + setup(props) { + const polygons = computed(() => + cylinderPolygons({ + radius: props.radius, + radiusTop: props.radiusTop, + height: props.height, + radialSegments: props.radialSegments, + color: props.color, + }), + ); + return () => + h(PolyMesh, { + polygons: polygons.value, + position: props.position, + scale: props.scale, + rotation: props.rotation, + autoCenter: props.autoCenter, + id: props.id, + }); + }, +}); + +export const PolyCone = defineComponent({ + name: "PolyCone", + props: { + radius: { type: Number, default: undefined }, + height: { type: Number, default: undefined }, + radialSegments: { type: Number, default: undefined }, + color: { type: String, default: undefined }, + // Common mesh props + 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 }, + autoCenter: { type: Boolean, default: false }, + id: { type: String, default: undefined }, + }, + setup(props) { + const polygons = computed(() => + conePolygons({ + radius: props.radius, + height: props.height, + radialSegments: props.radialSegments, + color: props.color, + }), + ); + return () => + h(PolyMesh, { + polygons: polygons.value, + position: props.position, + scale: props.scale, + rotation: props.rotation, + autoCenter: props.autoCenter, + id: props.id, + }); + }, +}); + +export const PolyTorus = defineComponent({ + name: "PolyTorus", + props: { + radius: { type: Number, default: undefined }, + tube: { type: Number, default: undefined }, + radialSegments: { type: Number, default: undefined }, + tubularSegments: { type: Number, default: undefined }, + color: { type: String, default: undefined }, + // Common mesh props + 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 }, + autoCenter: { type: Boolean, default: false }, + id: { type: String, default: undefined }, + }, + setup(props) { + const polygons = computed(() => + torusPolygons({ + radius: props.radius, + tube: props.tube, + radialSegments: props.radialSegments, + tubularSegments: props.tubularSegments, + color: props.color, + }), + ); + return () => + h(PolyMesh, { + polygons: polygons.value, + position: props.position, + scale: props.scale, + rotation: props.rotation, + autoCenter: props.autoCenter, + id: props.id, + }); + }, +}); diff --git a/packages/vue/src/shapes/index.ts b/packages/vue/src/shapes/index.ts index b5faa500..e7cb26d7 100644 --- a/packages/vue/src/shapes/index.ts +++ b/packages/vue/src/shapes/index.ts @@ -1,2 +1,15 @@ export { Poly } from "./Poly"; export type { PolyProps, PolyContext } from "./Poly"; +export { + PolyBox, + PolyPlane, + PolyRing, + PolyOctahedron, + PolySphere, + PolyTetrahedron, + PolyIcosahedron, + PolyDodecahedron, + PolyCylinder, + PolyCone, + PolyTorus, +} from "./PolyShapes"; diff --git a/website/src/components/GalleryWorkbench/helpers/loaders.ts b/website/src/components/GalleryWorkbench/helpers/loaders.ts index 1a46e40c..0cb3e5cc 100644 --- a/website/src/components/GalleryWorkbench/helpers/loaders.ts +++ b/website/src/components/GalleryWorkbench/helpers/loaders.ts @@ -62,10 +62,31 @@ export async function loadPresetModel( parser: ParserOptionsState, ): Promise { const started = performance.now(); + if (model.kind === "primitive") { + if (!model.generatePolygons) { + throw new Error(`Primitive preset "${model.id}" is missing generatePolygons`); + } + const polygons = model.generatePolygons(); + return { + label: model.label, + kind: "primitive", + parseResult: { polygons, objectUrls: [], warnings: [], dispose: () => {} }, + rawPolygons: polygons, + polygons, + sourcePolygons: polygons.length, + sourceBytes: 0, + warnings: [], + parseMs: performance.now() - started, + dispose: () => {}, + }; + } + const url = model.url; + if (!url) throw new Error(`Preset "${model.id}" (kind: ${model.kind}) is missing a url`); + if (model.kind === "obj") { const [objText, mtlText] = await Promise.all([ - fetch(model.url).then((res) => { - if (!res.ok) throw new Error(`fetch ${model.url} -> ${res.status}`); + fetch(url).then((res) => { + if (!res.ok) throw new Error(`fetch ${url} -> ${res.status}`); return res.text(); }), model.mtlUrl @@ -108,8 +129,8 @@ export async function loadPresetModel( }; } - const buf = await fetch(model.url).then((res) => { - if (!res.ok) throw new Error(`fetch ${model.url} -> ${res.status}`); + const buf = await fetch(url).then((res) => { + if (!res.ok) throw new Error(`fetch ${url} -> ${res.status}`); return res.arrayBuffer(); }); @@ -131,7 +152,7 @@ export async function loadPresetModel( const parsedGltf = parseGltf(buf, { ...mergeParserOptions(model.options, parser), - baseUrl: new URL(model.url, window.location.href).href, + baseUrl: new URL(url, window.location.href).href, }); const parsed = await bakeSolidTextureSamples(parsedGltf); return { 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 boxPolygons({ size: 100, color: COLOR }), + }, + { + id: "primitive-plane", + label: "Plane", + category: "Primitives", + kind: "primitive", + galleryBucket: "Primitives", + zoom: 0.05, + rotX: 65, + rotY: 45, + generatePolygons: () => planePolygons({ axis: 2, size: 50, offset: 0, color: COLOR }), + }, + { + id: "primitive-ring", + label: "Ring", + category: "Primitives", + kind: "primitive", + galleryBucket: "Primitives", + zoom: 0.05, + rotX: 65, + rotY: 45, + generatePolygons: () => ringPolygons({ axis: 2, radius: 50, halfThickness: 10, segments: 32, color: COLOR }), + }, + { + id: "primitive-sphere", + label: "Sphere", + category: "Primitives", + kind: "primitive", + galleryBucket: "Primitives", + zoom: 0.05, + rotX: 65, + rotY: 45, + generatePolygons: () => spherePolygons({ radius: 50, subdivisions: 1, color: COLOR }), + }, + { + id: "primitive-tetrahedron", + label: "Tetrahedron", + category: "Primitives", + kind: "primitive", + galleryBucket: "Primitives", + zoom: 0.05, + rotX: 65, + rotY: 45, + generatePolygons: () => tetrahedronPolygons({ size: 80, color: COLOR }), + }, + { + id: "primitive-octahedron", + label: "Octahedron", + category: "Primitives", + kind: "primitive", + galleryBucket: "Primitives", + zoom: 0.05, + rotX: 65, + rotY: 45, + generatePolygons: () => octahedronPolygons({ center: [0, 0, 0], size: 70, color: COLOR }), + }, + { + id: "primitive-icosahedron", + label: "Icosahedron", + category: "Primitives", + kind: "primitive", + galleryBucket: "Primitives", + zoom: 0.05, + rotX: 65, + rotY: 45, + generatePolygons: () => icosahedronPolygons({ size: 80, color: COLOR }), + }, + { + id: "primitive-dodecahedron", + label: "Dodecahedron", + category: "Primitives", + kind: "primitive", + galleryBucket: "Primitives", + zoom: 0.05, + rotX: 65, + rotY: 45, + generatePolygons: () => dodecahedronPolygons({ size: 80, color: COLOR }), + }, + { + id: "primitive-cylinder", + label: "Cylinder", + category: "Primitives", + kind: "primitive", + galleryBucket: "Primitives", + zoom: 0.05, + rotX: 65, + rotY: 45, + generatePolygons: () => cylinderPolygons({ radius: 40, height: 100, radialSegments: 12, color: COLOR }), + }, + { + id: "primitive-cone", + label: "Cone", + category: "Primitives", + kind: "primitive", + galleryBucket: "Primitives", + zoom: 0.05, + rotX: 65, + rotY: 45, + generatePolygons: () => conePolygons({ radius: 50, height: 100, radialSegments: 12, color: COLOR }), + }, + { + id: "primitive-torus", + label: "Torus", + category: "Primitives", + kind: "primitive", + galleryBucket: "Primitives", + zoom: 0.05, + rotX: 65, + rotY: 45, + generatePolygons: () => torusPolygons({ radius: 50, tube: 15, radialSegments: 12, tubularSegments: 16, color: COLOR }), + }, + { id: "chicken", label: "Chicken", diff --git a/website/src/components/GalleryWorkbench/types.ts b/website/src/components/GalleryWorkbench/types.ts index 8531f120..baca56df 100644 --- a/website/src/components/GalleryWorkbench/types.ts +++ b/website/src/components/GalleryWorkbench/types.ts @@ -5,8 +5,8 @@ import type { ObjParseOptions, GltfParseOptions, VoxParseOptions, ParseResult, Polygon, ParseAnimationController } from "@layoutit/polycss"; export type Renderer = "react" | "vanilla"; -export type ModelKind = "obj" | "glb" | "gltf" | "vox"; -export type GalleryBucket = "Solid" | "Textured" | "Animated" | "Voxel"; +export type ModelKind = "obj" | "glb" | "gltf" | "vox" | "primitive"; +export type GalleryBucket = "Primitives" | "Solid" | "Textured" | "Animated" | "Voxel"; export type MatrixPrecision = "exact" | "2" | "3" | "4" | "5" | "6"; export type BorderShapePrecision = "exact" | "2" | "3" | "4" | "5" | "6"; @@ -22,7 +22,7 @@ export interface PresetModel { label: string; kind: ModelKind; category: string; - url: string; + url?: string; mtlUrl?: string; zoom?: number; rotX?: number; @@ -30,12 +30,14 @@ export interface PresetModel { options?: ObjParseOptions | GltfParseOptions | VoxParseOptions; galleryBucket?: GalleryBucket; attribution?: ModelAttribution; + /** For kind: "primitive". Returns the polygon array for this primitive. */ + generatePolygons?: () => Polygon[]; } export interface DroppedModelSource { id: string; label: string; - kind: Exclude; + kind: Exclude; primaryFile: File; files: File[]; preset: PresetModel; diff --git a/website/src/components/PolyDemo.astro b/website/src/components/PolyDemo.astro index ecfabc79..811c50b9 100644 --- a/website/src/components/PolyDemo.astro +++ b/website/src/components/PolyDemo.astro @@ -530,20 +530,24 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat // ── Scene init for generator mode ───────────────────────────────────── async function initGeneratorScene() { - const { createPolyScene, createPolyOrbitControls } = await import("@layoutit/polycss"); + const { + createPolyOrbitControls, + createPolyOrthographicCamera, + createPolyPerspectiveCamera, + createPolyScene, + } = await import("@layoutit/polycss"); sceneEl = document.createElement("div"); sceneEl.style.cssText = "width:100%;height:100%;position:absolute;inset:0;"; sceneHost.appendChild(sceneEl); - const sceneOptions: Record = { - rotX: state.rotX, - rotY: state.rotY, - zoom: state.zoom, - autoCenter: true, - }; + const cameraOpts = { rotX: state.rotX, rotY: state.rotY, zoom: state.zoom }; const perspective = perspectiveOverride(); - if (perspective !== undefined) sceneOptions.perspective = perspective; + const camera = perspective !== undefined + ? createPolyPerspectiveCamera({ ...cameraOpts, perspective }) + : createPolyOrthographicCamera(cameraOpts); + + const sceneOptions: Record = { camera, autoCenter: true }; if (state.light) sceneOptions.directionalLight = state.light; sceneHandle = createPolyScene(sceneEl as HTMLElement, sceneOptions); @@ -586,14 +590,13 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat // ── Update camera from state (model mode uses attributes) ────────────── function updateCamera() { if (sceneHandle) { - // Generator mode — use imperative API - sceneHandle.setOptions({ + // Generator mode — use imperative API; camera state lives on the camera handle + sceneHandle.camera?.update({ rotX: state.rotX, rotY: state.rotY, - perspective: perspectiveOverride(), zoom: state.zoom, - autoCenter: true, }); + sceneHandle.applyCamera(); } else { // applySceneAttrs handles rotation/zoom/light attrs on the // . drag/wheel/animate flow through @@ -616,113 +619,145 @@ const { id, model, mtl, generator, generatorParams, controls, defaults, showStat // ── Code snippet generation ──────────────────────────────────────────── function updateCode() { - const cp: Record = {}; - if (state.zoom !== 1) cp.zoom = state.zoom.toFixed(2); - if (state.rotX !== 65) cp.rotX = String(state.rotX); - if (state.rotY !== 45) cp.rotY = String(state.rotY); - if (state.perspective !== rendererDefaultPerspective) cp.perspective = String(state.perspective); - if (state.animate) cp.animate = "true"; + // Camera props — zoom/rotX/rotY always on camera; perspective on + // PolyPerspectiveCamera only (omitted entirely for ortho). + // state.perspective === false → orthographic; number → perspective. + const isOrtho = state.perspective === false; + const perspectivePx = isOrtho ? null : (state.perspective as number); + + // Only include non-default camera props in snippets to keep them tidy. + const camProps: Record = {}; + if (state.zoom !== 1) camProps.zoom = state.zoom.toFixed(2); + if (state.rotX !== 65) camProps.rotX = String(state.rotX); + if (state.rotY !== 45) camProps.rotY = String(state.rotY); + // Perspective is emitted only when non-default (8000 is the default). + if (!isOrtho && perspectivePx !== rendererDefaultPerspective) { + camProps.perspective = String(perspectivePx); + } - const sp: Record = {}; + // Whether to include a controls line in the snippet. + const wantControls = state.animate || state.interactive; // ── React snippet ────────────────────────────────────────────────── if (codeEls.react) { - const r: string[] = ['import { PolyScene, PolyMesh } from "@layoutit/polycss-react";', ""]; - r.push("export function App() {"); - r.push(" return ("); + const cameraTag = isOrtho ? "PolyCamera" : "PolyPerspectiveCamera"; - const camAttrs = Object.entries(cp).map(([k, v]) => - v === "true" ? k : `${k}={${v}}` - ); - const sceneAttrs = Object.entries(sp).map(([k, v]) => - v === "true" ? k : `${k}={${v}}` - ); - const allAttrs = [...camAttrs, ...sceneAttrs]; + const reactCamAttrs = Object.entries(camProps).map(([k, v]) => `${k}={${v}}`); + + // Build camera open-tag + const reactCamOpen = + reactCamAttrs.length <= 2 + ? ` <${cameraTag}${reactCamAttrs.length ? " " + reactCamAttrs.join(" ") : ""}>` + : ` <${cameraTag}\n` + reactCamAttrs.map((a) => " " + a).join("\n") + "\n >"; + + const r: string[] = []; if (modelSrc) { - if (allAttrs.length <= 3) { - r.push(" "); - } else { - r.push(" r.push(" " + a)); - r.push(" >"); + const imports = [cameraTag, "PolyScene", "PolyMesh"]; + if (wantControls) imports.splice(2, 0, "PolyOrbitControls"); + r.push(`import { ${imports.join(", ")} } from "@layoutit/polycss-react";`); + r.push(""); + r.push("export function App() {"); + r.push(" return ("); + r.push(reactCamOpen); + r.push(" "); + if (wantControls) { + const animProp = state.animate ? ' animate={{ speed: 0.3 }}' : ''; + const dragProp = state.interactive ? ' drag wheel' : ''; + r.push(` `); } - r.push(' '); - r.push(" <" + "/PolyScene>"); + r.push(' '); + r.push(" <" + "/PolyScene>"); + r.push(" <" + `/${cameraTag}>`); + r.push(" );"); + r.push("}"); } else if (generatorName) { const fnCall = generatorName === "sphere" ? `buildSpherePolygons(${state.radius}, ${state.subdivisions})` : `build${generatorName.charAt(0).toUpperCase()}${generatorName.slice(1)}Polygons(${state.radius})`; - r[0] = 'import { PolyScene, Poly } from "@layoutit/polycss-react";'; - r.splice(1, 0, ''); - r.splice(2, 0, `const polygons = ${fnCall};`); - r.splice(3, 0, ''); - if (allAttrs.length <= 3) { - r.push(" "); - } else { - r.push(" r.push(" " + a)); - r.push(" >"); + const imports = [cameraTag, "PolyScene", "Poly"]; + if (wantControls) imports.splice(2, 0, "PolyOrbitControls"); + r.push(`import { ${imports.join(", ")} } from "@layoutit/polycss-react";`); + r.push(""); + r.push(`const polygons = ${fnCall};`); + r.push(""); + r.push("export function App() {"); + r.push(" return ("); + r.push(reactCamOpen); + r.push(" "); + if (wantControls) { + const animProp = state.animate ? ' animate={{ speed: 0.3 }}' : ''; + const dragProp = state.interactive ? ' drag wheel' : ''; + r.push(` `); } - r.push(" {polygons.map((p, i) => )}"); - r.push(" <" + "/PolyScene>"); + r.push(" {polygons.map((p, i) => )}"); + r.push(" <" + "/PolyScene>"); + r.push(" <" + `/${cameraTag}>`); + r.push(" );"); + r.push("}"); } - r.push(" );"); - r.push("}"); codeEls.react.textContent = r.join("\n"); } // ── Vue snippet ─────────────────────────────────────────────────── if (codeEls.vue) { - const vueCamAttrs = Object.entries(cp).map(([k, v]) => { - const attr = k.replace(/([A-Z])/g, "-$1").toLowerCase(); - return v === "true" ? attr : `:${attr}="${v}"`; - }); - const vueSceneAttrs = Object.entries(sp).map(([k, v]) => { + const cameraTag = isOrtho ? "PolyCamera" : "PolyPerspectiveCamera"; + + // kebab-case with : binding for numeric props + const vueCamAttrs = Object.entries(camProps).map(([k, v]) => { const attr = k.replace(/([A-Z])/g, "-$1").toLowerCase(); - return v === "true" ? attr : `:${attr}="${v}"`; + return `:${attr}="${v}"`; }); - const allVue = [...vueCamAttrs, ...vueSceneAttrs]; + + const vueCamOpen = + vueCamAttrs.length <= 2 + ? ` <${cameraTag}${vueCamAttrs.length ? " " + vueCamAttrs.join(" ") : ""}>` + : ` <${cameraTag}\n` + vueCamAttrs.map((a) => " " + a).join("\n") + "\n >"; + const v: string[] = [" ``` @@ -94,10 +96,11 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po When you mount a scene through the `createPolyScene` imperative API, pair it with `createPolyOrbitControls(scene, options)`. The controls handle returns a small lifecycle interface for live updates. ```ts -import { createPolyScene, createPolyOrbitControls, loadMesh } from "@layoutit/polycss"; +import { createPolyCamera, createPolyScene, createPolyOrbitControls, createPolyTorus } from "@layoutit/polycss"; -const scene = createPolyScene(host, { rotX: 65, rotY: 45 }); -scene.add(await loadMesh("/cottage.glb")); +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(host, { camera }); +scene.add(createPolyTorus({ color: "#4ecdc4" })); const controls = createPolyOrbitControls(scene, { drag: true, @@ -129,10 +132,12 @@ Use `` (or `createPolyMapControls`) when you want drag to pan ```html - - - - + + + + + + ``` @@ -160,10 +165,12 @@ Disable input but keep autorotate running: ```html - - - - + + + + + + ``` diff --git a/website/src/content/docs/components/poly-scene.mdx b/website/src/content/docs/components/poly-scene.mdx index b4da7ffe..392c566a 100644 --- a/website/src/content/docs/components/poly-scene.mdx +++ b/website/src/content/docs/components/poly-scene.mdx @@ -5,7 +5,7 @@ description: Scene component that sets up the 3D viewport, camera, and lighting import { Tabs, TabItem } from '@astrojs/starlight/components'; -The scene is the root of every polycss render tree. It applies scene-level lighting and atlas options, then renders its children (typically meshes or individual polygons) in 3D space. Vanilla `` also accepts camera attributes; React and Vue scenes receive camera state from a surrounding `` / `` / ``. +The scene is the root of every polycss render tree. It applies scene-level lighting and atlas options, then renders its children (typically meshes or individual polygons) in 3D space. `` / `PolyScene` must always be nested inside a camera element (`` / `PolyCamera` or the perspective variant): the camera owns the projection and orbital state. It's available as a custom element (``), via the imperative `createPolyScene(host, opts)` API, and as React / Vue components (``). @@ -22,7 +22,7 @@ It's available as a custom element (``), via the imperative `createP | `polygons` | `Polygon[]` | None | (Framework only.) Flat array of polygon objects rendered as direct children. Composes with JSX/slot children. | | `children` | None | None | `` / `` / `` (vanilla) or `` / `` / `` (React / Vue). | -**Camera state and input** are separate layers. For vanilla, set camera attributes (`rot-x`, `rot-y`, `zoom`, `perspective`) on ``. For React/Vue, set those props on ``. Add a child `` / `` to enable drag, wheel, or autorotate: see [PolyOrbitControls](/components/poly-controls). +**Camera state and input** are set on the wrapping camera element (`` / `PolyCamera`): `rot-x`, `rot-y`, `zoom`, `distance`. `` carries no camera attributes. Add a child `` / `` to enable drag, wheel, or autorotate: see [PolyOrbitControls](/components/poly-controls). ## Mesh props / attributes @@ -62,27 +62,29 @@ interface PolyAmbientLight { ### Basic Scene -A scene with a single GLB mesh at default camera angle. +A scene with a dodecahedron at default camera angle. ```html - - - + + + + + ``` ```tsx -import { PolyCamera, PolyScene, PolyMesh } from "@layoutit/polycss-react"; +import { PolyCamera, PolyScene, PolyDodecahedron } from "@layoutit/polycss-react"; export function App() { return ( - + - + ); @@ -92,15 +94,15 @@ export function App() { ```vue ``` @@ -109,15 +111,17 @@ import { PolyCamera, PolyScene, PolyMesh } from "@layoutit/polycss-vue"; ### Scene with Camera and Lighting ```tsx - +import { PolyPerspectiveCamera, PolyScene, PolyTorus, PolyBox } from "@layoutit/polycss-react"; + + - - + + - + ``` ### Multiple Meshes @@ -125,8 +129,8 @@ import { PolyCamera, PolyScene, PolyMesh } from "@layoutit/polycss-vue"; ```tsx - - + + ``` diff --git a/website/src/content/docs/core-concepts.mdx b/website/src/content/docs/core-concepts.mdx index e43a7829..05362a12 100644 --- a/website/src/content/docs/core-concepts.mdx +++ b/website/src/content/docs/core-concepts.mdx @@ -23,7 +23,7 @@ Where voxcss used to render a single voxel cube, polycss renders any regular pol polycss exposes three composable concepts. Each one ships as a custom element (vanilla) and as a React / Vue component: -- **Scene** (`` / `PolyScene`): the render tree root. Vanilla `` also owns camera attributes; React and Vue scenes render inside ``. Sets up lighting and fills its parent element. +- **Scene** (`` / `PolyScene`): the render tree root. Always nested inside a camera element. Sets up lighting and fills its parent element. - **Mesh** (`` / `PolyMesh`): loads a mesh from a URL (OBJ / glTF / GLB / VOX). Internally expands to one polygon child per face. Convenience wrapper around the parser + renderer. - **Polygon** (`` / `Poly`): one polygon. The atomic primitive. Renders one atlas-backed `` with `transform: matrix3d(...)`. Accepts standard DOM event handlers, classes, and styles: this is what makes polycss "DOM-native 3D" rather than "3D inside a black-box canvas". @@ -31,24 +31,22 @@ A mesh element is internally `polygons.map(p => )`, so any ren ## Camera -Vanilla scenes accept camera attributes directly: `rot-x`, `rot-y`, `zoom`, and `perspective`. React and Vue use a `` wrapper for camera state, with `` nested inside it. +The camera element (`` / `PolyCamera`) is always the **outer** node. `` / `PolyScene` is nested inside it. Camera attributes (`rot-x`, `rot-y`, `zoom`, `distance`) belong on the camera element, never on the scene. `PolyCamera` is orthographic by default; use `PolyPerspectiveCamera` for depth foreshortening. ```html - - - + + + + + ``` ```tsx // React - + - + ``` @@ -89,12 +87,12 @@ Scene content renders relative to the (0,0,0) origin. Most mesh files are author ```html - + ``` ```tsx // React - + ``` ## Rendering Pipeline @@ -103,7 +101,7 @@ polycss is structured in three layers: 1. **Core** (`@layoutit/polycss-core`): Pure math and parsing. Handles OBJ / glTF / GLB / VOX parsing, UV decoding, lighting math, and polygon normalization. No DOM dependency. 2. **Triangle Renderer**: Takes parsed polygons and produces DOM elements. It runs a one-time off-DOM canvas atlas pass for both textured and flat-color polygons (UV affine transform when available → clip → drawImage or fill → atlas Blob URL → CSS background-position). -3. **Entry points**: The vanilla `polycss` package exposes custom elements (``, ``, ``) plus an imperative `createPolyScene` API; this is the default surface and what the rest of these docs use first. Thin React (`@layoutit/polycss-react`) and Vue (`@layoutit/polycss-vue`) bindings (`PolyScene`, `PolyMesh`, `Poly`, `PolyCamera`) wrap the same renderer with framework-native reactivity, lifecycle, and prop updates. +3. **Entry points**: The vanilla `polycss` package exposes custom elements (``, ``, ``, ``) plus an imperative `createPolyCamera` / `createPolyScene` API; this is the default surface and what the rest of these docs use first. Thin React (`@layoutit/polycss-react`) and Vue (`@layoutit/polycss-vue`) bindings (`PolyCamera`, `PolyScene`, `PolyMesh`, `Poly`) wrap the same renderer with framework-native reactivity, lifecycle, and prop updates. ## Automatic Polygon Merge diff --git a/website/src/content/docs/guides/performance.mdx b/website/src/content/docs/guides/performance.mdx index b01d07a4..657ce2cb 100644 --- a/website/src/content/docs/guides/performance.mdx +++ b/website/src/content/docs/guides/performance.mdx @@ -61,21 +61,23 @@ Generated atlas pages default to `textureQuality="auto"`. Auto starts from the p ```html - - - + + + + + ``` ```tsx // React - + // Or per mesh: - + ``` Use explicit numeric values when you want to override auto raster scale: `0.5` or `0.75` for distant or dense assets, `1` for close-up inspection when the runtime bitmap cost is acceptable. Numeric quality keeps the 64px atlas sprite size. diff --git a/website/src/content/docs/guides/projections.mdx b/website/src/content/docs/guides/projections.mdx index 969606cb..e1c69e21 100644 --- a/website/src/content/docs/guides/projections.mdx +++ b/website/src/content/docs/guides/projections.mdx @@ -6,7 +6,7 @@ description: Choosing perspective depth and camera angles for different visual s import { Tabs, TabItem } from '@astrojs/starlight/components'; import PolyDemo from '../../../components/PolyDemo.astro'; -polycss uses CSS 3D perspective to project the scene. The `perspective` attribute on vanilla `` (or `perspective` prop on React/Vue ``) controls the depth illusion, and `rot-x` / `rot-y` control the camera angle. +polycss uses CSS 3D perspective to project the scene. Camera attributes (`rot-x`, `rot-y`, `zoom`, `perspective`) live on the wrapping camera element — `` for orthographic (default) or `` for perspective — never on ``. `PolyCamera` is orthographic by default; pick `PolyPerspectiveCamera` when depth foreshortening is needed. ## Live demo: camera controls @@ -21,75 +21,81 @@ Use the sliders below to explore how `perspective`, `rotX`, and `rotY` interact. ## Perspective depth -Higher `perspective` values feel flatter (more isometric-like); lower values exaggerate depth. For orthographic projection (no perspective distortion), use `` (React/Vue) or `perspective="false"` (vanilla). +Higher `perspective` values feel flatter (more isometric-like); lower values exaggerate depth. For orthographic projection (no perspective distortion, the default), use `` / `PolyCamera`. For perspective distortion, use `` / `PolyPerspectiveCamera`. ```html + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + ``` ```tsx -import { PolyCamera, PolyScene, PolyMesh, PolyOrthographicCamera } from "@layoutit/polycss-react"; +import { PolyCamera, PolyPerspectiveCamera, PolyScene, PolyIcosahedron } from "@layoutit/polycss-react"; -// Standard perspective - +// Orthographic (default) + - + -// Very flat / near-isometric - +// Standard perspective + - + - + -// Orthographic: use PolyOrthographicCamera, not perspective={false} - +// Very flat / near-isometric perspective + - + - + ``` ```vue ``` @@ -97,16 +103,16 @@ import { PolyCamera, PolyScene, PolyMesh, PolyOrthographicCamera } from "@layout ## Camera angles -Use `rot-x` and `rot-y` on vanilla ``, or `rotX` / `rotY` on React/Vue ``, to position the camera: +Use `rot-x` and `rot-y` on the camera element to position the camera: - `rotX`: vertical tilt. `90` is straight-down top view; `0` is horizontal. - `rotY`: horizontal rotation (0–360). Controls which face of the scene is forward. ```html - - - + + + ``` ```tsx diff --git a/website/src/content/docs/guides/shapes.mdx b/website/src/content/docs/guides/shapes.mdx index 36753bff..14096a39 100644 --- a/website/src/content/docs/guides/shapes.mdx +++ b/website/src/content/docs/guides/shapes.mdx @@ -45,15 +45,17 @@ const polygons = boxPolygons({ ```html - - - - - + + + + + + + ``` @@ -62,7 +64,7 @@ import { PolyCamera, PolyScene, Poly } from "@layoutit/polycss-react"; const triangle: Vec3[] = [[0,0,0], [1,0,0], [0,1,0]]; - + @@ -84,9 +86,11 @@ const triangle: Vec3[] = [[0,0,0], [1,0,0], [0,1,0]]; poly-polygon { transition: filter 0.2s; } - - - + + + + + - - - + + + + + ``` @@ -41,7 +46,10 @@ export function App() { return ( - + ); @@ -53,7 +61,10 @@ export function App() { @@ -115,12 +126,14 @@ For programmatic loading with explicit lifecycle, use `loadMesh` from the core p ```ts // Vanilla: works anywhere, no framework -import { loadMesh, createPolyScene } from "@layoutit/polycss"; +import { createPolyCamera, loadMesh, createPolyScene } from "@layoutit/polycss"; -const result = await loadMesh("/cottage.glb", { +const result = await loadMesh("https://polycss.com/gallery/obj/cottage.obj", { + mtlUrl: "https://polycss.com/gallery/obj/cottage.mtl", gltfOptions: { targetSize: 60 }, }); -const scene = createPolyScene(document.getElementById("host")!); +const camera = createPolyCamera({ rotX: 65, rotY: 45 }); +const scene = createPolyScene(document.getElementById("host")!, { camera }); scene.add(result); // later: scene.destroy(); // removes the scene and disposes registered meshes @@ -131,7 +144,9 @@ scene.destroy(); // removes the scene and disposes registered meshes import { PolyCamera, PolyScene, Poly, usePolyMesh } from "@layoutit/polycss-react"; function Viewer() { - const { polygons, loading, error } = usePolyMesh("/cottage.glb"); + const { polygons, loading, error } = usePolyMesh("https://polycss.com/gallery/obj/cottage.obj", { + mtlUrl: "https://polycss.com/gallery/obj/cottage.mtl", + }); if (loading) return
Loading...
; if (error) return
Error: {error}
; diff --git a/website/src/content/docs/introduction.mdx b/website/src/content/docs/introduction.mdx index befa892b..1de69877 100644 --- a/website/src/content/docs/introduction.mdx +++ b/website/src/content/docs/introduction.mdx @@ -11,7 +11,7 @@ Internally, polycss uses an off-DOM canvas to pack textured and flat-color polyg ## Framework Support -polycss is **vanilla-first**. The default entry point is custom elements (``, ``, ``) plus an imperative `createPolyScene` API: no framework required. First-class bindings for **React** and **Vue** ship as separate packages on top of the same engine. Pick whatever fits your stack. +polycss is **vanilla-first**. The default entry point is custom elements (``, ``, ``, ``) plus an imperative `createPolyCamera` / `createPolyScene` API: no framework required. First-class bindings for **React** and **Vue** ship as separate packages on top of the same engine. Pick whatever fits your stack. ## Installation @@ -43,26 +43,28 @@ You can also load polycss directly from a CDN with no build step: ## A quick taste -Load a GLB mesh into an interactive scene with zero JS: just custom elements: +Render a 3D shape with zero JS: just custom elements: ```html - - - + + + + + ``` Or with React: ```tsx -import { PolyCamera, PolyScene, PolyMesh } from "@layoutit/polycss-react"; +import { PolyCamera, PolyScene, PolyIcosahedron } from "@layoutit/polycss-react"; export function App() { return ( - + - + ); diff --git a/website/src/content/docs/quickstart.mdx b/website/src/content/docs/quickstart.mdx index 21c5f8e5..e22124c5 100644 --- a/website/src/content/docs/quickstart.mdx +++ b/website/src/content/docs/quickstart.mdx @@ -30,27 +30,29 @@ npm install @layoutit/polycss-vue ## 2. Add a scene and load a mesh -The vanilla scene element (``) sets up the 3D viewport: perspective, camera, lighting. In React and Vue, wrap `` in `` to provide the camera context. The mesh element (`` / `PolyMesh`) loads OBJ, glTF, GLB, or VOX files and renders their polygons. Pass `rot-x` / `rot-y` to `` or `` to set the camera angle. +The camera element (`` / `PolyCamera`) is always the outer node: it owns the projection and orbital state. `` / `PolyScene` is nested inside it and carries lighting and atlas options. The mesh element (`` / `PolyMesh`) loads OBJ, glTF, GLB, or VOX files and renders their polygons. `PolyCamera` uses orthographic projection by default; use `PolyPerspectiveCamera` for depth foreshortening. ```html - - - + + + + + ``` ```tsx -import { PolyCamera, PolyScene, PolyMesh } from "@layoutit/polycss-react"; +import { PolyCamera, PolyScene, PolyBox } from "@layoutit/polycss-react"; export function App() { return ( - + - + ); @@ -60,15 +62,15 @@ export function App() { ```vue ``` diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index ec7e7611..0eaf05d1 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -29,23 +29,25 @@ const frameworkTabs = [ language: "html", code: ` - - - -`, + + + + + +`, }, { id: "react", label: "React", language: "tsx", - code: `import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react"; + code: `import { PolyCamera, PolyScene, PolyOrbitControls, PolyIcosahedron } from "@layoutit/polycss-react"; export function App() { return ( - + ); @@ -59,12 +61,12 @@ export function App() { - + `, }, ].map(tab => ({ @@ -166,8 +168,8 @@ const ldJson = {
- +
@@ -224,7 +226,7 @@ const ldJson = {
-

polycss provides custom elements (<poly-scene>, <poly-mesh>), an imperative createPolyScene API, and optional React / Vue bindings. Each polygon in the mesh becomes a DOM element you can style and interact with: use whichever entry point fits your stack.

+

polycss provides custom elements (<poly-camera>, <poly-scene>, <poly-mesh>), an imperative createPolyCamera / createPolyScene API, and optional React / Vue bindings. Each polygon in the mesh becomes a DOM element you can style and interact with: use whichever entry point fits your stack.

@@ -276,13 +278,13 @@ const ldJson = { } fetchStars(); - // Hero model: loads the Apple GLB and mounts it with createPolyScene. + // Hero model: apple GLB, auto-rotated for visual interest. // autoCenter keeps drag gestures pivoted around the mesh centroid. async function mountHeroModel() { const host = document.getElementById("hero-root"); if (!host) return; try { - const { createPolyScene, createPolyOrbitControls, loadMesh } = await import("@layoutit/polycss"); + const { createPolyPerspectiveCamera, createPolyScene, loadMesh } = await import("@layoutit/polycss"); const parseResult = await loadMesh("/gallery/glb/apple.glb", { gltfOptions: { targetSize: 60, @@ -290,20 +292,21 @@ const ldJson = { }, }); - const scene = createPolyScene(host, { + const camera = createPolyPerspectiveCamera({ rotX: 74.4, rotY: 301.6, zoom: 0.3, - autoCenter: true, perspective: 100000, }); + const scene = createPolyScene(host, { camera, autoCenter: true }); scene.add(parseResult); // Auto-rotate the hero apple for visual interest. let rotY = 301.6; function tick() { rotY = (rotY + 1.2) % 360; - scene.setOptions({ rotY }); + camera.update({ rotY }); + scene.applyCamera(); requestAnimationFrame(tick); } requestAnimationFrame(tick);