Skip to content
This repository was archived by the owner on May 19, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 0 additions & 1 deletion bench/perf-cracks.html
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,6 @@ <h1>crack finder</h1>
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
Expand Down
95 changes: 95 additions & 0 deletions packages/core/src/cull/cameraBackfaceCulling.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
116 changes: 116 additions & 0 deletions packages/core/src/cull/cameraBackfaceCulling.ts
Original file line number Diff line number Diff line change
@@ -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<Vec3 | null | undefined>,
): CameraCullNormalGroup[] {
const groups = new Map<string, Vec3>();
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("|");
}
19 changes: 19 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
115 changes: 115 additions & 0 deletions packages/core/src/parser/loadMesh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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([
Expand Down
Loading
Loading