Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
e577f9f
docs: add API_DESIGN.md unified-shape spec
apresmoi May 19, 2026
cc30f25
feat(polycss): register <poly-first-person-controls> custom element
apresmoi May 19, 2026
4b787e5
feat(polycss): add createPolyCamera as alias for createPolyOrthograph…
apresmoi May 19, 2026
d146897
feat(polycss): register <poly-camera> as alias for <poly-orthographic…
apresmoi May 19, 2026
af29c40
fix(vue): replace atlas-scale with textureQuality in README
apresmoi May 19, 2026
abb2eba
feat(core): add tetrahedronPolygons helper
apresmoi May 19, 2026
1f16748
feat(core): add icosahedronPolygons helper
apresmoi May 19, 2026
0dffa89
feat(core): add dodecahedronPolygons helper
apresmoi May 19, 2026
df19f0e
feat(core): add cylinderPolygons helper
apresmoi May 19, 2026
b94f7c1
feat(core): add conePolygons helper
apresmoi May 19, 2026
e1bf6ff
feat(core): add torusPolygons helper
apresmoi May 19, 2026
84969c0
feat(core): export new polygon generators from package surface
apresmoi May 19, 2026
bbf0d85
feat(react)!: repoint PolyCamera alias to PolyOrthographicCamera
apresmoi May 19, 2026
7603dfd
feat(vue)!: repoint PolyCamera alias to PolyOrthographicCamera
apresmoi May 19, 2026
bd40f73
feat(polycss)!: require camera handle on createPolyScene; scene no lo…
apresmoi May 19, 2026
25ef24a
docs(agents): repoint PolyCamera alias to ortho; drop PolyControls
apresmoi May 19, 2026
37b026c
feat(website): update landing hero and tabs to new camera-wraps-scene…
apresmoi May 19, 2026
7ac415f
docs(website): rewrite component docs for unified camera-wraps-scene API
apresmoi May 19, 2026
efce30b
docs(website): update intro, quickstart, guides for camera-wraps-scen…
apresmoi May 19, 2026
23e1ae5
docs(website): fix textures and headless API docs for camera-wraps-sc…
apresmoi May 19, 2026
85c11fc
feat(polycss): add createPoly* shape factories and export polygon gen…
apresmoi May 19, 2026
861e681
feat(polycss): add poly-box, poly-plane, poly-ring, poly-octahedron, …
apresmoi May 19, 2026
6b692ed
feat(react): add PolyBox, PolyPlane, PolyRing, PolyOctahedron, PolyTe…
apresmoi May 19, 2026
d3e846c
feat(vue): add PolyBox, PolyPlane, PolyRing, PolyOctahedron, PolyTetr…
apresmoi May 19, 2026
93957df
feat(react): add meshResolution top-level prop on PolyMesh
apresmoi May 19, 2026
017ff33
feat(vue): add meshResolution top-level prop on PolyMesh
apresmoi May 19, 2026
c837f84
feat(polycss): <poly-mesh mesh-resolution> top-level attribute
apresmoi May 19, 2026
2b6a63b
feat(polycss): meshResolution option on scene.add()
apresmoi May 19, 2026
75ffbfb
fix(website): restore apple hero + use PolyIcosahedron in framework tabs
apresmoi May 20, 2026
7b832cb
fix(website): migrate VanillaScene to camera-handle API
apresmoi May 20, 2026
ee9de2c
fix(website): migrate PolyDemo generator scene to camera-handle API
apresmoi May 20, 2026
9271e62
feat(gallery): add primitive kind and Primitives bucket to types
apresmoi May 20, 2026
3b14549
feat(gallery): insert Primitives bucket first in GALLERY_BUCKET_ORDER
apresmoi May 20, 2026
7ea02e4
feat(gallery): handle primitive kind in loadPresetModel
apresmoi May 20, 2026
207146f
feat(gallery): add 10 primitive shape presets to the gallery
apresmoi May 20, 2026
a5206ed
fix(website): rewrite updateCode() to camera-wraps-scene API
apresmoi May 20, 2026
baf5e12
fix(docs): update orbit controls required scene surface in headless.mdx
apresmoi May 20, 2026
60a34e2
fix(docs): add missing camera wrapper to imperative snippets in guides
apresmoi May 20, 2026
4ba0ed4
fix(gallery): zoom 0.05 for primitive presets
apresmoi May 20, 2026
f47861f
fix(core): cylinderPolygons/conePolygons use Z-axis (polycss world up)
apresmoi May 20, 2026
e398bfd
fix(core): torusPolygons rings in XY plane with Z-axis donut hole
apresmoi May 20, 2026
c02e847
fix(docs): replace broken /cottage.glb with https://polycss.com/galle…
apresmoi May 20, 2026
ca3a6f1
docs: swap cottage.obj to primitive components in README and API_DESIGN
apresmoi May 20, 2026
35023c8
docs: swap cottage.obj to primitive components in intro, quickstart, …
apresmoi May 20, 2026
7d06091
docs: swap cottage.obj to primitive components in component reference…
apresmoi May 20, 2026
50aab07
docs: swap cottage.obj to primitive components in guides (projections…
apresmoi May 20, 2026
271bc4e
docs: pair cottage.obj with mtl companion in texture and headless API…
apresmoi May 20, 2026
a42c824
feat(core): add spherePolygons (icosphere) helper
apresmoi May 20, 2026
b509a4d
feat(polycss): add createPolySphere factory
apresmoi May 20, 2026
e226f9e
feat(polycss): register <poly-sphere> custom element
apresmoi May 20, 2026
b96d17d
feat(react): add PolySphere component
apresmoi May 20, 2026
caf2f70
feat(vue): add PolySphere component
apresmoi May 20, 2026
0c83994
docs: ship PolySphere in API_DESIGN.md
apresmoi May 20, 2026
acaf52c
feat(gallery): add Sphere to Primitives bucket
apresmoi May 20, 2026
e687390
chore: drop API_DESIGN.md from tracking (working doc, kept local)
apresmoi May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<poly-scene>`, `<poly-mesh>`, `<poly-polygon>`, `<poly-controls>`, `<poly-axes-helper>`, `<poly-directional-light-helper>`. Any new element follows the same shape (e.g. `<poly-perspective-camera>`, `<poly-transform-controls>`, `<poly-select>`).
- **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createTransformControls`, `createSelect`).
- **HTML custom elements:** `poly-` prefix + kebab-case. Existing tags: `<poly-scene>`, `<poly-mesh>`, `<poly-polygon>`, `<poly-perspective-camera>`, `<poly-orthographic-camera>`, `<poly-axes-helper>`, `<poly-directional-light-helper>`. Any new element follows the same shape (e.g. `<poly-transform-controls>`, `<poly-select>`).
- **Leaf DOM tags (`<b>`, `<i>`, `<s>`, `<u>`):** 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

