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
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Public API is **mirrored** across React and Vue. Adding a hook on one side witho

**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.

Raw MagicaVoxel `.vox` sources have a narrower baked-mode fast path: `parseVox` still returns the polygon mesh for bounds, fallback rendering, and public handles, but also preserves a `PolyVoxelSource`. Eligible vanilla meshes render that source through three axis hosts plus absolutely positioned rectangular brush leaves, using the voxcss `mergeVoxels: "3d"` slice planner rather than one `matrix3d` per polygon. `.vox` normalization snaps to the nearest integer CSS cell size so brush rectangles use integer pixel coordinates without any scale wrapper. Brush colors still receive baked Lambert shading from the scene lights. Dynamic lighting, shadows, stable DOM animation, and geometry replaced via `setPolygons` fall back to the polygon renderer.

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 All @@ -38,12 +40,14 @@ Strategies are ordered cheapest → most expensive. The mesher's job is to maxim

Callers can opt out of specific strategies via `strategies: { disable: ["b" | "i" | "u"] }` on `RenderTextureAtlasOptions`. Disabled strategies fall through the chain (`b → i → s`, `u → i → s`, `i → s`). `<s>` is the universal fallback and cannot be disabled.

The voxel slice-brush fast path emits plain `<b>` quad elements inside axis hosts. They intentionally reuse the cheap quad tag, but they are absolutely positioned brush rectangles rather than polygon strategy leaves and do not use one `matrix3d` per polygon.

### Lighting modes (`PolyTextureLightingMode = "baked" | "dynamic"`)

- **Baked.** Lambert is computed once on the CPU per polygon, multiplied into the inline `color` (for `<b>`/`<i>`/`<u>`) or into the rasterised atlas pixels (for `<s>`). Moving a light requires re-rasterising affected polys.
- **Dynamic.** Scene root carries the light setup as custom properties (`--plx/y/z`, `--plr/g/b`, `--pli`, `--par/g/b`, `--pai`). Each leaf embeds its surface normal (`--pnx/y/z`) and base color (`--psr/g/b`) inline. CSS `calc()` resolves the Lambert dot product and per-channel tint at paint time. Moving a light mutates one var on the scene root — zero JS, no atlas redraw.

All solid/atlas tags work in both modes. The full coverage matrix is in `packages/polycss/src/styles/styles.ts`.
All solid/atlas tags work in both modes. The `.vox` slice-brush fast path is baked-only for now; dynamic mode uses the polygon path so lighting semantics stay correct. The full coverage matrix is in `packages/polycss/src/styles/styles.ts`.

### Meshing implications (what generators must respect)

