diff --git a/AGENTS.md b/AGENTS.md index b1100b25..72987723 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,9 @@ Public API is **mirrored** across React and Vue. Adding a hook on one side witho ## Rendering model — the mental model -**One `Polygon` → one leaf DOM element.** Leaves use canonical CSS primitives where possible and move scale into `matrix3d`; `border-shape` uses a larger fixed primitive because its paint geometry becomes unstable when collapsed to 1px. Textured polygons still pack their local-2D bounding rect (`canvasW × canvasH`) into the atlas. The HTML tag *is* the render strategy — the renderer picks one tag per polygon based on its shape and material. +**One visible `Polygon` → one leaf DOM element.** Leaves use canonical CSS primitives where possible and move scale into `matrix3d`; `border-shape` uses a larger fixed primitive because its paint geometry becomes unstable when collapsed to 1px. Textured polygons still pack their local-2D bounding rect (`canvasW × canvasH`) into the atlas. The HTML tag *is* the render strategy — the renderer picks one tag per polygon based on its shape and material. + +Voxel-shaped meshes are the exception to "all polygons stay mounted": meshes with at most the six axis-aligned face normals, excluding helpers/auto-center-exempt meshes, automatically mount only camera-facing leaves and patch the mounted set when the camera or mesh rotation crosses a visible-normal boundary. Non-voxel meshes keep the full leaf DOM mounted; broad camera-dependent DOM culling is not worth the mutation cost. ### Tag-as-strategy table diff --git a/bench/perf-cracks.html b/bench/perf-cracks.html index 4bc6060d..626dd61f 100644 --- a/bench/perf-cracks.html +++ b/bench/perf-cracks.html @@ -227,7 +227,6 @@

crack finder

textureLighting: "baked", ambientLight: { color: "#ffffff", intensity: 1 }, directionalLight: { direction: [0, 0, 1], color: "#000000", intensity: 0 }, - domCullBackfaces: false, // Wireframe mode generates very thin polygons. The atlas rasterizes // each polygon's bbox at `meshSize × tile × atlasScale` pixels with // a 1-pixel floor, so thin lines need a high atlasScale to render diff --git a/packages/core/src/cull/cameraBackfaceCulling.test.ts b/packages/core/src/cull/cameraBackfaceCulling.test.ts new file mode 100644 index 00000000..1e46eb14 --- /dev/null +++ b/packages/core/src/cull/cameraBackfaceCulling.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import type { Polygon } from "../types"; +import { + cameraCullNormalGroupsFromPolygons, + cameraCullVisibleSignature, + isVoxelCameraCullableNormalGroups, + polygonCssSurfaceNormal, + polygonFacesCamera, +} from "./cameraBackfaceCulling"; + +function triangle(): Polygon { + return { + vertices: [ + [0, 0, 0], + [1, 0, 0], + [0, 1, 0], + ], + }; +} + +function backTriangle(): Polygon { + return { + vertices: [ + [0, 0, 0], + [0, 1, 0], + [1, 0, 0], + ], + }; +} + +function sideTriangle(): Polygon { + return { + vertices: [ + [0, 0, 0], + [0, 0, 1], + [1, 0, 0], + ], + }; +} + +function rotatedSideTriangle(deg: number): Polygon { + const r = deg * Math.PI / 180; + const c = Math.cos(r); + const s = Math.sin(r); + const rotate = (v: [number, number, number]): [number, number, number] => [ + v[0] * c - v[1] * s, + v[0] * s + v[1] * c, + v[2], + ]; + return { + vertices: sideTriangle().vertices.map(rotate), + }; +} + +describe("cameraBackfaceCulling", () => { + it("computes normals in polycss CSS space", () => { + expect(polygonCssSurfaceNormal(triangle())).toEqual([0, 0, 1]); + expect(polygonCssSurfaceNormal(backTriangle())).toEqual([0, 0, -1]); + }); + + it("recognizes voxel normal sets", () => { + const groups = cameraCullNormalGroupsFromPolygons([ + triangle(), + backTriangle(), + sideTriangle(), + ]); + + expect(isVoxelCameraCullableNormalGroups(groups)).toBe(true); + }); + + it("rejects non-axis normal sets", () => { + const groups = cameraCullNormalGroupsFromPolygons([ + triangle(), + rotatedSideTriangle(15), + ]); + + expect(isVoxelCameraCullableNormalGroups(groups)).toBe(false); + }); + + it("tests whether a polygon faces the camera after scene rotation", () => { + expect(polygonFacesCamera(triangle(), { rotX: 0, rotY: 0 })).toBe(true); + expect(polygonFacesCamera(backTriangle(), { rotX: 0, rotY: 0 })).toBe(false); + expect(polygonFacesCamera(backTriangle(), { rotX: 180, rotY: 0 })).toBe(true); + }); + + it("builds stable signatures from visible normal groups", () => { + const groups = cameraCullNormalGroupsFromPolygons([ + triangle(), + backTriangle(), + ]); + + expect(cameraCullVisibleSignature(groups, { rotX: 0, rotY: 0 })).toBe("0.0000,0.0000,1.0000"); + expect(cameraCullVisibleSignature(groups, { rotX: 180, rotY: 0 })).toBe("0.0000,0.0000,-1.0000"); + }); +}); diff --git a/packages/core/src/cull/cameraBackfaceCulling.ts b/packages/core/src/cull/cameraBackfaceCulling.ts new file mode 100644 index 00000000..3355b66b --- /dev/null +++ b/packages/core/src/cull/cameraBackfaceCulling.ts @@ -0,0 +1,116 @@ +import type { Polygon, Vec3 } from "../types"; +import { rotateVec3 } from "../math/rotation"; + +export const CAMERA_BACKFACE_CULL_EPS = 1e-5; +export const VOXEL_CAMERA_CULL_AXIS_EPS = 1e-3; +export const VOXEL_CAMERA_CULL_NORMAL_LIMIT = 6; + +export interface CameraCullRotation { + rotX: number; + rotY: number; + meshRotation?: Vec3; +} + +export interface CameraCullNormalGroup { + key: string; + normal: Vec3; +} + +export function polygonCssSurfaceNormal(polygon: Polygon): Vec3 | null { + const vertices = polygon.vertices; + if (vertices.length < 3) return null; + const v0 = vertices[0]; + let nx = 0; + let ny = 0; + let nz = 0; + for (let i = 1; i + 1 < vertices.length; i++) { + const v1 = vertices[i]; + const v2 = vertices[i + 1]; + const e1x = v1[1] - v0[1], e1y = v1[0] - v0[0], e1z = v1[2] - v0[2]; + const e2x = v2[1] - v0[1], e2y = v2[0] - v0[0], e2z = v2[2] - v0[2]; + nx -= e1y * e2z - e1z * e2y; + ny -= e1z * e2x - e1x * e2z; + nz -= e1x * e2y - e1y * e2x; + } + const len = Math.hypot(nx, ny, nz); + if (len < 1e-9) return null; + return [nx / len, ny / len, nz / len]; +} + +export function cameraFacingDepth(normal: Vec3, rotation: CameraCullRotation): number { + const meshRotation = rotation.meshRotation; + const meshNormal = meshRotation + ? rotateVec3(normal, meshRotation[0] ?? 0, meshRotation[1] ?? 0, meshRotation[2] ?? 0) + : normal; + return rotateVec3(meshNormal, rotation.rotX, 0, rotation.rotY)[2]; +} + +export function normalFacesCamera( + normal: Vec3, + rotation: CameraCullRotation, + depthThreshold = CAMERA_BACKFACE_CULL_EPS, +): boolean { + return cameraFacingDepth(normal, rotation) > depthThreshold; +} + +export function polygonFacesCamera( + polygon: Polygon, + rotation: CameraCullRotation, + depthThreshold = CAMERA_BACKFACE_CULL_EPS, +): boolean { + const normal = polygonCssSurfaceNormal(polygon); + return normal === null || normalFacesCamera(normal, rotation, depthThreshold); +} + +export function cameraCullNormalKey(normal: Vec3): string { + return `${normal[0].toFixed(4)},${normal[1].toFixed(4)},${normal[2].toFixed(4)}`; +} + +export function cameraCullNormalGroups( + normals: Iterable, +): CameraCullNormalGroup[] { + const groups = new Map(); + for (const normal of normals) { + if (!normal) continue; + const key = cameraCullNormalKey(normal); + if (!groups.has(key)) groups.set(key, normal); + } + return Array.from(groups, ([key, normal]) => ({ key, normal })); +} + +export function cameraCullNormalGroupsFromPolygons( + polygons: readonly Polygon[], +): CameraCullNormalGroup[] { + return cameraCullNormalGroups(polygons.map(polygonCssSurfaceNormal)); +} + +export function isAxisAlignedSurfaceNormal( + normal: Vec3, + axisEpsilon = VOXEL_CAMERA_CULL_AXIS_EPS, +): boolean { + const ax = Math.abs(normal[0]); + const ay = Math.abs(normal[1]); + const az = Math.abs(normal[2]); + const max = Math.max(ax, ay, az); + return max > 1 - axisEpsilon && ax + ay + az - max < axisEpsilon; +} + +export function isVoxelCameraCullableNormalGroups( + groups: readonly CameraCullNormalGroup[], +): boolean { + return groups.length <= VOXEL_CAMERA_CULL_NORMAL_LIMIT && + groups.every(({ normal }) => isAxisAlignedSurfaceNormal(normal)); +} + +export function cameraCullVisibleSignature( + groups: readonly CameraCullNormalGroup[], + rotation: CameraCullRotation, + depthThreshold = CAMERA_BACKFACE_CULL_EPS, +): string { + const visible: string[] = []; + for (const { key, normal } of groups) { + if (normalFacesCamera(normal, rotation, depthThreshold)) visible.push(key); + } + visible.sort(); + return visible.join("|"); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7b4745b9..cc2d7496 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -89,6 +89,25 @@ export type { } from "./merge/optimizePolygons"; export { cullInteriorPolygons } from "./cull/cullInteriorPolygons"; export type { CullInteriorOptions } from "./cull/cullInteriorPolygons"; +export { + CAMERA_BACKFACE_CULL_EPS, + VOXEL_CAMERA_CULL_AXIS_EPS, + VOXEL_CAMERA_CULL_NORMAL_LIMIT, + cameraCullNormalGroups, + cameraCullNormalGroupsFromPolygons, + cameraCullNormalKey, + cameraCullVisibleSignature, + cameraFacingDepth, + isAxisAlignedSurfaceNormal, + isVoxelCameraCullableNormalGroups, + normalFacesCamera, + polygonCssSurfaceNormal, + polygonFacesCamera, +} from "./cull/cameraBackfaceCulling"; +export type { + CameraCullNormalGroup, + CameraCullRotation, +} from "./cull/cameraBackfaceCulling"; // ── Helper-gizmo geometry (axes, light marker, transform arrows / rings) ─ export { axesHelperPolygons, arrowPolygons, ringPolygons, octahedronPolygons } from "./helpers"; diff --git a/packages/core/src/parser/loadMesh.test.ts b/packages/core/src/parser/loadMesh.test.ts index 68d6e17b..ddf79d2e 100644 --- a/packages/core/src/parser/loadMesh.test.ts +++ b/packages/core/src/parser/loadMesh.test.ts @@ -26,6 +26,19 @@ const TEXTURED_QUAD_OBJ = [ "f 1/1 2/2 3/3 4/4", "", ].join("\n"); +const TEXTURED_SMALL_UV_QUAD_OBJ = [ + "v 0 0 0", + "v 1 0 0", + "v 1 1 0", + "v 0 1 0", + "vt 0 0", + "vt 0.49 0", + "vt 0.49 0.49", + "vt 0 0.49", + "usemtl Swatch", + "f 1/1 2/2 3/3 4/4", + "", +].join("\n"); function makeMockFetch(opts: { ok?: boolean; @@ -162,6 +175,108 @@ describe("loadMesh", () => { expect(result.polygons[0].vertices).toHaveLength(4); }); + it("bakes noisy color-swatch texture samples into solid polygons by default", async () => { + vi.stubGlobal("fetch", makeMockFetch({ text: TEXTURED_QUAD_OBJ })); + stubTexturePixels(2, 2, new Uint8Array([ + 100, 80, 60, 255, 106, 85, 65, 255, + 103, 83, 62, 255, 110, 90, 70, 255, + ])); + + const result = await loadMesh("model.obj", { + objOptions: { materialTextures: { Swatch: "swatch.png" } }, + }); + + expect(result.polygons.length).toBeGreaterThan(0); + expect(result.polygons.every((polygon) => polygon.texture === undefined)).toBe(true); + expect(result.polygons.every((polygon) => polygon.uvs === undefined)).toBe(true); + expect(result.polygons.every((polygon) => /^#[0-9a-f]{6}$/.test(polygon.color))).toBe(true); + }); + + it("bakes smooth low-detail swatch gradients into solid polygons by default", async () => { + vi.stubGlobal("fetch", makeMockFetch({ text: TEXTURED_QUAD_OBJ })); + stubTexturePixels(4, 4, new Uint8Array([ + 226, 194, 163, 255, 229, 199, 171, 255, 232, 204, 179, 255, 236, 214, 193, 255, + 227, 196, 166, 255, 230, 201, 174, 255, 233, 206, 182, 255, 235, 212, 190, 255, + 228, 198, 169, 255, 231, 203, 177, 255, 234, 208, 185, 255, 235, 211, 188, 255, + 230, 203, 176, 255, 232, 205, 180, 255, 234, 209, 186, 255, 236, 214, 193, 255, + ])); + + const result = await loadMesh("model.obj", { + objOptions: { materialTextures: { Swatch: "swatch.png" } }, + }); + + expect(result.polygons.length).toBeGreaterThan(0); + expect(result.polygons.every((polygon) => polygon.texture === undefined)).toBe(true); + expect(result.polygons.every((polygon) => polygon.uvs === undefined)).toBe(true); + }); + + it("honors a looser solid texture sample tolerance", async () => { + vi.stubGlobal("fetch", makeMockFetch({ text: TEXTURED_QUAD_OBJ })); + stubTexturePixels(2, 2, new Uint8Array([ + 226, 194, 163, 255, 232, 206, 181, 255, + 230, 203, 176, 255, 236, 214, 193, 255, + ])); + + const result = await loadMesh("model.obj", { + objOptions: { materialTextures: { Swatch: "swatch.png" } }, + solidTextureSamples: { colorTolerance: 32 }, + }); + + expect(result.polygons.length).toBeGreaterThan(0); + expect(result.polygons.every((polygon) => polygon.texture === undefined)).toBe(true); + expect(result.polygons.every((polygon) => polygon.uvs === undefined)).toBe(true); + }); + + it("honors a tighter solid texture sample tolerance", async () => { + vi.stubGlobal("fetch", makeMockFetch({ text: TEXTURED_QUAD_OBJ })); + stubTexturePixels(2, 2, new Uint8Array([ + 226, 194, 163, 255, 232, 206, 181, 255, + 230, 203, 176, 255, 236, 214, 193, 255, + ])); + + const result = await loadMesh("model.obj", { + objOptions: { materialTextures: { Swatch: "swatch.png" } }, + solidTextureSamples: { colorTolerance: 2 }, + }); + + expect(result.polygons.some((polygon) => polygon.texture === "swatch.png")).toBe(true); + expect(result.polygons.some((polygon) => polygon.uvs !== undefined)).toBe(true); + }); + + it("keeps smooth local samples texture-backed when the source texture is detailed", async () => { + vi.stubGlobal("fetch", makeMockFetch({ text: TEXTURED_SMALL_UV_QUAD_OBJ })); + stubTexturePixels(4, 4, new Uint8Array([ + 0, 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 0, 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 255, + 226, 194, 163, 255, 232, 206, 181, 255, 0, 0, 0, 255, 255, 255, 255, 255, + 230, 203, 176, 255, 236, 214, 193, 255, 255, 255, 255, 255, 0, 0, 0, 255, + ])); + + const result = await loadMesh("model.obj", { + objOptions: { materialTextures: { Swatch: "swatch.png" } }, + }); + + expect(result.polygons.some((polygon) => polygon.texture === "swatch.png")).toBe(true); + expect(result.polygons.some((polygon) => polygon.uvs !== undefined)).toBe(true); + }); + + it("keeps uniform local samples texture-backed when the source texture is detailed", async () => { + vi.stubGlobal("fetch", makeMockFetch({ text: TEXTURED_SMALL_UV_QUAD_OBJ })); + stubTexturePixels(4, 4, new Uint8Array([ + 0, 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 0, 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 255, + 100, 80, 60, 255, 100, 80, 60, 255, 0, 0, 0, 255, 255, 255, 255, 255, + 100, 80, 60, 255, 100, 80, 60, 255, 255, 255, 255, 255, 0, 0, 0, 255, + ])); + + const result = await loadMesh("model.obj", { + objOptions: { materialTextures: { Swatch: "swatch.png" } }, + }); + + expect(result.polygons.some((polygon) => polygon.texture === "swatch.png")).toBe(true); + expect(result.polygons.some((polygon) => polygon.uvs !== undefined)).toBe(true); + }); + it("keeps non-uniform texture samples texture-backed", async () => { vi.stubGlobal("fetch", makeMockFetch({ text: TEXTURED_QUAD_OBJ })); stubTexturePixels(2, 2, new Uint8Array([ diff --git a/packages/core/src/parser/solidTextureSamples.ts b/packages/core/src/parser/solidTextureSamples.ts index e50ac773..e0da90e9 100644 --- a/packages/core/src/parser/solidTextureSamples.ts +++ b/packages/core/src/parser/solidTextureSamples.ts @@ -52,10 +52,24 @@ interface TextureSampler { width: number; height: number; data: ArrayLike; + lowDetail: boolean; +} + +interface ColorStats { + min: SampledColor; + max: SampledColor; + sum: SampledColor; + count: number; } const DEFAULT_MAX_TEXTURE_PIXELS = 16 * 1024 * 1024; const DEFAULT_COLOR_TOLERANCE = 2; +const SMOOTH_SWATCH_TOLERANCE = 32; +const DETAIL_SAMPLE_TARGET = 128; +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; function textureForPolygon(polygon: Polygon): string | undefined { return polygon.material?.texture ?? polygon.texture; @@ -118,12 +132,58 @@ async function createSampler( const ctx = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx) return null; ctx.drawImage(img, 0, 0, width, height); - return { width, height, data: ctx.getImageData(0, 0, width, height).data }; + const data = ctx.getImageData(0, 0, width, height).data; + return { width, height, data, lowDetail: isLowDetailTexture(width, height, data) }; } catch { return null; } } +function maxRgbDeltaAt( + data: ArrayLike, + width: number, + x1: number, + y1: number, + x2: number, + y2: number, +): number { + const a = (y1 * width + x1) * 4; + const b = (y2 * width + x2) * 4; + return Math.max( + Math.abs((data[a] ?? 0) - (data[b] ?? 0)), + Math.abs((data[a + 1] ?? 0) - (data[b + 1] ?? 0)), + Math.abs((data[a + 2] ?? 0) - (data[b + 2] ?? 0)), + ); +} + +function isLowDetailTexture(width: number, height: number, data: ArrayLike): boolean { + const step = Math.max(1, Math.floor(Math.max(width, height) / DETAIL_SAMPLE_TARGET)); + let total = 0; + let edgeCount = 0; + let deltaSum = 0; + + for (let y = 0; y < height; y += step) { + for (let x = 0; x < width; x += step) { + if (x + step < width) { + const delta = maxRgbDeltaAt(data, width, x, y, x + step, y); + deltaSum += delta; + total++; + if (delta > DETAIL_EDGE_THRESHOLD) edgeCount++; + } + if (y + step < height) { + const delta = maxRgbDeltaAt(data, width, x, y, x, y + step); + deltaSum += delta; + total++; + if (delta > DETAIL_EDGE_THRESHOLD) edgeCount++; + } + } + } + + return total > 0 && + edgeCount / total <= LOW_DETAIL_MAX_EDGE_RATIO && + deltaSum / total <= LOW_DETAIL_MAX_AVERAGE_DELTA; +} + function clampInt(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } @@ -163,6 +223,26 @@ function triangleSampleUvs(uvs: readonly [Vec2, Vec2, Vec2]): Vec2[] { ]; } +function triangleGridSampleUvs(uvs: readonly [Vec2, Vec2, Vec2]): Vec2[] { + const [a, b, c] = uvs; + const out = triangleSampleUvs(uvs); + for (let wa = 1; wa < TRIANGLE_GRID_STEPS; wa++) { + for (let wb = 1; wb < TRIANGLE_GRID_STEPS - wa; wb++) { + const wc = TRIANGLE_GRID_STEPS - wa - wb; + if (wc <= 0) continue; + out.push(mixUv( + a, + b, + c, + wa / TRIANGLE_GRID_STEPS, + wb / TRIANGLE_GRID_STEPS, + wc / TRIANGLE_GRID_STEPS, + )); + } + } + return out; +} + function polygonTextureTriangles(polygon: Polygon): Array> { if (polygon.textureTriangles?.length) return polygon.textureTriangles; const uvs = polygon.uvs; @@ -197,27 +277,65 @@ function colorToCss(color: SampledColor): string { return `rgba(${Math.round(color.r)}, ${Math.round(color.g)}, ${Math.round(color.b)}, ${alpha})`; } +function createColorStats(): ColorStats { + return { + min: { r: 255, g: 255, b: 255, a: 255 }, + max: { r: 0, g: 0, b: 0, a: 0 }, + sum: { r: 0, g: 0, b: 0, a: 0 }, + count: 0, + }; +} + +function addColor(stats: ColorStats, color: SampledColor): void { + stats.min.r = Math.min(stats.min.r, color.r); + stats.min.g = Math.min(stats.min.g, color.g); + stats.min.b = Math.min(stats.min.b, color.b); + stats.min.a = Math.min(stats.min.a, color.a); + stats.max.r = Math.max(stats.max.r, color.r); + stats.max.g = Math.max(stats.max.g, color.g); + stats.max.b = Math.max(stats.max.b, color.b); + stats.max.a = Math.max(stats.max.a, color.a); + stats.sum.r += color.r; + stats.sum.g += color.g; + stats.sum.b += color.b; + stats.sum.a += color.a; + stats.count++; +} + +function statsColor(stats: ColorStats): SampledColor { + return { + r: stats.sum.r / stats.count, + g: stats.sum.g / stats.count, + b: stats.sum.b / stats.count, + a: stats.sum.a / stats.count, + }; +} + function solidColorForPolygon( polygon: Polygon, sampler: TextureSampler, tolerance: number, + explicitTolerance: boolean, ): string | null { const triangles = polygonTextureTriangles(polygon); if (triangles.length === 0) return null; + if (!explicitTolerance && !sampler.lowDetail) return null; + + const stats = createColorStats(); - let first: SampledColor | null = null; for (const triangle of triangles) { - for (const uv of triangleSampleUvs(triangle.uvs)) { + for (const uv of triangleGridSampleUvs(triangle.uvs)) { const color = sampleUv(sampler, uv); if (!color) return null; - if (!first) { - first = color; - } else if (!colorsClose(first, color, tolerance)) { - return null; - } + addColor(stats, color); } } - return first ? colorToCss(first) : null; + + if (stats.count === 0) return null; + if (colorsClose(stats.min, stats.max, tolerance)) return colorToCss(statsColor(stats)); + if (explicitTolerance) return null; + if (!colorsClose(stats.min, stats.max, SMOOTH_SWATCH_TOLERANCE)) return null; + return colorToCss(statsColor(stats)); } function bakePolygon(polygon: Polygon, color: string): Polygon { @@ -260,6 +378,7 @@ async function createSolidTextureBaker( ); const tolerance = options.colorTolerance ?? DEFAULT_COLOR_TOLERANCE; + const explicitTolerance = options.colorTolerance !== undefined; return { bake(nextPolygons: Polygon[]): { polygons: Polygon[]; changed: boolean } { let changed = false; @@ -268,7 +387,7 @@ async function createSolidTextureBaker( if (!texture) return polygon; const sampler = samplerByTexture.get(texture); if (!sampler) return polygon; - const color = solidColorForPolygon(polygon, sampler, tolerance); + const color = solidColorForPolygon(polygon, sampler, tolerance, explicitTolerance); if (!color) return polygon; changed = true; return bakePolygon(polygon, color); diff --git a/packages/polycss/src/api/createPolyScene.test.ts b/packages/polycss/src/api/createPolyScene.test.ts index bde533a5..ce17fd09 100644 --- a/packages/polycss/src/api/createPolyScene.test.ts +++ b/packages/polycss/src/api/createPolyScene.test.ts @@ -21,6 +21,66 @@ function triangle(color = "#ff0000"): Polygon { }; } +function backTriangle(color = "#00ff00"): Polygon { + return { + vertices: [ + [0, 0, 0], + [0, 1, 0], + [1, 0, 0], + ], + color, + }; +} + +function sideTriangle(color = "#0000ff"): Polygon { + return { + vertices: [ + [0, 0, 0], + [0, 0, 1], + [1, 0, 0], + ], + color, + }; +} + +function oppositeSideTriangle(color = "#ffff00"): Polygon { + return { + vertices: [ + [0, 0, 0], + [1, 0, 0], + [0, 0, 1], + ], + color, + }; +} + +function rotatedSideTriangle(deg: number, color = "#0000ff"): Polygon { + const r = deg * Math.PI / 180; + const c = Math.cos(r); + const s = Math.sin(r); + const rotate = (v: [number, number, number]): [number, number, number] => [ + v[0] * c - v[1] * s, + v[0] * s + v[1] * c, + v[2], + ]; + return { + vertices: sideTriangle(color).vertices.map(rotate), + color, + }; +} + +function highNormalTrianglePairs(count = 26): Polygon[] { + const out: Polygon[] = []; + for (let i = 0; i < count; i += 1) { + const poly = rotatedSideTriangle(i * 7, "#224466"); + out.push(poly, { + ...poly, + vertices: poly.vertices.map((v) => [v[0], v[1], v[2]] as [number, number, number]), + }); + } + return out; +} + function texturedTriangle(): Polygon { return { vertices: triangle().vertices, @@ -277,6 +337,20 @@ describe("createPolyScene", () => { expect(after[0].style.transform).not.toBe(beforeTransform); }); + it("preserves caller-mounted mesh wrapper children across setPolygons()", () => { + scene = createPolyScene(host); + const handle = scene.add(makeParseResult([triangle()]), { merge: false }); + const nested = document.createElement("div"); + nested.className = "nested-helper"; + handle.element.appendChild(nested); + + handle.setPolygons([triangle("#00ff00")], { merge: false }); + + expect(handle.element.contains(nested)).toBe(true); + expect(handle.element.lastElementChild).toBe(nested); + expect(handle.element.querySelectorAll("i,b,s,u").length).toBe(1); + }); + it("updates stableDom textured triangles without replacing loaded atlas elements", () => { scene = createPolyScene(host); const handle = scene.add(makeParseResult([texturedTriangle()]), { @@ -609,6 +683,118 @@ describe("createPolyScene", () => { expect(host.querySelector("i, s")).toBe(firstLeaf); }); + it("mounts only camera-facing voxel leaves by default", () => { + scene = createPolyScene(host, { + rotX: 0, + rotY: 0, + }); + const handle = scene.add(makeParseResult([triangle(), backTriangle()]), { merge: false }); + expect(handle.polygons.length).toBe(2); + const firstLeaf = host.querySelector(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u"); + expect(host.querySelectorAll(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u").length).toBe(1); + + scene.setOptions({ rotX: 180 }); + const nextLeaf = host.querySelector(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u"); + expect(nextLeaf).not.toBe(firstLeaf); + expect(host.querySelectorAll(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u").length).toBe(1); + }); + + it("does not remount culling leaves when camera rotation keeps the same visible normal set", () => { + scene = createPolyScene(host, { + rotX: 0, + rotY: 0, + }); + scene.add(makeParseResult([triangle(), backTriangle()]), { merge: false }); + const firstLeaf = host.querySelector(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u"); + expect(firstLeaf).not.toBeNull(); + + scene.setOptions({ rotY: 10 }); + + expect(host.querySelector(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u")).toBe(firstLeaf); + expect(host.querySelectorAll(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u").length).toBe(1); + }); + + it("keeps caller-mounted children when camera culling remounts leaves", () => { + scene = createPolyScene(host, { + rotX: 0, + rotY: 0, + }); + const handle = scene.add(makeParseResult([triangle(), backTriangle()]), { merge: false }); + const nested = document.createElement("div"); + nested.className = "nested-helper"; + handle.element.appendChild(nested); + + scene.setOptions({ rotX: 180 }); + + expect(handle.element.contains(nested)).toBe(true); + expect(handle.element.lastElementChild).toBe(nested); + expect(handle.element.querySelectorAll("i,b,s,u").length).toBe(1); + }); + + it("patches culling deltas without removing leaves that stayed visible", () => { + scene = createPolyScene(host, { + rotX: 65, + rotY: 45, + }); + const handle = scene.add( + makeParseResult([ + triangle("#111111"), + sideTriangle("#222222"), + oppositeSideTriangle("#333333"), + ]), + { merge: false }, + ); + const leaves = handle.element.querySelectorAll("i,b,s,u"); + expect(leaves.length).toBe(2); + const stableLeaf = leaves[0]; + const removed: Node[] = []; + const observer = new MutationObserver((records) => { + for (const record of records) removed.push(...Array.from(record.removedNodes)); + }); + observer.observe(handle.element, { childList: true }); + + scene.setOptions({ rotY: 225 }); + observer.disconnect(); + + expect(handle.element.querySelectorAll("i,b,s,u").length).toBe(2); + expect(handle.element.querySelector("i,b,s,u")).toBe(stableLeaf); + expect(removed).not.toContain(stableLeaf); + }); + + it("uses strict culling for low-normal meshes so voxel faces do not linger behind the camera", () => { + scene = createPolyScene(host, { + rotX: 65, + rotY: 179, + }); + scene.add(makeParseResult([triangle(), sideTriangle()]), { merge: false, stableDom: true }); + expect(host.querySelectorAll(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u").length).toBe(2); + + scene.setOptions({ rotY: 181 }); + + expect(host.querySelectorAll(".polycss-mesh i, .polycss-mesh b, .polycss-mesh s, .polycss-mesh u").length).toBe(1); + }); + + it("leaves high-normal meshes on the stable DOM path", () => { + scene = createPolyScene(host, { + rotX: 65, + rotY: 0, + textureLighting: "dynamic", + }); + const handle = scene.add(makeParseResult(highNormalTrianglePairs()), { merge: false }); + expect(handle.element.querySelector(".polycss-bucket")).not.toBeNull(); + const leafCount = handle.element.querySelectorAll("i,b,s,u").length; + + const records: MutationRecord[] = []; + const observer = new MutationObserver((items) => records.push(...items)); + observer.observe(handle.element, { childList: true, subtree: true }); + + scene.setOptions({ rotY: 180 }); + observer.disconnect(); + + expect(records).toHaveLength(0); + expect(handle.element.querySelectorAll("i,b,s,u").length).toBe(leafCount); + }); + // Perf-fix tests: setOptions used to call recomputeAutoCenter() on every // call, which is O(N polys) and would be paid 60×/sec by an autorotate // loop. The smart-diff version only recomputes when `autoCenter` itself diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index c0945a60..1bf3410c 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -26,14 +26,22 @@ import type { Polygon, PolyTextureLightingMode, Vec3, + CameraCullNormalGroup, + CameraCullRotation, } from "@layoutit/polycss-core"; import { BASE_TILE, + CAMERA_BACKFACE_CULL_EPS, + cameraCullNormalGroups, + cameraCullVisibleSignature, computeSceneBbox, findOverlappingPolygonDuplicates, inverseRotateVec3, + isVoxelCameraCullableNormalGroups, mergePolygons, + normalFacesCamera, parseHexColor, + polygonCssSurfaceNormal, } from "@layoutit/polycss-core"; import { cssBorderShapeForPlan, @@ -448,6 +456,7 @@ export function createPolyScene( } let currentOptions: PolySceneOptions = { ...options }; + const layoutScale = effectiveCssZoom(host); // Bbox-center of all live meshes (helpers opt out). Auto-managed by // `recomputeAutoCenter`. Folded into the scene transform alongside @@ -481,6 +490,8 @@ export function createPolyScene( stableDom: boolean; excludeFromAutoCenter: boolean; castShadow: boolean; + cameraCullGroups: CameraCullNormalGroup[]; + cameraCullSignature: string; /** Rotation snapshot used by the baked atlas baker. Advances only when * `rebakeAtlas()` is called — not on every `setTransform`. */ bakedRotation: Vec3; @@ -488,7 +499,6 @@ export function createPolyScene( const meshes = new Set(); function applySceneStyle(el: HTMLElement, opts: PolySceneOptions): void { - const layoutScale = effectiveCssZoom(host); applyCssZoomCompensation(el, layoutScale); el.style.transform = buildSceneTransform(opts, autoCenterOffset, layoutScale); if (typeof opts.perspective === "number") { @@ -576,8 +586,40 @@ export function createPolyScene( disposeRendered(entry.rendered, entry.disposeAtlas); entry.disposeAtlas = undefined; entry.rendered.length = 0; + entry.cameraCullGroups.length = 0; + entry.cameraCullSignature = ""; clearShadowLeaves(entry); - while (entry.wrapper.firstChild) entry.wrapper.removeChild(entry.wrapper.firstChild); + for (const child of Array.from(entry.wrapper.children)) { + if (child instanceof HTMLElement && child.classList.contains("polycss-bucket")) { + child.remove(); + } + } + } + + function firstPreservedChild(entry: MeshEntry): ChildNode | null { + for (const child of Array.from(entry.wrapper.childNodes)) { + if (!(child instanceof HTMLElement)) return child; + if (child.classList.contains("polycss-bucket")) continue; + if (child.classList.contains("polycss-shadow")) continue; + const tag = child.tagName.toLowerCase(); + if (tag === "b" || tag === "i" || tag === "s" || tag === "u" || tag === "q") continue; + return child; + } + return null; + } + + function mountRenderedFragment(entry: MeshEntry, fragment: DocumentFragment, before: ChildNode | null): void { + if (before?.parentNode === entry.wrapper) { + entry.wrapper.insertBefore(fragment, before); + } else { + entry.wrapper.appendChild(fragment); + } + } + + function hasDirectBucket(wrapper: HTMLDivElement): boolean { + return Array.from(wrapper.children).some( + (child) => child instanceof HTMLElement && child.classList.contains("polycss-bucket"), + ); } function clearShadowLeaves(entry: MeshEntry): void { @@ -595,7 +637,196 @@ export function createPolyScene( } } + function clearMountedRendered(entry: MeshEntry): void { + for (const child of Array.from(entry.wrapper.children)) { + if (child instanceof HTMLElement && child.classList.contains("polycss-bucket")) { + child.remove(); + } + } + for (const item of entry.rendered) { + if (item.element.parentNode) item.element.parentNode.removeChild(item.element); + } + } + + function normalForRendered(entry: MeshEntry, item: RenderedPoly): Vec3 | null { + const poly = entry.polygons[item.polygonIndex]; + if (entry.stableDom && poly) return polygonCssSurfaceNormal(poly); + return item.plan?.normal ?? (poly ? polygonCssSurfaceNormal(poly) : null); + } + + function renderedItemsForCamera(entry: MeshEntry): RenderedPoly[] { + if (!canDomCullCamera(entry)) return entry.rendered; + const rotation = cameraCullRotation(entry); + return entry.rendered.filter((item) => + renderedItemFacesCamera(entry, item, CAMERA_BACKFACE_CULL_EPS, rotation) + ); + } + + function cameraCullRotation(entry: MeshEntry): CameraCullRotation { + return { + rotX: currentOptions.rotX ?? DEFAULT_ROT_X, + rotY: currentOptions.rotY ?? DEFAULT_ROT_Y, + meshRotation: entry.handle.transform.rotation, + }; + } + + function renderedItemFacesCamera( + entry: MeshEntry, + item: RenderedPoly, + depthThreshold = CAMERA_BACKFACE_CULL_EPS, + rotation = cameraCullRotation(entry), + ): boolean { + const normal = normalForRendered(entry, item); + return normal === null || normalFacesCamera(normal, rotation, depthThreshold); + } + + function recomputeCameraCullGroups(entry: MeshEntry): void { + entry.cameraCullGroups = cameraCullNormalGroups( + entry.rendered.map((item) => normalForRendered(entry, item)), + ); + } + + function cameraCullSignature(entry: MeshEntry): string { + return canDomCullCamera(entry) + ? cameraCullVisibleSignature(entry.cameraCullGroups, cameraCullRotation(entry)) + : "all"; + } + + function canDomCullCamera(entry: MeshEntry): boolean { + return !entry.excludeFromAutoCenter && + isVoxelCameraCullableNormalGroups(entry.cameraCullGroups); + } + + function syncCameraCullSignature(entry: MeshEntry): void { + entry.cameraCullSignature = canDomCullCamera(entry) + ? cameraCullSignature(entry) + : "all"; + } + + function patchMountedRenderedForCamera(entry: MeshEntry, depthThreshold: number): boolean { + const visible = new Array(entry.rendered.length); + let changed = false; + const rotation = cameraCullRotation(entry); + + for (let i = 0; i < entry.rendered.length; i += 1) { + const item = entry.rendered[i]; + const shouldMount = renderedItemFacesCamera(entry, item, depthThreshold, rotation); + visible[i] = shouldMount; + } + + let removeStart: HTMLElement | null = null; + let removeEnd: HTMLElement | null = null; + const flushRemove = () => { + if (!removeStart || !removeEnd) return; + if (removeStart === removeEnd) { + removeStart.remove(); + } else { + const range = doc.createRange(); + range.setStartBefore(removeStart); + range.setEndAfter(removeEnd); + range.deleteContents(); + range.detach(); + } + removeStart = null; + removeEnd = null; + changed = true; + }; + + for (let i = 0; i < entry.rendered.length; i += 1) { + const item = entry.rendered[i]; + if (!visible[i] && item.element.parentNode === entry.wrapper) { + if (removeEnd && removeEnd.nextSibling === item.element) { + removeEnd = item.element; + } else { + flushRemove(); + removeStart = item.element; + removeEnd = item.element; + } + } else { + flushRemove(); + } + } + flushRemove(); + + const insertionPointAfter = (index: number): ChildNode | null => { + for (let i = index; i < entry.rendered.length; i += 1) { + const next = entry.rendered[i].element; + if (next.parentNode === entry.wrapper) return next; + } + return firstPreservedChild(entry); + }; + + let addStart = -1; + const flushAdd = (endExclusive: number) => { + if (addStart < 0) return; + const fragment = doc.createDocumentFragment(); + for (let i = addStart; i < endExclusive; i += 1) { + const item = entry.rendered[i]; + restoreInlineDynamicNormalVars(entry, item); + fragment.appendChild(item.element); + } + mountRenderedFragment(entry, fragment, insertionPointAfter(endExclusive)); + addStart = -1; + changed = true; + }; + + for (let i = 0; i < entry.rendered.length; i += 1) { + const item = entry.rendered[i]; + if (visible[i] && item.element.parentNode !== entry.wrapper) { + if (addStart < 0) addStart = i; + } else { + flushAdd(i); + } + } + flushAdd(entry.rendered.length); + + return changed; + } + + function syncMountedRenderedForCameraChange(entry: MeshEntry, force = false): void { + if (!canDomCullCamera(entry)) { + const wasCulled = entry.cameraCullSignature !== "all"; + entry.cameraCullSignature = "all"; + if (wasCulled) remountEntry(entry); + return; + } + + if (hasDirectBucket(entry.wrapper)) { + remountEntryIfCullSignatureChanged(entry, force); + return; + } + + const nextSignature = cameraCullSignature(entry); + if (!force && nextSignature === entry.cameraCullSignature) return; + + const changed = patchMountedRenderedForCamera(entry, CAMERA_BACKFACE_CULL_EPS); + entry.cameraCullSignature = nextSignature; + if (changed) emitShadowLeaves(entry); + } + + function remountEntryIfCullSignatureChanged(entry: MeshEntry, force = false): void { + const next = canDomCullCamera(entry) + ? cameraCullSignature(entry) + : "all"; + if (!force && next === entry.cameraCullSignature) return; + remountEntry(entry); + } + + function dynamicNormalForRendered(entry: MeshEntry, item: RenderedPoly): Vec3 | null { + return normalForRendered(entry, item); + } + + function restoreInlineDynamicNormalVars(entry: MeshEntry, item: RenderedPoly): void { + if (currentOptions.textureLighting !== "dynamic") return; + const normal = dynamicNormalForRendered(entry, item); + if (!normal) return; + item.element.style.setProperty("--pnx", normal[0].toFixed(4)); + item.element.style.setProperty("--pny", normal[1].toFixed(4)); + item.element.style.setProperty("--pnz", normal[2].toFixed(4)); + } + function syncMountedRendered(entry: MeshEntry): void { + clearMountedRendered(entry); const fragment = doc.createDocumentFragment(); // Lambert-bucketing only pays off in dynamic mode, where the cascade @@ -612,7 +843,7 @@ export function createPolyScene( const soloItems: RenderedPoly[] = []; // Pass 1 — gather per (quantized-normal × color) keys. - for (const item of entry.rendered) { + for (const item of renderedItemsForCamera(entry)) { const poly = entry.polygons[item.polygonIndex]; const q = useBuckets && poly ? quantizeNormalKey(poly) : null; if (!q) { @@ -631,10 +862,16 @@ export function createPolyScene( // Pass 2 — wrap groups of ≥ 2 (where one bucket-level lambert calc // beats the per-poly calcs it replaces). Singletons fall back to the // per-poly path so we don't add a wrapper that costs more than it saves. - for (const item of soloItems) fragment.appendChild(item.element); + for (const item of soloItems) { + restoreInlineDynamicNormalVars(entry, item); + fragment.appendChild(item.element); + } for (const group of groups.values()) { if (group.items.length < 2) { - for (const item of group.items) fragment.appendChild(item.element); + for (const item of group.items) { + restoreInlineDynamicNormalVars(entry, item); + fragment.appendChild(item.element); + } continue; } const bucketEl = doc.createElement("div"); @@ -655,7 +892,8 @@ export function createPolyScene( fragment.appendChild(bucketEl); } - entry.wrapper.appendChild(fragment); + mountRenderedFragment(entry, fragment, firstPreservedChild(entry)); + syncCameraCullSignature(entry); } function yieldToMainThread(): Promise { @@ -673,19 +911,23 @@ export function createPolyScene( return !shouldCancel(); } + clearMountedRendered(entry); let fragment = doc.createDocumentFragment(); + const before = firstPreservedChild(entry); let count = 0; - for (const item of entry.rendered) { + for (const item of renderedItemsForCamera(entry)) { if (shouldCancel()) return false; + restoreInlineDynamicNormalVars(entry, item); fragment.appendChild(item.element); count++; if (count % ASYNC_MOUNT_BATCH_SIZE === 0) { - entry.wrapper.appendChild(fragment); + mountRenderedFragment(entry, fragment, before); fragment = doc.createDocumentFragment(); await yieldToMainThread(); } } - if (fragment.childNodes.length > 0) entry.wrapper.appendChild(fragment); + if (fragment.childNodes.length > 0) mountRenderedFragment(entry, fragment, before); + syncCameraCullSignature(entry); return !shouldCancel(); } @@ -767,7 +1009,7 @@ export function createPolyScene( }); const fragment = doc.createDocumentFragment(); - for (const item of entry.rendered) { + for (const item of renderedItemsForCamera(entry)) { // Atlas () polygons cast shadows too — the shadow only needs // the polygon's OUTLINE (border-shape) and a flat dark color, not // the texture content. So fully textured meshes like the Frog Guy @@ -823,6 +1065,12 @@ export function createPolyScene( } } + function remountEntry(entry: MeshEntry): void { + clearShadowLeaves(entry); + syncMountedRendered(entry); + emitShadowLeaves(entry); + } + function renderEntry(entry: MeshEntry, lightDirectionOverride?: Vec3): void { clearRendered(entry); const baseDirLight = currentOptions.directionalLight; @@ -850,6 +1098,7 @@ export function createPolyScene( ) ?? renderPolygonsWithTextureAtlas(entry.polygons, renderOptionsWithDefaults); entry.rendered = atlas.rendered; entry.disposeAtlas = atlas.dispose; + recomputeCameraCullGroups(entry); syncMountedRendered(entry); emitShadowLeaves(entry); } @@ -911,6 +1160,7 @@ export function createPolyScene( applySolidPaintVars(entry.wrapper, solidPaintDefaults); entry.rendered = atlas.rendered; entry.disposeAtlas = atlas.dispose; + recomputeCameraCullGroups(entry); syncMountedRendered(entry); emitShadowLeaves(entry); return !shouldCancel(); @@ -928,6 +1178,7 @@ export function createPolyScene( applySolidPaintVars(entry.wrapper, asyncAtlas.solidPaintDefaults); entry.rendered = asyncAtlas.rendered; entry.disposeAtlas = asyncAtlas.dispose; + recomputeCameraCullGroups(entry); const mounted = await syncMountedRenderedChunked(entry, shouldCancel); if (mounted) emitShadowLeaves(entry); return mounted; @@ -1027,6 +1278,8 @@ export function createPolyScene( stableDom: stableDomOnUpdate, excludeFromAutoCenter: !!transformIn.excludeFromAutoCenter, castShadow: !!transformIn.castShadow, + cameraCullGroups: [], + cameraCullSignature: "", bakedRotation: (transformIn.rotation ? [...transformIn.rotation] : [0, 0, 0]) as Vec3, }; @@ -1057,7 +1310,7 @@ export function createPolyScene( handle.polygons = entry.polygons; applyTransformOrigin(entry.polygons); const shouldRecomputeAutoCenter = options?.recomputeAutoCenter ?? true; - if (entry.stableDom && !entry.wrapper.querySelector(".polycss-bucket")) { + if (entry.stableDom && !hasDirectBucket(entry.wrapper)) { const renderOptions = { doc, directionalLight: currentOptions.directionalLight, @@ -1074,6 +1327,8 @@ export function createPolyScene( { ...renderOptions, solidPaintDefaults }, ) ) { + recomputeCameraCullGroups(entry); + syncMountedRenderedForCameraChange(entry, true); if (shouldRecomputeAutoCenter) recomputeAutoCenter(); return; } @@ -1117,6 +1372,7 @@ export function createPolyScene( const css2 = buildMeshTransform(transform); wrapper.style.transform = css2 ?? ""; applyMeshLightVarOverride(wrapper, transform.rotation); + if (t.rotation !== undefined) syncMountedRenderedForCameraChange(entry); if (entry.castShadow !== prevCastShadow) { emitShadowLeaves(entry); recomputeShadowGround(); @@ -1165,6 +1421,8 @@ export function createPolyScene( const prevAutoCenter = !!currentOptions.autoCenter; const prevStrategies = currentOptions.strategies; const prevTextureLighting = currentOptions.textureLighting; + const prevRotX = currentOptions.rotX ?? DEFAULT_ROT_X; + const prevRotY = currentOptions.rotY ?? DEFAULT_ROT_Y; currentOptions = { ...currentOptions, ...partial }; applySceneStyle(sceneEl, currentOptions); const nextAutoCenter = !!currentOptions.autoCenter; @@ -1183,6 +1441,12 @@ export function createPolyScene( if (strategiesChanged) { for (const entry of meshes) renderEntry(entry); } + const cameraRotationChanged = + (partial.rotX !== undefined && (currentOptions.rotX ?? DEFAULT_ROT_X) !== prevRotX) || + (partial.rotY !== undefined && (currentOptions.rotY ?? DEFAULT_ROT_Y) !== prevRotY); + if (!strategiesChanged && cameraRotationChanged) { + for (const entry of meshes) syncMountedRenderedForCameraChange(entry); + } if (prevAutoCenter !== nextAutoCenter) recomputeAutoCenter(); // When lighting mode changes, re-emit or clear shadow leaves on all meshes // that have castShadow set. Shadow emission is only valid in dynamic mode. diff --git a/packages/react/README.md b/packages/react/README.md index 55757a47..66f4a40e 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -64,7 +64,7 @@ Loads a mesh from a URL and renders its polygons. Manages blob-URL lifecycle aut | `parseOptions` | `UseMeshOptions` | Forwarded to `loadMesh`; `meshResolution` defaults to `"lossy"` | | `fallback` | `ReactNode` | Rendered while loading | | `errorFallback` | `(error: Error) => ReactNode` | Rendered on parse failure | -| `children` | `(polygon, index) => ReactNode` | Per-polygon render prop override | +| `children` | `((polygon, index) => ReactNode) \| ReactNode` | Per-polygon render prop override, or static children mounted inside the mesh wrapper | ### `` diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b5d79f34..408ada58 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -119,15 +119,30 @@ export type { TexturePaintMetricsOptions, CoverPlanarPolygonsOptions, CullInteriorOptions, + CameraCullNormalGroup, + CameraCullRotation, ApproximateMergeOptions, OptimizeMeshPolygonsOptions, } from "@layoutit/polycss-core"; export { + CAMERA_BACKFACE_CULL_EPS, + VOXEL_CAMERA_CULL_AXIS_EPS, + VOXEL_CAMERA_CULL_NORMAL_LIMIT, normalizePolygons, mergePolygons, coverPlanarPolygons, optimizeMeshPolygons, cullInteriorPolygons, + cameraCullNormalGroups, + cameraCullNormalGroupsFromPolygons, + cameraCullNormalKey, + cameraCullVisibleSignature, + cameraFacingDepth, + isAxisAlignedSurfaceNormal, + isVoxelCameraCullableNormalGroups, + normalFacesCamera, + polygonCssSurfaceNormal, + polygonFacesCamera, parseObj, parseMtl, parseGltf, diff --git a/packages/react/src/scene/PolyMesh.test.tsx b/packages/react/src/scene/PolyMesh.test.tsx index a61f8504..aea5c322 100644 --- a/packages/react/src/scene/PolyMesh.test.tsx +++ b/packages/react/src/scene/PolyMesh.test.tsx @@ -68,7 +68,7 @@ function renderMesh(props: React.ComponentProps): HTMLElement { function renderMeshWithChildren( props: React.ComponentProps, - children: (polygon: Polygon, index: number) => React.ReactNode + children: React.ComponentProps["children"] ): HTMLElement { const container = document.createElement("div"); document.body.appendChild(container); @@ -286,6 +286,18 @@ describe("PolyMesh — render-prop children", () => { expect(custom).toBeTruthy(); expect(custom?.getAttribute("data-index")).toBe("0"); }); + + it("static children render inside the mesh wrapper without replacing polygon leaves", () => { + const container = renderMeshWithChildren( + { polygons: [TRIANGLE] }, + React.createElement("div", { className: "static-mesh-child" }), + ); + const mesh = container.querySelector(".polycss-mesh") as HTMLElement; + const child = container.querySelector(".static-mesh-child"); + expect(child).toBeTruthy(); + expect(child?.parentElement).toBe(mesh); + expect(mesh.querySelectorAll("i,b,s,u").length).toBe(1); + }); }); describe("PolyMesh — loading and error states (with src)", () => { diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index d40e26b8..3ba13f11 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -11,6 +11,8 @@ * - Returned elements render INSIDE the .polycss-mesh wrapper, so they * inherit the mesh transform automatically. Don't re-apply position * or you'll double-transform. + * - Non-function children are static wrapper children, matching Vue's + * default slot behavior. */ import { forwardRef, @@ -90,8 +92,8 @@ export interface PolyMeshProps extends TransformProps, InteractionProps { * a device-appropriate memory budget (~4 MB mobile / ~16 MB desktop). * Numeric values 0.1..1 force an explicit scale. */ textureQuality?: TextureQuality; - /** Per-polygon override render. Receives the polygon + its index. */ - children?: (polygon: Polygon, index: number) => ReactNode; + /** Per-polygon override render, or static children mounted inside the mesh wrapper. */ + children?: ((polygon: Polygon, index: number) => ReactNode) | ReactNode; /** Loading slot — rendered while `src` is being fetched/parsed. */ fallback?: ReactNode; /** Error slot — rendered if parse fails. Receives the Error. */ @@ -218,6 +220,11 @@ export const PolyMesh = forwardRef(function PolyM } const sourcePolygons = localPolygons ?? externalPolygons; + const hasRenderProp = typeof children === "function"; + const renderPolygon = hasRenderProp + ? children as (polygon: Polygon, index: number) => ReactNode + : null; + const staticChildren: ReactNode = hasRenderProp ? null : children as ReactNode; // Re-center vertices into mesh-local space if autoCenter is set. Done // once per polygon-list identity — bake into vertices, not per frame. @@ -490,7 +497,7 @@ export const PolyMesh = forwardRef(function PolyM const atlasPlans = useMemo( () => { - if (children) return []; + if (renderPolygon) return []; const repairEdges = buildTextureEdgeRepairSets(polygons); return polygons.map((p, i) => computeTextureAtlasPlan(p, i, { directionalLight: bakedDirectional, @@ -498,7 +505,7 @@ export const PolyMesh = forwardRef(function PolyM textureEdgeRepairEdges: repairEdges[i], })); }, - [children, polygons, bakedDirectional, effectiveAmbient], + [renderPolygon, polygons, bakedDirectional, effectiveAmbient], ); const textureAtlas = useTextureAtlas( atlasPlans, @@ -506,8 +513,8 @@ export const PolyMesh = forwardRef(function PolyM textureQuality, ); const solidPaintDefaults = useMemo( - () => !children ? getSolidPaintDefaults(atlasPlans, effectiveTextureLighting) : {}, - [children, atlasPlans, effectiveTextureLighting], + () => !renderPolygon ? getSolidPaintDefaults(atlasPlans, effectiveTextureLighting) : {}, + [renderPolygon, atlasPlans, effectiveTextureLighting], ); const defaultPaintVars = useMemo( () => solidPaintVars(solidPaintDefaults), @@ -538,7 +545,7 @@ export const PolyMesh = forwardRef(function PolyM // the outlines are identical. Deduplication removes stacked coplanar // shadow leaves that would produce visible double-shadows on the receiver. const shadowLeaves = useMemo(() => { - if (!castShadow || effectiveTextureLighting !== "dynamic" || children) return []; + if (!castShadow || effectiveTextureLighting !== "dynamic" || renderPolygon) return []; const shadowColor = sceneCtx?.shadow?.color ?? "#000000"; const shadowOpacity = sceneCtx?.shadow?.opacity ?? 0.25; @@ -567,7 +574,7 @@ export const PolyMesh = forwardRef(function PolyM ); } return leaves; - }, [castShadow, effectiveTextureLighting, children, polygons, atlasPlans, sceneCtx?.shadow]); + }, [castShadow, effectiveTextureLighting, renderPolygon, polygons, atlasPlans, sceneCtx?.shadow]); const wrapperStyle: CSSProperties = { transform, @@ -576,12 +583,12 @@ export const PolyMesh = forwardRef(function PolyM ...defaultPaintVars, }; - const renderedPolygons = children + const renderedPolygons = renderPolygon ? polygons.map((p, i) => ( // Render-prop: caller controls how each polygon renders. We still // wrap in a fragment with key so React reconciliation works. - {children} + {renderPolygon} )) : textureAtlas.entries.map((entry, index) => { @@ -656,6 +663,7 @@ export const PolyMesh = forwardRef(function PolyM > {shadowLeaves} {renderedPolygons} + {staticChildren} ); }); diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index be232027..da81ce68 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -108,15 +108,30 @@ export type { TexturePaintMetricsOptions, CoverPlanarPolygonsOptions, CullInteriorOptions, + CameraCullNormalGroup, + CameraCullRotation, ApproximateMergeOptions, OptimizeMeshPolygonsOptions, } from "@layoutit/polycss-core"; export { + CAMERA_BACKFACE_CULL_EPS, + VOXEL_CAMERA_CULL_AXIS_EPS, + VOXEL_CAMERA_CULL_NORMAL_LIMIT, normalizePolygons, mergePolygons, coverPlanarPolygons, optimizeMeshPolygons, cullInteriorPolygons, + cameraCullNormalGroups, + cameraCullNormalGroupsFromPolygons, + cameraCullNormalKey, + cameraCullVisibleSignature, + cameraFacingDepth, + isAxisAlignedSurfaceNormal, + isVoxelCameraCullableNormalGroups, + normalFacesCamera, + polygonCssSurfaceNormal, + polygonFacesCamera, parseObj, parseMtl, parseGltf, diff --git a/website/public/gallery/glb/apocalypse/electric_pole_1.glb b/website/public/gallery/glb/apocalypse/electric_pole_1.glb new file mode 100644 index 00000000..8cea19f4 Binary files /dev/null and b/website/public/gallery/glb/apocalypse/electric_pole_1.glb differ diff --git a/website/public/gallery/glb/apocalypse/ground_planks.glb b/website/public/gallery/glb/apocalypse/ground_planks.glb new file mode 100644 index 00000000..acfac46b Binary files /dev/null and b/website/public/gallery/glb/apocalypse/ground_planks.glb differ diff --git a/website/public/gallery/glb/apocalypse/jerry_can_with_nozzle.glb b/website/public/gallery/glb/apocalypse/jerry_can_with_nozzle.glb new file mode 100644 index 00000000..37d26715 Binary files /dev/null and b/website/public/gallery/glb/apocalypse/jerry_can_with_nozzle.glb differ diff --git a/website/public/gallery/glb/apocalypse/pallet_cluster_1.glb b/website/public/gallery/glb/apocalypse/pallet_cluster_1.glb new file mode 100644 index 00000000..1a72e41a Binary files /dev/null and b/website/public/gallery/glb/apocalypse/pallet_cluster_1.glb differ diff --git a/website/public/gallery/glb/apocalypse/road_barrier.glb b/website/public/gallery/glb/apocalypse/road_barrier.glb new file mode 100644 index 00000000..70ceb608 Binary files /dev/null and b/website/public/gallery/glb/apocalypse/road_barrier.glb differ diff --git a/website/public/gallery/glb/apocalypse/tire.glb b/website/public/gallery/glb/apocalypse/tire.glb new file mode 100644 index 00000000..f6b14062 Binary files /dev/null and b/website/public/gallery/glb/apocalypse/tire.glb differ diff --git a/website/public/gallery/glb/apocalypse/wall_1_hole.glb b/website/public/gallery/glb/apocalypse/wall_1_hole.glb new file mode 100644 index 00000000..8c9d5657 Binary files /dev/null and b/website/public/gallery/glb/apocalypse/wall_1_hole.glb differ diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index f4e6305c..967f340b 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -99,7 +99,7 @@ const DEFAULT_SCENE: SceneOptionsState = { matrixPrecision: "exact", borderShapePrecision: "exact", meshResolution: "lossy", - meshInteriorFill: true, + meshInteriorFill: false, outlinePolygons: false, dragMode: "orbit", target: [0, 0, 0], @@ -490,8 +490,6 @@ export default function GalleryWorkbench() { observer.observe(root, { childList: true, subtree: true, - attributes: true, - attributeFilter: ["class", "style"], }); return () => { observer.disconnect(); @@ -520,8 +518,6 @@ export default function GalleryWorkbench() { observer.observe(root, { childList: true, subtree: true, - attributes: true, - attributeFilter: ["style"], }); return () => { observer.disconnect(); @@ -576,7 +572,7 @@ export default function GalleryWorkbench() { // Inspector data — grouped by mesh, then by polygon color. Recomputed // when renderModelPolygons or the loaded model change. Mutations to a - // polygon's color via the picker do NOT change the renderPolygons + // polygon's color via the picker do NOT change the renderModelPolygons // reference, so this memo doesn't re-fire on each tweak and the swatch // local state stays in sync. const inspectorMeshes = useMemo(() => { diff --git a/website/src/components/GalleryWorkbench/helpers/domMetrics.ts b/website/src/components/GalleryWorkbench/helpers/domMetrics.ts index 34bb0cb2..ce50ad09 100644 --- a/website/src/components/GalleryWorkbench/helpers/domMetrics.ts +++ b/website/src/components/GalleryWorkbench/helpers/domMetrics.ts @@ -11,7 +11,7 @@ export const EMPTY_METRICS: DomMetrics = { export function measureDom(root: HTMLElement | null): DomMetrics { if (!root) return EMPTY_METRICS; - const modelScopes = Array.from(root.querySelectorAll(".dn-model-mesh, .dn-interior-fill-mesh")); + const modelScopes = Array.from(root.querySelectorAll(".dn-model-mesh")); if (modelScopes.length === 0) return EMPTY_METRICS; const scopes = modelScopes; const countInScopes = (selector: string): number => diff --git a/website/src/components/GalleryWorkbench/helpers/loaders.ts b/website/src/components/GalleryWorkbench/helpers/loaders.ts index ebbb3fbe..13ce6548 100644 --- a/website/src/components/GalleryWorkbench/helpers/loaders.ts +++ b/website/src/components/GalleryWorkbench/helpers/loaders.ts @@ -1,6 +1,5 @@ import { bakeSolidTextureSamples, - optimizeMeshPolygons, parseGltf, parseMtl, parseObj, @@ -95,12 +94,11 @@ export async function loadPresetModel( }, }); const parsed = await bakeSolidTextureSamples(parsedObj); - const finalPolys = optimizeMeshPolygons(parsed.polygons, { meshResolution: "lossless" }); return { label: model.label, kind: "obj", rawPolygons: parsed.polygons, - polygons: finalPolys, + polygons: parsed.polygons, sourcePolygons: parsed.polygons.length, sourceBytes: objText.length + (mtlText?.length ?? 0), warnings: parsed.warnings ?? [], @@ -116,12 +114,11 @@ export async function loadPresetModel( if (model.kind === "vox") { const parsed = parseVox(buf, mergeParserOptions(model.options, parser)); - const finalPolys = optimizeMeshPolygons(parsed.polygons, { meshResolution: "lossless" }); return { label: model.label, kind: "vox", rawPolygons: parsed.polygons, - polygons: finalPolys, + polygons: parsed.polygons, sourcePolygons: parsed.polygons.length, sourceBytes: buf.byteLength, warnings: parsed.warnings ?? [], @@ -135,12 +132,11 @@ export async function loadPresetModel( baseUrl: new URL(model.url, window.location.href).href, }); const parsed = await bakeSolidTextureSamples(parsedGltf); - const finalPolys = optimizeMeshPolygons(parsed.polygons, { meshResolution: "lossless" }); return { label: model.label, kind: model.kind, rawPolygons: parsed.polygons, - polygons: finalPolys, + polygons: parsed.polygons, sourcePolygons: parsed.polygons.length, sourceBytes: buf.byteLength, warnings: parsed.warnings ?? [], @@ -204,13 +200,12 @@ export async function loadDroppedModel( }, }); const parsed = await bakeSolidTextureSamples(parsedObj); - const finalPolys = optimizeMeshPolygons(parsed.polygons, { meshResolution: "lossless" }); let disposed = false; return { label: source.label, kind: "obj", rawPolygons: parsed.polygons, - polygons: finalPolys, + polygons: parsed.polygons, sourcePolygons: parsed.polygons.length, sourceBytes, warnings: [...(parsed.warnings ?? []), ...warnings], @@ -228,12 +223,11 @@ export async function loadDroppedModel( if (source.kind === "vox") { const parsed = parseVox(buf, options); - const finalPolys = optimizeMeshPolygons(parsed.polygons, { meshResolution: "lossless" }); return { label: source.label, kind: "vox", rawPolygons: parsed.polygons, - polygons: finalPolys, + polygons: parsed.polygons, sourcePolygons: parsed.polygons.length, sourceBytes, warnings: parsed.warnings ?? [], @@ -244,12 +238,11 @@ export async function loadDroppedModel( const parsedGltf = parseGltf(buf, options); const parsed = await bakeSolidTextureSamples(parsedGltf); - const finalPolys = optimizeMeshPolygons(parsed.polygons, { meshResolution: "lossless" }); return { label: source.label, kind: "glb", rawPolygons: parsed.polygons, - polygons: finalPolys, + polygons: parsed.polygons, sourcePolygons: parsed.polygons.length, sourceBytes, warnings: parsed.warnings ?? [], diff --git a/website/src/components/GalleryWorkbench/presets/presetFiles.ts b/website/src/components/GalleryWorkbench/presets/presetFiles.ts index bd9bdbe5..afd058ae 100644 --- a/website/src/components/GalleryWorkbench/presets/presetFiles.ts +++ b/website/src/components/GalleryWorkbench/presets/presetFiles.ts @@ -149,7 +149,6 @@ export const POLY_PIZZA_PRESET_FILES: GalleryPresetFile[] = [ file: "poly-pizza/bird.glb", label: "Bird", category: "Animals", - galleryBucket: "Textured", attribution: { creator: "Quaternius", license: "CC0 1.0", @@ -172,7 +171,6 @@ export const POLY_PIZZA_PRESET_FILES: GalleryPresetFile[] = [ file: "poly-pizza/ducky.glb", label: "Ducky", category: "Animals", - galleryBucket: "Textured", attribution: { creator: "Isa Lousberg", license: "CC0 1.0", @@ -283,7 +281,6 @@ export const POLY_PIZZA_PRESET_FILES: GalleryPresetFile[] = [ file: "poly-pizza/bucket.glb", label: "Bucket", category: "Objects", - galleryBucket: "Textured", attribution: { creator: "Quaternius", license: "CC0 1.0", @@ -374,7 +371,6 @@ export const POLY_PIZZA_PRESET_FILES: GalleryPresetFile[] = [ file: "poly-pizza/empty-box.glb", label: "Empty Box", category: "Objects", - galleryBucket: "Textured", attribution: { creator: "CreativeTrio", license: "CC0 1.0", @@ -441,7 +437,6 @@ export const POLY_PIZZA_PRESET_FILES: GalleryPresetFile[] = [ file: "poly-pizza/arrow.glb", label: "Arrow", category: "Weapons", - galleryBucket: "Textured", attribution: { creator: "CreativeTrio", license: "CC0 1.0", diff --git a/website/src/components/GalleryWorkbench/presets/presetList.ts b/website/src/components/GalleryWorkbench/presets/presetList.ts index ce624bbf..12d8642e 100644 --- a/website/src/components/GalleryWorkbench/presets/presetList.ts +++ b/website/src/components/GalleryWorkbench/presets/presetList.ts @@ -114,6 +114,97 @@ export const PRESETS: PresetModel[] = [ rotY: 45, attribution: ATOMIC_REALM_POST_APOCALYPTIC_ATTRIBUTION, }, + { + id: "apoc-tire", + label: "Apocalypse Tire (GLB)", + category: "Objects", + kind: "glb", + url: "/gallery/glb/apocalypse/tire.glb", + options: { targetSize: 50 }, + galleryBucket: "Textured", + zoom: 0.5, + rotX: 65, + rotY: 45, + attribution: ATOMIC_REALM_POST_APOCALYPTIC_ATTRIBUTION, + }, + { + id: "apoc-ground-planks", + label: "Ground Planks (GLB)", + category: "Environment", + kind: "glb", + url: "/gallery/glb/apocalypse/ground_planks.glb", + options: { targetSize: 60 }, + galleryBucket: "Textured", + zoom: 0.55, + rotX: 65, + rotY: 45, + attribution: ATOMIC_REALM_POST_APOCALYPTIC_ATTRIBUTION, + }, + { + id: "apoc-jerry-can", + label: "Jerry Can (GLB)", + category: "Objects", + kind: "glb", + url: "/gallery/glb/apocalypse/jerry_can_with_nozzle.glb", + options: { targetSize: 50 }, + galleryBucket: "Textured", + zoom: 0.5, + rotX: 65, + rotY: 45, + attribution: ATOMIC_REALM_POST_APOCALYPTIC_ATTRIBUTION, + }, + { + id: "apoc-pallet-cluster", + label: "Pallet Cluster (GLB)", + category: "Objects", + kind: "glb", + url: "/gallery/glb/apocalypse/pallet_cluster_1.glb", + options: { targetSize: 60 }, + galleryBucket: "Textured", + zoom: 0.45, + rotX: 65, + rotY: 45, + attribution: ATOMIC_REALM_POST_APOCALYPTIC_ATTRIBUTION, + }, + { + id: "apoc-road-barrier", + label: "Road Barrier (GLB)", + category: "Objects", + kind: "glb", + url: "/gallery/glb/apocalypse/road_barrier.glb", + options: { targetSize: 60 }, + galleryBucket: "Textured", + zoom: 0.45, + rotX: 65, + rotY: 45, + attribution: ATOMIC_REALM_POST_APOCALYPTIC_ATTRIBUTION, + }, + { + id: "apoc-electric-pole", + label: "Electric Pole (GLB)", + category: "Environment", + kind: "glb", + url: "/gallery/glb/apocalypse/electric_pole_1.glb", + options: { targetSize: 70 }, + galleryBucket: "Textured", + zoom: 0.35, + rotX: 65, + rotY: 45, + attribution: ATOMIC_REALM_POST_APOCALYPTIC_ATTRIBUTION, + }, + { + id: "apoc-wall-hole", + label: "Wall with Hole (GLB)", + category: "Architecture", + kind: "glb", + url: "/gallery/glb/apocalypse/wall_1_hole.glb", + options: { targetSize: 60 }, + galleryBucket: "Textured", + zoom: 0.45, + rotX: 65, + rotY: 45, + attribution: ATOMIC_REALM_POST_APOCALYPTIC_ATTRIBUTION, + }, { id: "apoc-spike", label: "Spike Barricade (GLB)", @@ -145,7 +236,6 @@ export const PRESETS: PresetModel[] = [ kind: "glb", url: "/gallery/glb/poly-pizza/flying-saucer.glb", options: { targetSize: 60, defaultColor: "#94a3b8" }, - galleryBucket: "Textured", zoom: 0.2, rotX: 67, rotY: 42.3, diff --git a/website/src/components/ReactScene/ReactScene.tsx b/website/src/components/ReactScene/ReactScene.tsx index ee6f5c7b..13ea2ac5 100644 --- a/website/src/components/ReactScene/ReactScene.tsx +++ b/website/src/components/ReactScene/ReactScene.tsx @@ -1,4 +1,4 @@ -import type { RefObject } from "react"; +import { useMemo, type RefObject } from "react"; import { PolyAxesHelper, PolyOrthographicCamera, @@ -18,9 +18,27 @@ import type { Polygon, Vec3, } from "@layoutit/polycss-react"; -import type { TextureQuality } from "@layoutit/polycss"; +import { + cameraCullNormalGroupsFromPolygons, + isVoxelCameraCullableNormalGroups, + polygonFacesCamera, + type TextureQuality, +} from "@layoutit/polycss"; import type { GizmoMode, SceneOptionsState } from "../types"; +function canCullCameraBackfaces(polygons: Polygon[]): boolean { + return isVoxelCameraCullableNormalGroups(cameraCullNormalGroupsFromPolygons(polygons)); +} + +function cullCameraBackfaces( + polygons: Polygon[], + rotX: number, + rotY: number, + meshRotation?: Vec3, +): Polygon[] { + return polygons.filter((polygon) => polygonFacesCamera(polygon, { rotX, rotY, meshRotation })); +} + export interface ReactSceneProps { rendererDebugKey: string; sceneOptions: SceneOptionsState; @@ -76,12 +94,37 @@ 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 fillMesh = interiorFillPolygons.length > 0 ? ( + const centerPolygons = useMemo( + () => interiorFillPolygons.length > 0 + ? [...scenePolygons, ...interiorFillPolygons] + : scenePolygons, + [scenePolygons, interiorFillPolygons], + ); + const effectiveMeshRotation = sceneOptions.selection ? meshRotation : undefined; + const canCullScenePolygons = useMemo( + () => canCullCameraBackfaces(scenePolygons), + [scenePolygons], + ); + const canCullInteriorFillPolygons = useMemo( + () => canCullCameraBackfaces(interiorFillPolygons), + [interiorFillPolygons], + ); + const visibleScenePolygons = useMemo( + () => canCullScenePolygons + ? cullCameraBackfaces(scenePolygons, sceneOptions.rotX, sceneOptions.rotY, effectiveMeshRotation) + : scenePolygons, + [scenePolygons, canCullScenePolygons, sceneOptions.rotX, sceneOptions.rotY, effectiveMeshRotation], + ); + const visibleInteriorFillPolygons = useMemo( + () => canCullInteriorFillPolygons + ? cullCameraBackfaces(interiorFillPolygons, sceneOptions.rotX, sceneOptions.rotY, effectiveMeshRotation) + : interiorFillPolygons, + [interiorFillPolygons, canCullInteriorFillPolygons, sceneOptions.rotX, sceneOptions.rotY, effectiveMeshRotation], + ); + const fillMesh = visibleInteriorFillPolygons.length > 0 ? ( ) : null; return ( @@ -105,7 +148,7 @@ export function ReactScene({ )} setHoveredMeshId(null) : undefined } - /> + > + {fillMesh} + ) : null} - {sceneOptions.selection ? fillMesh : null} {!sceneOptions.selection ? ( - <> - + {fillMesh} - + ) : null} {sceneOptions.selection && selectedMeshes.length > 0 && ( { + const modelEl = meshHandleRef.current?.element; + const fillEl = interiorFillHandleRef.current?.element; + if (!modelEl || !fillEl) return; + if (fillEl.parentElement !== modelEl || fillEl.nextSibling !== null) { + modelEl.appendChild(fillEl); + } + }, []); + // Split things into "structural" (require destroying the scene) vs // "incremental" (can be applied via setOptions / setTransform). In // dynamic mode the chicken's atlas is light-independent, so we drop the @@ -182,8 +191,9 @@ export function VanillaScene({ ]); // Effect 1.5 — replace geometry on the existing mesh. This is the path - // used by animated GLB playback. Interior fill lives in a trailing helper - // mesh so its oval CSS can be scoped without stamping every leaf. + // used by animated GLB playback. Interior fill remains a non-shadow mesh, + // but its wrapper is mounted inside the model mesh wrapper so it inherits + // the same mesh transform. useEffect(() => { const handle = meshHandleRef.current; const scene = sceneRef.current; @@ -204,6 +214,7 @@ export function VanillaScene({ stableDom: stableDomForMesh, recomputeAutoCenter: false, }); + mountInteriorFillInsideModel(); } else { fillHandle = scene.add( { @@ -220,17 +231,14 @@ export function VanillaScene({ }, ); fillHandle.element.classList.add("dn-interior-fill-mesh"); - fillHandle.setTransform({ - position: handle.transform.position, - rotation: handle.transform.rotation, - }); interiorFillHandleRef.current = fillHandle; + mountInteriorFillInsideModel(); } requestAnimationFrame(() => onBuildRef.current(performance.now() - started), ); - }, [polygons, interiorFillPolygons, mergePolygonsForMesh, stableDomForMesh]); + }, [polygons, interiorFillPolygons, mergePolygonsForMesh, stableDomForMesh, mountInteriorFillInsideModel]); // Effect 1.6 — live-toggle castShadow without rebuilding the scene. useEffect(() => { @@ -256,13 +264,6 @@ export function VanillaScene({ if (!scene) return; const tc = createTransformControls(scene, { mode: gizmoMode ?? "translate", - onObjectChange: (event) => { - if (event.object !== meshHandleRef.current) return; - interiorFillHandleRef.current?.setTransform({ - position: event.object.transform.position, - rotation: event.object.transform.rotation, - }); - }, }); transformControlsRef.current = tc; const select = createSelect(scene, {