Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/LayoutitStudio/polycss.git"
"url": "git+https://github.com/LayoutitStudio/polycss.git"
},
"bugs": {
"url": "https://github.com/LayoutitStudio/polycss/issues"
Expand Down Expand Up @@ -52,5 +52,11 @@
"react": "^19.2.6",
"react-dom": "^19.2.6",
"vue": "^3.5.30"
}
},
"main": "index.js",
"directories": {
"example": "examples"
},
"keywords": [],
"author": ""
}
66 changes: 26 additions & 40 deletions packages/core/src/helpers/conePolygons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,60 +26,43 @@ function len(v: Vec3): number {

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 [a, b, c, ...rest] = vertices;
const n = cross(sub(b, a), sub(c, a));
const nl = len(n);
for (const v of vertices) {
const dist = Math.abs(dot(n, sub(v, ref!))) / nl;
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 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 [a, b, c] = vertices;
const n = cross(sub(b, a), sub(c, a));
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;
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;
}

function uniqueVertexCount(vertices: Vec3[]): number {
return new Set(vertices.map((v) => v.map((x) => x.toFixed(6)).join(","))).size;
}

// ── Tests ─────────────────────────────────────────────────────────────────────

describe("conePolygons", () => {
it("returns n side quads + n bottom triangles for default 12 segments (no top cap)", () => {
it("returns n side triangles + 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", () => {
it("side polygons and cap polygons are triangles", () => {
const n = 6;
const polygons = conePolygons({ radialSegments: n });
for (let i = 0; i < n; i++) expect(polygons[i].vertices).toHaveLength(4);
for (let i = 0; i < n; i++) expect(polygons[i].vertices).toHaveLength(3);
for (let i = n; i < 2 * n; i++) expect(polygons[i].vertices).toHaveLength(3);
});

Expand All @@ -93,23 +76,26 @@ describe("conePolygons", () => {

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.
// Side triangles are ordered [bl, br, apex].
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(","));
const apex = polygons[i].vertices[2];
apexPoints.add(apex.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
const apex = polygons[0].vertices[2];
expect(apex[0]).toBeCloseTo(0, 5);
expect(apex[1]).toBeCloseTo(0, 5);
expect(apex[2]).toBeCloseTo(50, 5);
});

it("does not emit degenerate duplicate-vertex side polygons", () => {
const n = 12;
const polygons = conePolygons({ radialSegments: n });
for (let i = 0; i < n; i++) expect(uniqueVertexCount(polygons[i].vertices)).toBe(3);
});

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);
Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/helpers/conePolygons.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
/**
* Y-axis cone geometry — a cylinder with `radiusTop: 0`.
* Z-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`.
* to zero. The top cap is omitted (no area at the tip), and side faces are
* emitted as triangles.
*
* 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.
* Polycss world space: +X right, +Y forward, +Z up. Cone axis is Z; the apex
* is at Z = +height/2 and the base at Z = -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 along the Z axis. Default 100. */
height?: number;
/** Number of radial segments. Default 12. */
radialSegments?: number;
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/helpers/cylinderPolygons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,22 @@ describe("cylinderPolygons", () => {
}
});

it("omits top cap when radiusTop is 0", () => {
it("omits top cap and triangulates sides 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);
for (let i = 0; i < n; i++) expect(polygons[i].vertices).toHaveLength(3);
for (let i = n; i < 2 * n; i++) expect(polygons[i].vertices).toHaveLength(3);
});

it("omits bottom cap and triangulates sides when radius is 0", () => {
const n = 6;
const polygons = cylinderPolygons({ radius: 0, radiusTop: 40, radialSegments: n });
// n sides + n top cap (no bottom cap) = 2n
expect(polygons).toHaveLength(2 * n);
for (let i = 0; i < n; i++) expect(polygons[i].vertices).toHaveLength(3);
for (let i = n; i < 2 * n; i++) expect(polygons[i].vertices).toHaveLength(3);
});

it("supports a tapered cylinder (frustum)", () => {
Expand Down
31 changes: 23 additions & 8 deletions packages/core/src/helpers/cylinderPolygons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* Z-axis cylinder geometry with optional radius taper.
*
* Geometry:
* - `radialSegments` side quads (one per angular segment).
* - `radialSegments` side faces (quads for cylinders/frustums, triangles
* when one radius collapses to a cone tip).
* - `radialSegments` bottom-cap triangles (fan from center).
* - `radialSegments` top-cap triangles (fan from center), omitted when
* radiusTop ≈ 0 (i.e. cone tip).
Expand All @@ -24,13 +25,13 @@ export interface CylinderPolygonsOptions {
radiusTop?: number;
/** Height along the Z axis. Default 100. */
height?: number;
/** Number of radial segments (quads on the side). Default 12. */
/** Number of radial segments. Default 12. */
radialSegments?: number;
/** Fill color applied to all polygons. */
color?: string;
}

/** Threshold below which `radiusTop` is treated as zero (cone tip, no cap). */
/** Threshold below which a radius is treated as zero (cone tip, no cap). */
const RADIUS_ZERO_EPS = 1e-6;

export function cylinderPolygons(options: CylinderPolygonsOptions = {}): Polygon[] {
Expand All @@ -51,10 +52,16 @@ export function cylinderPolygons(options: CylinderPolygonsOptions = {}): 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.
const bottomIsPoint = radius <= RADIUS_ZERO_EPS;
const topIsPoint = radiusTop <= RADIUS_ZERO_EPS;

// ── Side faces ───────────────────────────────────────────────────────────
// One face per segment. Quads use [bl, br, tr, tl]; cone-tip sides use
// triangles so renderers don't receive degenerate quads with duplicate apex
// vertices.
for (let i = 0; i < n; i++) {
if (bottomIsPoint && topIsPoint) break;

const a0 = angles[i];
const a1 = angles[i + 1];
const bx0 = Math.cos(a0) * radius;
Expand All @@ -71,8 +78,16 @@ export function cylinderPolygons(options: CylinderPolygonsOptions = {}): Polygon
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 });
if (topIsPoint) {
const apex: Vec3 = [0, 0, zTop];
polygons.push({ vertices: [bl, br, apex], color });
} else if (bottomIsPoint) {
const apex: Vec3 = [0, 0, zBottom];
polygons.push({ vertices: [apex, tr, tl], color });
} else {
// CCW from outside: cross((br - bl), (tr - bl)) points radially outward.
polygons.push({ vertices: [bl, br, tr, tl], color });
}
}

// ── Bottom cap (radius > 0) ──────────────────────────────────────────────
Expand Down
131 changes: 131 additions & 0 deletions packages/core/src/helpers/primitiveGeometry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import type { Polygon, Vec3 } from "../types";
import { computeTextureAtlasPlanPublic } from "../atlas/plan";
import { arrowPolygons } from "./arrowPolygons";
import { axesHelperPolygons } from "./axesPolygons";
import { boxPolygons } from "./boxPolygons";
import { conePolygons } from "./conePolygons";
import { cylinderPolygons } from "./cylinderPolygons";
import { dodecahedronPolygons } from "./dodecahedronPolygons";
import { icosahedronPolygons } from "./icosahedronPolygons";
import { octahedronPolygons } from "./octahedronPolygons";
import { planePolygons } from "./planePolygons";
import { ringPolygons } from "./ringPolygons";
import { ringQuadPolygons } from "./ringQuadPolygons";
import { spherePolygons } from "./spherePolygons";
import { tetrahedronPolygons } from "./tetrahedronPolygons";
import { torusPolygons } from "./torusPolygons";

const EPS = 1e-8;
const PLANAR_EPS = 1e-4;

function sub(a: Vec3, b: Vec3): Vec3 {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}

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 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.hypot(v[0], v[1], v[2]);
}

function faceNormal(vertices: Vec3[]): Vec3 | null {
const origin = vertices[0];
for (let i = 1; i + 1 < vertices.length; i += 1) {
const n = cross(sub(vertices[i], origin), sub(vertices[i + 1], origin));
const nLen = len(n);
if (nLen > EPS) return [n[0] / nLen, n[1] / nLen, n[2] / nLen];
}
return null;
}

function faceArea(vertices: Vec3[]): number {
const n = faceNormal(vertices);
if (!n) return 0;
const sum: Vec3 = [0, 0, 0];
for (let i = 0; i < vertices.length; i += 1) {
const c = cross(vertices[i], vertices[(i + 1) % vertices.length]);
sum[0] += c[0];
sum[1] += c[1];
sum[2] += c[2];
}
return Math.abs(dot(sum, n)) / 2;
}

function uniqueVertexCount(vertices: Vec3[]): number {
return new Set(vertices.map((v) => v.map((x) => x.toFixed(9)).join(","))).size;
}

function maxPlaneDistance(vertices: Vec3[]): number {
const n = faceNormal(vertices);
if (!n) return Infinity;
const origin = vertices[0];
let max = 0;
for (const v of vertices) {
max = Math.max(max, Math.abs(dot(n, sub(v, origin))));
}
return max;
}

function expectRenderablePrimitive(name: string, polygons: Polygon[]): void {
expect(polygons.length, `${name} polygon count`).toBeGreaterThan(0);
for (let i = 0; i < polygons.length; i += 1) {
const polygon = polygons[i];
expect(polygon.vertices.length, `${name}[${i}] vertex count`).toBeGreaterThanOrEqual(3);
for (const vertex of polygon.vertices) {
expect(vertex.every(Number.isFinite), `${name}[${i}] finite vertex`).toBe(true);
}
expect(uniqueVertexCount(polygon.vertices), `${name}[${i}] duplicate vertices`).toBe(polygon.vertices.length);
expect(faceArea(polygon.vertices), `${name}[${i}] face area`).toBeGreaterThan(EPS);
if (polygon.vertices.length > 3) {
expect(maxPlaneDistance(polygon.vertices), `${name}[${i}] coplanarity`).toBeLessThanOrEqual(PLANAR_EPS);
}
expect(computeTextureAtlasPlanPublic(polygon, i), `${name}[${i}] atlas plan`).not.toBeNull();
}
}

describe("primitive geometry invariants", () => {
const cases: Array<[string, () => Polygon[]]> = [
["box", () => boxPolygons()],
["plane x", () => planePolygons({ axis: 0 })],
["plane y", () => planePolygons({ axis: 1 })],
["plane z", () => planePolygons({ axis: 2 })],
["ring x", () => ringPolygons({ axis: 0, radius: 2 })],
["ring y", () => ringPolygons({ axis: 1, radius: 2 })],
["ring z", () => ringPolygons({ axis: 2, radius: 2 })],
["ring quad x", () => ringQuadPolygons({ axis: 0, outerRadius: 2 })],
["ring quad y", () => ringQuadPolygons({ axis: 1, outerRadius: 2 })],
["ring quad z", () => ringQuadPolygons({ axis: 2, outerRadius: 2 })],
["cylinder", () => cylinderPolygons()],
["cylinder top cone-tip", () => cylinderPolygons({ radiusTop: 0 })],
["cylinder bottom cone-tip", () => cylinderPolygons({ radius: 0, radiusTop: 50 })],
["cone", () => conePolygons()],
["tetrahedron", () => tetrahedronPolygons()],
["octahedron", () => octahedronPolygons({ center: [0, 0, 0], size: 100 })],
["icosahedron", () => icosahedronPolygons()],
["dodecahedron", () => dodecahedronPolygons()],
["sphere", () => spherePolygons()],
["torus", () => torusPolygons()],
["axes helper", () => axesHelperPolygons()],
["arrow x", () => arrowPolygons({ axis: 0 })],
["arrow y", () => arrowPolygons({ axis: 1 })],
["arrow z", () => arrowPolygons({ axis: 2 })],
["arrow negative x", () => arrowPolygons({ axis: 0, sign: -1 })],
["arrow negative y", () => arrowPolygons({ axis: 1, sign: -1 })],
["arrow negative z", () => arrowPolygons({ axis: 2, sign: -1 })],
];

it.each(cases)("%s emits renderable non-degenerate polygons", (name, build) => {
expectRenderablePrimitive(name, build());
});
});
Loading
Loading