From d8c972c3e16c998fe5d6750c35037a584b998329 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Sat, 23 May 2026 18:05:36 -0300 Subject: [PATCH] feat: refine gallery model processing --- package.json | 10 +- .../core/src/helpers/conePolygons.test.ts | 66 +++--- packages/core/src/helpers/conePolygons.ts | 12 +- .../core/src/helpers/cylinderPolygons.test.ts | 13 +- packages/core/src/helpers/cylinderPolygons.ts | 31 ++- .../src/helpers/primitiveGeometry.test.ts | 131 +++++++++++ packages/core/src/parser/parseGltf.test.ts | 60 ++++- packages/core/src/parser/parseGltf.ts | 20 +- .../src/parser/solidTextureSamples.test.ts | 44 ++++ .../core/src/parser/solidTextureSamples.ts | 75 ++++++- website/public/polycss-primitives-banner.png | Bin 0 -> 30940 bytes .../polycss-primitives-bluesky-banner.png | Bin 0 -> 41878 bytes .../GalleryWorkbench/GalleryWorkbench.tsx | 212 +++++++++++++++++- .../GalleryWorkbench/gallery-workbench.css | 11 + .../src/components/ReactScene/ReactScene.tsx | 48 +++- .../components/VanillaScene/VanillaScene.tsx | 4 +- 16 files changed, 659 insertions(+), 78 deletions(-) create mode 100644 packages/core/src/helpers/primitiveGeometry.test.ts create mode 100644 website/public/polycss-primitives-banner.png create mode 100644 website/public/polycss-primitives-bluesky-banner.png 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 0000000000000000000000000000000000000000..cea7166a4cc51bd3bcfbd81d566ee66e5d3a3cbb GIT binary patch literal 30940 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 Gu>S>a7)huA literal 0 HcmV?d00001 diff --git a/website/public/polycss-primitives-bluesky-banner.png b/website/public/polycss-primitives-bluesky-banner.png new file mode 100644 index 0000000000000000000000000000000000000000..8ce45599f76db11f4975386dd1fd48d3ca8b47a0 GIT binary patch literal 41878 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 YfE=~Wc#TDb((i9Sx^JLWqiGlQUnEDlc>n+a literal 0 HcmV?d00001 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;