Expand Down Expand Up @@ -77,7 +81,7 @@ If you find yourself wanting a `requestAnimationFrame` loop to update many DOM n
- **Hooks/composables:** `usePolyCamera`, `usePolyMesh`, `usePolySceneContext`, `usePolySelect`, `usePolySelectionApi`, `usePolyAnimation`.
- **Components:** `PolyPerspectiveCamera`, `PolyOrthographicCamera`, `PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`, `PolySelect`, `PolyAxesHelper`, `PolyDirectionalLightHelper`, `PolyControls`.
- **Types:** `PolyDirectionalLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyAnimationMixer`.
- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`.
- **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `buildPolyVoxelFaceData`, `buildPolyVoxelSlicePlan`.
- **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createPolyControls`, `createTransformControls`, `createSelect`).
- **HTML custom elements:** `poly-` prefix + kebab-case. Existing tags: `<poly-scene>`, `<poly-mesh>`, `<poly-polygon>`, `<poly-controls>`, `<poly-axes-helper>`, `<poly-directional-light-helper>`. Any new element follows the same shape (e.g. `<poly-perspective-camera>`, `<poly-transform-controls>`, `<poly-select>`).
- **Leaf DOM tags (`<b>`, `<i>`, `<s>`, `<u>`):** internal render-strategy tags. Not part of the public API and not user-facing — do not document them as such.
Expand Down
5 changes: 5 additions & 0 deletions bench/perf-shared.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ export const PRESETS = {
options: { targetSize: 60 },
zoom: 0.4, rotX: 65, rotY: 45,
},
"apoc-car": {
url: "/gallery/glb/apocalypse/car.glb",
options: { targetSize: 60 },
zoom: 0.4, rotX: 65, rotY: 45,
},
crate: {
url: "/gallery/obj/opengameart/crate/Box.obj",
mtlUrl: "/gallery/obj/opengameart/crate/Box.mtl",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ npm install @layoutit/polycss-core
| `parseObj(text, options?)` | Parses OBJ text into `ParseResult`. Supports UV (`vt`), materials, `map_Kd` textures. |
| `parseMtl(text)` | Parses MTL text into `{ colors, textures }`. |
| `parseGltf(buffer, options?)` | Parses GLB or glTF `ArrayBuffer` into `ParseResult`. Extracts embedded textures as blob URLs. |
| `parseVox(buffer, options?)` | Parses MagicaVoxel `.vox` `ArrayBuffer` into `ParseResult`. Face-culls interior voxel faces and fan-triangulates exposed quads. |
| `parseVox(buffer, options?)` | Parses MagicaVoxel `.vox` `ArrayBuffer` into `ParseResult`. Face-culls interior voxel faces and fan-triangulates exposed quads. `targetSize` snaps to integer voxel CSS cells for the slice-brush renderer. |
| `loadMesh(url, options?)` | Fetches a URL, dispatches to the right parser by extension (`.obj`, `.glb`, `.gltf`, `.vox`). Returns `Promise<ParseResult>` and defaults to `meshResolution: "lossy"`. |
| `parseColor(input)` | Parse any CSS color string to `{ r, g, b, a }`. |
| `shadeColor(input, lambert, ...)` | Apply Lambert shading factor to a color. |
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ export type {
export type {
ParseAnimationClip,
ParseAnimationController,
PolyVoxelCell,
PolyVoxelSource,
ParseResult,
} from "./parser/types";
export { parseObj } from "./parser/parseObj";
Expand All @@ -155,5 +157,20 @@ export {
export type { SolidTextureSampleOptions } from "./parser/solidTextureSamples";
export { parseVox } from "./parser/parseVox";
export type { VoxParseOptions } from "./parser/parseVox";
export {
buildFaceDataFromVoxelSource as buildPolyVoxelFaceData,
buildSlicePlan as buildPolyVoxelSlicePlan,
NEXT_LAYER_STEP as POLY_VOXEL_NEXT_LAYER_STEP,
} from "./voxel/voxelSlicePlanner";
export type {
Brush as PolyVoxelBrush,
FaceBuffer as PolyVoxelFaceBuffer,
FaceData as PolyVoxelFaceData,
FaceKey as PolyVoxelFaceKey,
PlaneAxis as PolyVoxelPlaneAxis,
PolyVoxelFace,
PolyVoxelWallsMask,
SlicePlan as PolyVoxelSlicePlan,
} from "./voxel/voxelSlicePlanner";
export { loadMesh } from "./parser/loadMesh";
export type { LoadMeshOptions } from "./parser/loadMesh";
4 changes: 4 additions & 0 deletions packages/core/src/parser/loadMesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export interface LoadMeshOptions {
const FETCH_NAME = "loadMesh";

function withMeshResolution(result: ParseResult, options?: LoadMeshOptions): ParseResult {
// parseVox already emits greedy axis-aligned quads, and voxel fast paths
// need load-time latency dominated by the raw voxel source rather than a
// second generic polygon optimizer pass with marginal fallback savings.
if (result.voxelSource) return result;
const polygons = optimizeMeshPolygons(result.polygons, {
meshResolution: options?.meshResolution,
});
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/parser/parseVox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,35 @@ describe("parseVox — minimal synthetic buffer", () => {
expect(result.polygons.length).toBe(6);
});

it("preserves normalized raw voxel source for slice-brush rendering", () => {
const buf = buildVoxBuffer([3, 2, 1], [{ x: 2, y: 1, z: 0, colorIndex: 1 }]);
const result = parseVox(buf, { targetSize: 30, gridShift: 2 });
expect(result.voxelSource).toEqual({
kind: "magica-vox",
cells: [{ x: 0, y: 0, z: 0, color: "#ffffff" }],
rows: 1,
cols: 1,
depth: 1,
scale: 30,
gridShift: 2,
sourceBytes: buf.byteLength,
});
});

it("snaps voxel scale to integer CSS cell sizes", () => {
const buf = buildVoxBuffer(
[80, 1, 1],
[
{ x: 0, y: 0, z: 0, colorIndex: 1 },
{ x: 79, y: 0, z: 0, colorIndex: 1 },
],
);
const result = parseVox(buf, { targetSize: 70, gridShift: 0 });
expect(result.voxelSource?.scale).toBe(0.88);
const xs = result.polygons.flatMap((p) => p.vertices.map((v) => v[0]));
expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(70.4, 3);
});

it("two adjacent voxels share one face — greedy-meshed to 6 polys", () => {
// Two voxels side by side on X: (0,0,0) and (1,0,0). Same material →
// greedy mesh runs each long face as a single 2×1 rectangle:
Expand Down
41 changes: 30 additions & 11 deletions packages/core/src/parser/parseVox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,19 @@
* swap). Voxel coordinates are always non-negative (origin at 0), so no
* shift is required by default.
*
* Output mesh is uniformly scaled to fit `targetSize` units along the
* longest bbox axis.
* Output mesh is uniformly scaled near `targetSize` units along the longest
* bbox axis, snapped to the nearest integer CSS cell so voxel slice renderers
* can avoid fractional brush coordinates without adding scale transforms.
*/
import type { Polygon, Vec3 } from "../types";
import type { ParseResult } from "./types";
import { BASE_TILE } from "../camera/camera";
import type { ParseResult, PolyVoxelSource } from "./types";

export interface VoxParseOptions {
/**
* Largest mesh extent (in scene-space units). The mesh is uniformly
* scaled so its longest bbox dimension equals this. Default: 60.
* Largest mesh extent (in scene-space units). For `.vox`, the requested
* extent is snapped to the nearest integer CSS cell size to keep voxel
* slice brushes on integer pixel coordinates. Default: 60.
*/
targetSize?: number;
/**
Expand Down Expand Up @@ -406,7 +409,26 @@ export function parseVox(buffer: ArrayBuffer, options?: VoxParseOptions): ParseR
}
}
const maxDim = Math.max(maxX - minX, maxY - minY, maxZ - minZ);
const scale = maxDim > 0 ? targetSize / maxDim : 1;
const rawScale = maxDim > 0 ? targetSize / maxDim : 1;
const targetCellPx = rawScale * BASE_TILE;
const scale = Number.isFinite(targetCellPx) && targetCellPx > 0
? Math.max(1, Math.round(targetCellPx)) / BASE_TILE
: rawScale;
const voxelSource: PolyVoxelSource = {
kind: "magica-vox",
cells: voxels.map((v) => ({
x: v.x - minX,
y: v.y - minY,
z: v.z - minZ,
color: resolveColor(v.colorIndex),
})),
rows: Math.max(0, maxX - minX),
cols: Math.max(0, maxY - minY),
depth: Math.max(0, maxZ - minZ),
scale,
gridShift,
sourceBytes,
};

const round = (n: number): number => Math.round(n * 1000) / 1000;
const project = (v: Vec3): Vec3 => [
Expand All @@ -422,18 +444,15 @@ export function parseVox(buffer: ArrayBuffer, options?: VoxParseOptions): ParseR

return {
polygons,
voxelSource,
objectUrls: [],
dispose: () => { /* no-op: parseVox has no minted blob URLs */ },
warnings: [],
metadata: {
triangleCount: polygons.length,
sourceBytes,
// voxelCount is a vox-specific extension to the base metadata shape.
// Cast as any to avoid the structural type mismatch — we keep it in
// metadata so callers can access it without polluting the ParseResult type.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
voxelCount: voxels.length,
} as ParseResult["metadata"],
},
};
}

Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/parser/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,24 @@
*/
import type { Polygon } from "../types";

export interface PolyVoxelCell {
x: number;
y: number;
z: number;
color: string;
}

export interface PolyVoxelSource {
kind: "magica-vox";
cells: PolyVoxelCell[];
rows: number;
cols: number;
depth: number;
scale: number;
gridShift: number;
sourceBytes: number;
}

export interface ParseAnimationClip {
/** Stable numeric index in the source file's animation array. */
index: number;
Expand All @@ -35,6 +53,8 @@ export interface ParseAnimationController {
export interface ParseResult {
/** The mesh, as a flat polygon list. Already vertex-permuted to polycss space. */
polygons: Polygon[];
/** Optional raw voxel source for `.vox` fast paths; polygon fallback remains authoritative. */
voxelSource?: PolyVoxelSource;
/** Optional animation sampler for formats that carry timeline data. */
animation?: ParseAnimationController;
/**
Expand Down Expand Up @@ -66,5 +86,7 @@ export interface ParseResult {
animations?: ParseAnimationClip[];
/** Source file size in bytes (for diagnostics). */
sourceBytes?: number;
/** Voxel count for `.vox` sources. */
voxelCount?: number;
};
}
Loading
Loading