diff --git a/package.json b/package.json index 201718f9..a65fa360 100644 --- a/package.json +++ b/package.json @@ -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" @@ -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": "" } diff --git a/packages/core/src/helpers/conePolygons.test.ts b/packages/core/src/helpers/conePolygons.test.ts index 28bae891..d1923ec6 100644 --- a/packages/core/src/helpers/conePolygons.test.ts +++ b/packages/core/src/helpers/conePolygons.test.ts @@ -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); }); @@ -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(); 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); diff --git a/packages/core/src/helpers/conePolygons.ts b/packages/core/src/helpers/conePolygons.ts index ffb8547f..18f60f52 100644 --- a/packages/core/src/helpers/conePolygons.ts +++ b/packages/core/src/helpers/conePolygons.ts @@ -1,12 +1,12 @@ /** - * 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"; @@ -14,7 +14,7 @@ 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; diff --git a/packages/core/src/helpers/cylinderPolygons.test.ts b/packages/core/src/helpers/cylinderPolygons.test.ts index 339d4313..0eab8ba2 100644 --- a/packages/core/src/helpers/cylinderPolygons.test.ts +++ b/packages/core/src/helpers/cylinderPolygons.test.ts @@ -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)", () => { diff --git a/packages/core/src/helpers/cylinderPolygons.ts b/packages/core/src/helpers/cylinderPolygons.ts index fa00ecb0..415e2d88 100644 --- a/packages/core/src/helpers/cylinderPolygons.ts +++ b/packages/core/src/helpers/cylinderPolygons.ts @@ -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). @@ -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[] { @@ -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; @@ -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) ────────────────────────────────────────────── diff --git a/packages/core/src/helpers/primitiveGeometry.test.ts b/packages/core/src/helpers/primitiveGeometry.test.ts new file mode 100644 index 00000000..a4e99a64 --- /dev/null +++ b/packages/core/src/helpers/primitiveGeometry.test.ts @@ -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()); + }); +}); diff --git a/packages/core/src/parser/parseGltf.test.ts b/packages/core/src/parser/parseGltf.test.ts index 96fe0e96..d8347643 100644 --- a/packages/core/src/parser/parseGltf.test.ts +++ b/packages/core/src/parser/parseGltf.test.ts @@ -295,6 +295,9 @@ function buildTriangleGlb(opts?: { mode?: number; includeTexcoord?: boolean; texcoords?: number[]; + includeTexcoord1?: boolean; + texcoords1?: number[]; + textureTexCoord?: number; textureUrl?: string; alphaMode?: string; doubleSided?: boolean; @@ -310,12 +313,16 @@ function buildTriangleGlb(opts?: { const texcoords = opts?.includeTexcoord ? (opts.texcoords ?? [0, 0, 1, 0, 0, 1]) // 3 UV pairs : []; + const texcoords1 = opts?.includeTexcoord1 + ? (opts.texcoords1 ?? [0, 0, 1, 0, 0, 1]) + : []; // Build binary buffer let totalBytes = positions.length * 4; const posStart = 0; let idxStart = -1, idxBytes = 0; let uvStart = -1; + let uv1Start = -1; if (opts?.indexed !== false) { idxBytes = indices.length * 2; // UNSIGNED_SHORT @@ -328,6 +335,10 @@ function buildTriangleGlb(opts?: { uvStart = totalBytes; totalBytes += texcoords.length * 4; } + if (opts?.includeTexcoord1) { + uv1Start = totalBytes; + totalBytes += texcoords1.length * 4; + } const bin = new Uint8Array(totalBytes); const binView = new DataView(bin.buffer); @@ -348,6 +359,11 @@ function buildTriangleGlb(opts?: { binView.setFloat32(uvStart + i * 4, texcoords[i], true); } } + if (opts?.includeTexcoord1 && uv1Start >= 0) { + for (let i = 0; i < texcoords1.length; i++) { + binView.setFloat32(uv1Start + i * 4, texcoords1[i], true); + } + } // glTF doc const accessors: object[] = [ @@ -390,6 +406,18 @@ function buildTriangleGlb(opts?: { }); bufferViews.push({ buffer: 0, byteOffset: uvStart, byteLength: texcoords.length * 4 }); } + let uv1AccessorIdx: number | undefined = undefined; + if (opts?.includeTexcoord1 && uv1Start >= 0) { + uv1AccessorIdx = accessors.length; + accessors.push({ + bufferView: bufferViews.length, + byteOffset: 0, + componentType: 5126, + count: 3, + type: "VEC2", + }); + bufferViews.push({ buffer: 0, byteOffset: uv1Start, byteLength: texcoords1.length * 4 }); + } const materials: object[] = []; let materialIdx: number | undefined = undefined; @@ -405,7 +433,10 @@ function buildTriangleGlb(opts?: { if (opts?.textureUrl) { mat.pbrMetallicRoughness = { ...((mat.pbrMetallicRoughness as object | undefined) ?? {}), - baseColorTexture: { index: 0 }, + baseColorTexture: { + index: 0, + ...(opts.textureTexCoord !== undefined ? { texCoord: opts.textureTexCoord } : {}), + }, }; } materials.push(mat); @@ -413,6 +444,7 @@ function buildTriangleGlb(opts?: { const primitiveAttrs: Record = { POSITION: 0 }; if (uvAccessorIdx !== undefined) primitiveAttrs.TEXCOORD_0 = uvAccessorIdx; + if (uv1AccessorIdx !== undefined) primitiveAttrs.TEXCOORD_1 = uv1AccessorIdx; const primitive: Record = { attributes: primitiveAttrs, @@ -841,9 +873,18 @@ describe("parseGltf", () => { expect(result.polygons[0].color).toBe("#ff0000"); }); - it("PBR baseColorFactor alpha is preserved in the polygon color", () => { + it("PBR baseColorFactor alpha is ignored for default opaque materials", () => { + const { glb } = buildTriangleGlb({ + materialColor: [0, 0.5, 1, 0.25], + }); + const result = parseGltf(glb); + expect(result.polygons[0].color).toBe("#0080ff"); + }); + + it("PBR baseColorFactor alpha is preserved for blend materials", () => { const { glb } = buildTriangleGlb({ materialColor: [0, 0.5, 1, 0.25], + alphaMode: "BLEND", }); const result = parseGltf(glb); expect(result.polygons[0].color).toBe("rgba(0, 128, 255, 0.25)"); @@ -1189,6 +1230,21 @@ describe("parseGltf", () => { expect(poly.uvs?.some(([u]) => u > 1)).toBe(true); }); + it("uses the baseColorTexture texCoord set instead of always TEXCOORD_0", () => { + const { glb } = buildTriangleGlb({ + includeTexcoord: true, + texcoords: [0, 0, 0, 0, 0, 0], + includeTexcoord1: true, + texcoords1: [0.25, 0.75, 0.5, 0.75, 0.25, 0.5], + textureTexCoord: 1, + textureUrl: "texture.png", + }); + + const result = parseGltf(glb); + + expect(result.polygons[0].uvs).toEqual([[0.25, 0.25], [0.5, 0.25], [0.25, 0.5]]); + }); + it("no texture → uvs not set on polygon", () => { const { glb } = buildTriangleGlb({ includeTexcoord: true }); const result = parseGltf(glb); diff --git a/packages/core/src/parser/parseGltf.ts b/packages/core/src/parser/parseGltf.ts index a5e98aac..21d064f8 100644 --- a/packages/core/src/parser/parseGltf.ts +++ b/packages/core/src/parser/parseGltf.ts @@ -118,6 +118,7 @@ interface GltfBufferView { } interface GltfTextureInfo { index: number; // index into doc.textures[] + texCoord?: number; } interface GltfMaterial { name?: string; @@ -538,6 +539,7 @@ interface GltfMaterialTextureInfo { url: string; wrap: PolyTextureWrap; alphaMode: PolyTextureAlphaMode; + texCoord: number; } function gltfWrapMode(value: number | undefined): PolyTextureWrapMode { @@ -581,19 +583,24 @@ function buildMaterialTextureMap(doc: GltfDoc, imageUrls: string[]): Map Math.max(0, Math.min(1, n)); const toHex = (n: number) => Math.round(clamp01(n) * 255).toString(16).padStart(2, "0"); const toByte = (n: number) => Math.round(clamp01(n) * 255); - const alpha = clamp01(c[3] ?? 1); + const alpha = alphaMode === "opaque" ? 1 : clamp01(c[3] ?? 1); return alpha < 1 ? `rgba(${toByte(c[0])}, ${toByte(c[1])}, ${toByte(c[2])}, ${Math.round(alpha * 1000) / 1000})` : `#${toHex(c[0])}${toHex(c[1])}${toHex(c[2])}`; @@ -1343,11 +1350,13 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp } const material = prim.material !== undefined ? doc.materials?.[prim.material] : undefined; + const materialAlphaMode = gltfAlphaMode(material?.alphaMode); const matName = material?.name; const matOverride = matName ? materialOverrides[matName] : undefined; const color = matOverride ?? colorFromMaterial( material, - defaultColor + defaultColor, + materialAlphaMode, ); const doubleSided = material?.doubleSided === true; const materialTextureInfo = prim.material !== undefined ? matTexMap.get(prim.material) : undefined; @@ -1360,8 +1369,9 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp ? materialTextureInfo?.wrap : undefined; const textureAlphaMode = texture - ? materialTextureInfo?.alphaMode ?? gltfAlphaMode(material?.alphaMode) + ? materialTextureInfo?.alphaMode ?? materialAlphaMode : undefined; + const textureTexCoord = texture ? materialTextureInfo?.texCoord ?? 0 : 0; const { array: posArr, count: vertCount } = readAccessor(doc, buffers, prim.attributes.POSITION); if (!(posArr instanceof Float32Array)) continue; @@ -1374,7 +1384,7 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp } let uvs: Vec2[] | null = null; - const uvAccIdx = prim.attributes.TEXCOORD_0; + const uvAccIdx = prim.attributes[`TEXCOORD_${textureTexCoord}`]; if (texture && uvAccIdx !== undefined) { const { array: uvArr, count: uvCount } = readAccessor(doc, buffers, uvAccIdx); uvs = []; diff --git a/packages/core/src/parser/solidTextureSamples.test.ts b/packages/core/src/parser/solidTextureSamples.test.ts index 1a953ee0..b6fa7c68 100644 --- a/packages/core/src/parser/solidTextureSamples.test.ts +++ b/packages/core/src/parser/solidTextureSamples.test.ts @@ -57,6 +57,16 @@ function texturedTriangle(vertices: Polygon["vertices"]): Polygon { }; } +function pointSampledTexturedTriangle(vertices: Polygon["vertices"], uv: [number, number]): Polygon { + return { + vertices, + color: "#000000", + texture: "texture.png", + uvs: [uv, uv, uv], + textureTriangles: [{ uvs: [uv, uv, uv] }], + }; +} + describe("bakeSolidTextureSamples", () => { it("reuses baked solid texture colors for animated samples", async () => { installSolidTextureEnv([10, 20, 30, 255]); @@ -93,6 +103,40 @@ describe("bakeSolidTextureSamples", () => { expect(frame[0].textureTriangles).toBeUndefined(); }); + it("normalizes close baked texture swatch colors", async () => { + installSolidTextureDataEnv(2, 1, [ + 254, 202, 74, 255, + 254, 193, 68, 255, + ]); + const dominantUv: [number, number] = [0.25, 0.5]; + const closeUv: [number, number] = [0.75, 0.5]; + const authoredCloseColor: Polygon = { + vertices: [[0, 2, 0], [1, 2, 0], [0, 3, 0]], + color: "#fec144", + }; + const result: ParseResult = { + polygons: [ + pointSampledTexturedTriangle([[0, 0, 0], [1, 0, 0], [0, 1, 0]], dominantUv), + pointSampledTexturedTriangle([[1, 0, 0], [2, 0, 0], [1, 1, 0]], dominantUv), + pointSampledTexturedTriangle([[2, 0, 0], [3, 0, 0], [2, 1, 0]], closeUv), + authoredCloseColor, + ], + objectUrls: [], + dispose() {}, + warnings: [], + }; + + const baked = await bakeSolidTextureSamples(result); + + expect(baked.polygons.map((polygon) => polygon.color)).toEqual([ + "#feca4a", + "#feca4a", + "#feca4a", + "#fec144", + ]); + expect(baked.polygons.slice(0, 3).every((polygon) => polygon.texture === undefined)).toBe(true); + }); + it("uses texture wrap when baking repeated UVs into solid colors", async () => { installSolidTextureDataEnv(2, 1, [ 10, 20, 30, 255, diff --git a/packages/core/src/parser/solidTextureSamples.ts b/packages/core/src/parser/solidTextureSamples.ts index 33fe6743..e42f8001 100644 --- a/packages/core/src/parser/solidTextureSamples.ts +++ b/packages/core/src/parser/solidTextureSamples.ts @@ -104,6 +104,13 @@ const DETAIL_EDGE_THRESHOLD = 32; const LOW_DETAIL_MAX_EDGE_RATIO = 0.045; const LOW_DETAIL_MAX_AVERAGE_DELTA = 10; const TRIANGLE_GRID_STEPS = 6; +const BAKED_SWATCH_COLOR_TOLERANCE = 16; + +interface SolidTextureBakeResult { + polygons: Polygon[]; + changed: boolean; + bakedIndices: number[]; +} function textureForPolygon(polygon: Polygon): string | undefined { return polygon.material?.texture ?? polygon.texture; @@ -474,6 +481,53 @@ function bakePolygon(polygon: Polygon, color: string): Polygon { }; } +function normalizeBakedSolidTextureColors(polygons: Polygon[], bakedIndices: readonly number[]): Polygon[] { + if (bakedIndices.length < 2) return polygons; + + const entries: Array<{ index: number; key: string }> = []; + const counts = new Map(); + for (const index of bakedIndices) { + const parsed = cssColorToSampledColor(polygons[index]?.color); + if (!parsed || parsed.a < 255) continue; + const key = colorKey(parsed); + entries.push({ index, key }); + const current = counts.get(key); + if (current) current.count += 1; + else counts.set(key, { color: parsed, count: 1 }); + } + if (counts.size < 2) return polygons; + + const representatives: SampledColor[] = []; + const canonicalByKey = new Map(); + const colorsByFrequency = [...counts.entries()] + .sort((a, b) => b[1].count - a[1].count || a[0].localeCompare(b[0])); + for (const [key, entry] of colorsByFrequency) { + let representative: SampledColor | null = null; + for (const candidate of representatives) { + if (colorsClose(entry.color, candidate, BAKED_SWATCH_COLOR_TOLERANCE)) { + representative = candidate; + break; + } + } + if (!representative) { + representative = entry.color; + representatives.push(representative); + } + canonicalByKey.set(key, colorToCss(representative)); + } + + let changed = false; + const next = polygons.slice(); + for (const entry of entries) { + const canonical = canonicalByKey.get(entry.key); + const polygon = polygons[entry.index]; + if (!canonical || !polygon || polygon.color === canonical) continue; + next[entry.index] = { ...polygon, color: canonical }; + changed = true; + } + return changed ? next : polygons; +} + export function bakeSolidTextureSampledAnimationFrame( frame: Polygon[], bakedColorEntries: readonly SolidTextureBakedColorEntry[], @@ -496,7 +550,7 @@ export function getSolidTextureBakedAnimationInfo( } interface SolidTextureBaker { - bake(polygons: Polygon[]): { polygons: Polygon[]; changed: boolean }; + bake(polygons: Polygon[]): SolidTextureBakeResult; } async function createSolidTextureBaker( @@ -523,9 +577,10 @@ async function createSolidTextureBaker( const tolerance = options.colorTolerance ?? DEFAULT_COLOR_TOLERANCE; const explicitTolerance = options.colorTolerance !== undefined; return { - bake(nextPolygons: Polygon[]): { polygons: Polygon[]; changed: boolean } { + bake(nextPolygons: Polygon[]): SolidTextureBakeResult { let changed = false; - const baked = nextPolygons.map((polygon) => { + const bakedIndices: number[] = []; + const baked = nextPolygons.map((polygon, index) => { const texture = textureForPolygon(polygon); if (!texture) return polygon; const sampler = samplerByTexture.get(texture); @@ -533,9 +588,15 @@ async function createSolidTextureBaker( const color = solidColorForPolygon(polygon, sampler, tolerance, explicitTolerance); if (!color) return polygon; changed = true; + bakedIndices.push(index); return bakePolygon(polygon, color); }); - return { polygons: changed ? baked : nextPolygons, changed }; + if (!changed) return { polygons: nextPolygons, changed: false, bakedIndices }; + return { + polygons: normalizeBakedSolidTextureColors(baked, bakedIndices), + changed: true, + bakedIndices, + }; }, }; } @@ -559,12 +620,10 @@ export async function bakeSolidTextureSamples( const baked = baker.bake(result.polygons); if (!baked.changed) return result; const bakedColorEntries: SolidTextureBakedColorEntry[] = []; - for (let index = 0; index < baked.polygons.length; index++) { + for (const index of baked.bakedIndices) { const polygon = baked.polygons[index]!; const color = polygon.color; - if (polygon !== result.polygons[index] && color) { - bakedColorEntries.push({ index, color }); - } + if (color) bakedColorEntries.push({ index, color }); } const animation = result.animation; const bakedAnimation: ParseAnimationController | undefined = animation diff --git a/website/public/polycss-primitives-banner.png b/website/public/polycss-primitives-banner.png new file mode 100644 index 00000000..cea7166a Binary files /dev/null and b/website/public/polycss-primitives-banner.png differ diff --git a/website/public/polycss-primitives-bluesky-banner.png b/website/public/polycss-primitives-bluesky-banner.png new file mode 100644 index 00000000..8ce45599 Binary files /dev/null and b/website/public/polycss-primitives-bluesky-banner.png differ diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index cdaf64a0..4ff536e7 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import type { PolyMeshHandle as ReactPolyMeshHandle, Polygon, @@ -150,6 +150,215 @@ const DEFAULT_PARSER: ParserOptionsState = { defaultColor: "#8b95a1", }; +const LIGHT_HELPER_TILE = 50; +const LIGHT_HELPER_SELECTOR = ".dn-light-helper"; + +interface ScreenPoint { + x: number; + y: number; +} + +function wrapDegrees(value: number): number { + return ((value % 360) + 360) % 360; +} + +function clampLightElevation(value: number): number { + return Math.max(-90, Math.min(90, value)); +} + +function lightDirectionFromAngles(azimuth: number, elevation: number): ReactVec3 { + const az = (azimuth * Math.PI) / 180; + const el = (elevation * Math.PI) / 180; + const cosEl = Math.cos(el); + return [ + cosEl * Math.sin(az), + cosEl * Math.cos(az), + Math.sin(el), + ]; +} + +function projectLightDirectionToScreen( + direction: ReactVec3, + sceneOptions: SceneOptionsState, + radiusCss: number, +): ReactVec3 { + const [dx, dy, dz] = direction; + const x = dx * radiusCss; + const y = dy * radiusCss; + const z = dz * radiusCss; + const rotY = (sceneOptions.rotY * Math.PI) / 180; + const rotX = (sceneOptions.rotX * Math.PI) / 180; + const cosY = Math.cos(rotY); + const sinY = Math.sin(rotY); + const cosX = Math.cos(rotX); + const sinX = Math.sin(rotX); + const x1 = x * cosY - y * sinY; + const y1 = x * sinY + y * cosY; + const y2 = y1 * cosX - z * sinX; + const z2 = y1 * sinX + z * cosX; + return [x1 * sceneOptions.zoom, y2 * sceneOptions.zoom, z2 * sceneOptions.zoom]; +} + +function lightAnglesFromScreenOffset( + offset: ScreenPoint, + sceneOptions: SceneOptionsState, + radiusCss: number, +): { lightAzimuth: number; lightElevation: number } { + const zoomedRadius = Math.max(1, radiusCss * Math.max(0.001, sceneOptions.zoom)); + let qx = offset.x / zoomedRadius; + let qy = offset.y / zoomedRadius; + const len = Math.hypot(qx, qy); + if (len > 1) { + qx /= len; + qy /= len; + } + + const currentDirection = lightDirectionFromAngles( + sceneOptions.lightAzimuth, + sceneOptions.lightElevation, + ); + const currentProjected = projectLightDirectionToScreen(currentDirection, sceneOptions, 1); + const qzSign = currentProjected[2] >= 0 ? 1 : -1; + const qz = qzSign * Math.sqrt(Math.max(0, 1 - qx * qx - qy * qy)); + + const rotY = (sceneOptions.rotY * Math.PI) / 180; + const rotX = (sceneOptions.rotX * Math.PI) / 180; + const cosY = Math.cos(rotY); + const sinY = Math.sin(rotY); + const cosX = Math.cos(rotX); + const sinX = Math.sin(rotX); + + const x1 = qx; + const y1 = qy * cosX + qz * sinX; + const z = -qy * sinX + qz * cosX; + const dx = x1 * cosY + y1 * sinY; + const dy = -x1 * sinY + y1 * cosY; + const dz = Math.max(-1, Math.min(1, z)); + return { + lightAzimuth: wrapDegrees((Math.atan2(dx, dy) * 180) / Math.PI), + lightElevation: clampLightElevation((Math.asin(dz) * 180) / Math.PI), + }; +} + +function elementScreenCenter(element: HTMLElement): ScreenPoint { + const leaves = Array.from(element.querySelectorAll("b,i,s,u")); + const rects = (leaves.length > 0 ? leaves : [element]) + .map((el) => el.getBoundingClientRect()) + .filter((rect) => rect.width > 0 || rect.height > 0); + if (rects.length === 0) { + const rect = element.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + } + const minX = Math.min(...rects.map((rect) => rect.left)); + const maxX = Math.max(...rects.map((rect) => rect.right)); + const minY = Math.min(...rects.map((rect) => rect.top)); + const maxY = Math.max(...rects.map((rect) => rect.bottom)); + return { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }; +} + +function useLightRotationDrag( + viewportRef: RefObject, + sceneOptions: SceneOptionsState, + helperScale: number, + gizmoDragging: boolean, + onUpdateScene: (partial: Partial) => void, +): void { + const sceneOptionsRef = useRef(sceneOptions); + const helperScaleRef = useRef(helperScale); + const gizmoDraggingRef = useRef(gizmoDragging); + const onUpdateSceneRef = useRef(onUpdateScene); + sceneOptionsRef.current = sceneOptions; + helperScaleRef.current = helperScale; + gizmoDraggingRef.current = gizmoDragging; + onUpdateSceneRef.current = onUpdateScene; + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + + let activePointerId: number | null = null; + let helperTargetScreen = { x: 0, y: 0 }; + let helperGrabOffset = { x: 0, y: 0 }; + let helperRadiusCss = 1; + + const helperDragEnabled = (): boolean => { + const options = sceneOptionsRef.current; + return options.interactive && options.showLight && !gizmoDraggingRef.current; + }; + + const stopDrag = (event: PointerEvent): void => { + if (activePointerId !== event.pointerId) return; + activePointerId = null; + viewport.classList.remove("is-light-rotating"); + try { viewport.releasePointerCapture(event.pointerId); } catch { /* ignore */ } + }; + + const onPointerDown = (event: PointerEvent): void => { + if (activePointerId !== null) return; + if (event.isPrimary === false) return; + if (event.button !== 0) return; + const target = event.target instanceof Element ? event.target : null; + if (target?.closest("[data-poly-transform-controls]")) return; + const helper = target?.closest(LIGHT_HELPER_SELECTOR) ?? null; + if (!helper || !helperDragEnabled()) return; + event.preventDefault(); + event.stopPropagation(); + activePointerId = event.pointerId; + const options = sceneOptionsRef.current; + helperRadiusCss = Math.max(1, helperScaleRef.current * 0.7 * LIGHT_HELPER_TILE); + const helperCenter = elementScreenCenter(helper); + const currentOffset = projectLightDirectionToScreen( + lightDirectionFromAngles(options.lightAzimuth, options.lightElevation), + options, + helperRadiusCss, + ); + helperTargetScreen = { + x: helperCenter.x - currentOffset[0], + y: helperCenter.y - currentOffset[1], + }; + helperGrabOffset = { + x: event.clientX - helperCenter.x, + y: event.clientY - helperCenter.y, + }; + viewport.classList.add("is-light-rotating"); + try { viewport.setPointerCapture(event.pointerId); } catch { /* ignore */ } + }; + + const onPointerMove = (event: PointerEvent): void => { + if (activePointerId !== event.pointerId) return; + if (!helperDragEnabled()) { + stopDrag(event); + return; + } + event.preventDefault(); + const helperCenter = { + x: event.clientX - helperGrabOffset.x, + y: event.clientY - helperGrabOffset.y, + }; + onUpdateSceneRef.current(lightAnglesFromScreenOffset( + { + x: helperCenter.x - helperTargetScreen.x, + y: helperCenter.y - helperTargetScreen.y, + }, + sceneOptionsRef.current, + helperRadiusCss, + )); + }; + + viewport.addEventListener("pointerdown", onPointerDown, { capture: true }); + viewport.addEventListener("pointermove", onPointerMove); + viewport.addEventListener("pointerup", stopDrag); + viewport.addEventListener("pointercancel", stopDrag); + return () => { + viewport.removeEventListener("pointerdown", onPointerDown, { capture: true }); + viewport.removeEventListener("pointermove", onPointerMove); + viewport.removeEventListener("pointerup", stopDrag); + viewport.removeEventListener("pointercancel", stopDrag); + viewport.classList.remove("is-light-rotating"); + }; + }, [viewportRef]); +} + function parserDefaultsFor(model: PresetModel): Partial { const options = model.options as (ObjParseOptions & GltfParseOptions & VoxParseOptions) | undefined; return { @@ -491,6 +700,7 @@ export default function GalleryWorkbench() { reactAnimatedPolygons: animation.reactAnimatedPolygons, interiorFill: sceneOptions.interiorFill, }); + useLightRotationDrag(viewportRef, sceneOptions, helperScale, gizmoDragging, updateScene); const renderModelPolygons = useMemo( () => sceneOptions.solidMaterials ? withSolidMaterials(modelPolygons, parserOptions.defaultColor) diff --git a/website/src/components/GalleryWorkbench/gallery-workbench.css b/website/src/components/GalleryWorkbench/gallery-workbench.css index b55f93cb..0e1ccf36 100644 --- a/website/src/components/GalleryWorkbench/gallery-workbench.css +++ b/website/src/components/GalleryWorkbench/gallery-workbench.css @@ -370,6 +370,17 @@ background: #000; } +.dn-light-helper, +.dn-light-helper * { + cursor: grab; +} + +.dn-viewport.is-light-rotating, +.dn-viewport.is-light-rotating .dn-light-helper, +.dn-viewport.is-light-rotating .dn-light-helper * { + cursor: grabbing; +} + .dn-viewport--outline-polygons .polycss-scene i, .dn-viewport--outline-polygons .polycss-scene b, .dn-viewport--outline-polygons .polycss-scene s, diff --git a/website/src/components/ReactScene/ReactScene.tsx b/website/src/components/ReactScene/ReactScene.tsx index 2f774a0a..3cf84604 100644 --- a/website/src/components/ReactScene/ReactScene.tsx +++ b/website/src/components/ReactScene/ReactScene.tsx @@ -5,11 +5,11 @@ import { PolyPerspectiveCamera, PolyMapControls, PolyOrbitControls, - PolyDirectionalLightHelper, PolyMesh, PolyScene, PolySelect, PolyTransformControls, + octahedronPolygons, } from "@layoutit/polycss-react"; import type { PolyAmbientLight, @@ -26,6 +26,44 @@ import { } from "@layoutit/polycss"; import { meshResolutionShowsMesh, type GizmoMode, type SceneOptionsState } from "../types"; +const LIGHT_HELPER_TILE = 50; + +function LightHelperMesh({ + light, + target, + distance, + size, +}: { + light: PolyDirectionalLight; + target: Vec3; + distance: number; + size: number; +}) { + const swatch = light.color ?? "#ffd54a"; + const polygons = useMemo( + () => octahedronPolygons({ center: [0, 0, 0], size, color: swatch }), + [size, swatch], + ); + const position = useMemo(() => { + const [dx, dy, dz] = light.direction; + const len = Math.hypot(dx, dy, dz) || 1; + return [ + (target[1] + (dx / len) * distance) * LIGHT_HELPER_TILE, + (target[0] + (dy / len) * distance) * LIGHT_HELPER_TILE, + (target[2] + (dz / len) * distance) * LIGHT_HELPER_TILE, + ]; + }, [light.direction, target, distance]); + + return ( + + ); +} + function canCullCameraBackfaces(polygons: Polygon[]): boolean { return isVoxelCameraCullableNormalGroups(cameraCullNormalGroupsFromPolygons(polygons)); } @@ -94,6 +132,8 @@ export function ReactScene({ const camProps = sceneOptions.perspective === false ? { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target } : { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target, perspective: sceneOptions.perspective }; + const orbitCameraDrag = sceneOptions.interactive && !gizmoDragging; + const mapCameraDrag = sceneOptions.interactive && !gizmoDragging; const centerPolygons = scenePolygons; const effectiveMeshRotation = sceneOptions.selection ? meshRotation : undefined; const canCullScenePolygons = useMemo( @@ -121,7 +161,7 @@ export function ReactScene({ {sceneOptions.dragMode === "pan" ? ( } {sceneOptions.showLight && ( - { lightHandleRef.current?.dispose(); lightHandleRef.current = null;