Expand Down
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<PolyCamera rotX={65} rotY={45}>
<PolyScene>
<PolyOrbitControls drag wheel />
<PolyMesh src="/cottage.glb" />
<PolyIcosahedron size={100} color="#ff6644" />
</PolyScene>
</PolyCamera>
);
Expand All @@ -45,13 +45,13 @@ export function App() {
<PolyCamera :rot-x="65" :rot-y="45">
<PolyScene>
<PolyOrbitControls drag wheel />
<PolyMesh src="/cottage.glb" />
<PolyIcosahedron :size="100" color="#4ecdc4" />
</PolyScene>
</PolyCamera>
</template>

<script setup lang="ts">
import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-vue";
import { PolyCamera, PolyScene, PolyOrbitControls, PolyIcosahedron } from "@layoutit/polycss-vue";
</script>
```

Expand All @@ -60,10 +60,12 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po
```html
<script type="module" src="https://esm.sh/@layoutit/polycss/elements"></script>

<poly-scene rot-x="65" rot-y="45">
<poly-orbit-controls drag wheel></poly-orbit-controls>
<poly-mesh src="/cottage.glb"></poly-mesh>
</poly-scene>
<poly-camera rot-x="65" rot-y="45">
<poly-scene>
<poly-orbit-controls drag wheel></poly-orbit-controls>
<poly-icosahedron size="100" color="#ffd166"></poly-icosahedron>
</poly-scene>
</poly-camera>
```

## Per-polygon interactivity
Expand Down
125 changes: 125 additions & 0 deletions packages/core/src/helpers/conePolygons.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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);
}
});
});
27 changes: 27 additions & 0 deletions packages/core/src/helpers/conePolygons.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
120 changes: 120 additions & 0 deletions packages/core/src/helpers/cylinderPolygons.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading