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, {