Skip to content
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export default function App() {
- `autoCenter` shifts the mesh bbox center to local origin.
- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. STL imports use the conservative lossless path in both modes.
- `castShadow` emits CSS-projected shadows in dynamic lighting mode.
- Tooling can reuse `buildPolyMeshTransform`, `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, and `polyCssPositionToWorld` for renderer-compatible transforms and world/CSS conversions.
- Tooling can reuse `buildPolyMeshTransform`, `buildPolySceneTransform`, `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDirectionalLightToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, and `polyCssPositionToWorld` for renderer-compatible transforms and world/CSS conversions.

### Controls

Expand Down
2 changes: 1 addition & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po
- `autoCenter` shifts the mesh bbox center to local origin.
- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. Lossy also applies bounded seam repair; STL imports use the conservative lossless path in both modes.
- `castShadow` emits CSS-projected shadows in dynamic lighting mode.
- Tooling can reuse `buildPolyMeshTransform`, `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, and `polyCssPositionToWorld` for renderer-compatible transforms and world/CSS conversions.
- Tooling can reuse `buildPolyMeshTransform`, `buildPolySceneTransform`, `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDirectionalLightToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, and `polyCssPositionToWorld` for renderer-compatible transforms and world/CSS conversions.

### Controls

Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,12 +183,15 @@ export {
worldDirectionToCss,
worldDirectionToCss as worldDirectionToPolyCss,
worldDirectionalLightToCss,
worldDirectionalLightToCss as worldDirectionalLightToPolyCss,
worldPositionToCss,
worldPositionToCss as worldPositionToPolyCss,
} from "./shadow/receiverFaceGroups";
export type { ReceiverPlaneGroup } from "./shadow/receiverFaceGroups";
export { buildPolyMeshTransform } from "./transform/meshTransform";
export type { PolyMeshTransformInput } from "./transform/meshTransform";
export { buildPolySceneTransform } from "./transform/sceneTransform";
export type { PolySceneTransformInput } from "./transform/sceneTransform";
export {
buildSharedEdgeMap,
computeReceiverShadowFaces,
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/shadow/receiverFaceGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ export function worldDirectionToCss(d: Vec3): Vec3 {
/** Apply {@link worldDirectionToCss} to a directional-light object,
* preserving the other fields. Used by atlas plan + buildBasisHints +
* receiver-shadow callers so the light vector is in the same CSS-axis
* frame as the polygon normals. Mirror of vanilla's
* `worldDirectionalLightToCss` in `packages/polycss/src/api/scene/transforms.ts`. */
* frame as the polygon normals. Public package wrappers delegate here so
* directional-light conversion stays single-source. */
export function worldDirectionalLightToCss<
T extends { direction?: Vec3 } | undefined,
>(light: T): T {
Expand Down
47 changes: 47 additions & 0 deletions packages/core/src/transform/sceneTransform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { BASE_TILE, DEFAULT_CAMERA_STATE } from "../camera/camera";
import { buildPolySceneTransform } from "./sceneTransform";

describe("buildPolySceneTransform", () => {
it("uses the default camera state when fields are omitted", () => {
expect(buildPolySceneTransform()).toBe(
`scale(${DEFAULT_CAMERA_STATE.zoom / BASE_TILE}) rotateX(${DEFAULT_CAMERA_STATE.rotX}deg) rotate(${DEFAULT_CAMERA_STATE.rotY}deg) translate3d(0px, 0px, 0px)`,
);
});

it("builds the renderer scene-root transform from explicit camera state", () => {
expect(buildPolySceneTransform({ rotX: 30, rotY: 45, zoom: 1 })).toBe(
`scale(${1 / BASE_TILE}) rotateX(30deg) rotate(45deg) translate3d(0px, 0px, 0px)`,
);
});

it("prepends translateZ when distance is non-zero", () => {
expect(buildPolySceneTransform({ rotX: 0, rotY: 0, zoom: BASE_TILE, distance: 100 })).toBe(
"translateZ(-100px) scale(1) rotateX(0deg) rotate(0deg) translate3d(0px, 0px, 0px)",
);
});

it("adds target and autoCenterOffset before world-to-CSS conversion", () => {
expect(buildPolySceneTransform({
rotX: 0,
rotY: 0,
zoom: BASE_TILE,
target: [1, 2, 3],
autoCenterOffset: [10, 20, 30],
})).toBe(
`scale(1) rotateX(0deg) rotate(0deg) translate3d(${-22 * BASE_TILE}px, ${-11 * BASE_TILE}px, ${-33 * BASE_TILE}px)`,
);
});

it("folds layoutScale into scene scale and camera distance", () => {
expect(buildPolySceneTransform({ rotX: 0, rotY: 0, zoom: 1, distance: 10, layoutScale: 2 })).toBe(
`translateZ(-20px) scale(${(1 / BASE_TILE) * 2}) rotateX(0deg) rotate(0deg) translate3d(0px, 0px, 0px)`,
);
});

it("supports a custom world unit size for external adapters", () => {
expect(buildPolySceneTransform({ rotX: 0, rotY: 0, zoom: 1, target: [3, 5, 7], worldUnitPx: 10 })).toBe(
"scale(0.1) rotateX(0deg) rotate(0deg) translate3d(-50px, -30px, -70px)",
);
});
});
45 changes: 45 additions & 0 deletions packages/core/src/transform/sceneTransform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { BASE_TILE, DEFAULT_CAMERA_STATE } from "../camera/camera";
import type { Vec3 } from "../types";
import { worldPositionToCss } from "../shadow/receiverFaceGroups";

export interface PolySceneTransformInput {
/** World point that should appear at the viewport center. */
target?: Vec3;
/** Scene orbit tilt in degrees. */
rotX?: number;
/** Scene orbit rotation around world up in degrees. */
rotY?: number;
/** User-facing zoom in CSS pixels per world unit. */
zoom?: number;
/** Camera pull-back from target in CSS pixels. */
distance?: number;
/** Auto-center offset added to target before world→CSS conversion. */
autoCenterOffset?: Vec3;
/** Extra scale folded into zoom and distance for CSS zoom compensation. */
layoutScale?: number;
/** CSS pixels per PolyCSS world unit. Defaults to the renderer base tile. */
worldUnitPx?: number;
}

/**
* Build the scene-root transform used by PolyCSS renderers:
*
* `translateZ(-distance) scale(zoom / worldUnitPx) rotateX(rotX) rotate(rotY) translate3d(-targetCss)`
*/
export function buildPolySceneTransform(
input: PolySceneTransformInput = {},
): string {
const target = input.target ?? DEFAULT_CAMERA_STATE.target;
const autoCenterOffset = input.autoCenterOffset ?? [0, 0, 0] as Vec3;
const layoutScale = input.layoutScale ?? 1;
const worldUnitPx = input.worldUnitPx ?? BASE_TILE;
const zoom = ((input.zoom ?? DEFAULT_CAMERA_STATE.zoom) / worldUnitPx) * layoutScale;
const distance = (input.distance ?? DEFAULT_CAMERA_STATE.distance) * layoutScale;
const cssTarget = worldPositionToCss([
target[0] + autoCenterOffset[0],
target[1] + autoCenterOffset[1],
target[2] + autoCenterOffset[2],
], worldUnitPx);
const distancePart = distance !== 0 ? `translateZ(${-distance}px) ` : "";
return `${distancePart}scale(${zoom}) rotateX(${input.rotX ?? DEFAULT_CAMERA_STATE.rotX}deg) rotate(${input.rotY ?? DEFAULT_CAMERA_STATE.rotY}deg) translate3d(${-cssTarget[0]}px, ${-cssTarget[1]}px, ${-cssTarget[2]}px)`;
}
2 changes: 1 addition & 1 deletion packages/polycss/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po
- `autoCenter` shifts the mesh bbox center to local origin.
- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. Lossy also applies bounded seam repair; STL imports use the conservative lossless path in both modes.
- `castShadow` emits CSS-projected shadows in dynamic lighting mode.
- Tooling can reuse `buildPolyMeshTransform`, `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, and `polyCssPositionToWorld` for renderer-compatible transforms and world/CSS conversions.
- Tooling can reuse `buildPolyMeshTransform`, `buildPolySceneTransform`, `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDirectionalLightToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, and `polyCssPositionToWorld` for renderer-compatible transforms and world/CSS conversions.

### Controls

Expand Down
43 changes: 20 additions & 23 deletions packages/polycss/src/api/scene/transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@
*
* Constants are exported so call sites can compare against them.
*/
import { BASE_TILE, buildPolyMeshTransform } from "@layoutit/polycss-core";
import {
BASE_TILE,
buildPolyMeshTransform,
buildPolySceneTransform,
worldDirectionToCss as coreWorldDirectionToCss,
worldDirectionalLightToCss as coreWorldDirectionalLightToCss,
worldPositionToCss as coreWorldPositionToCss,
} from "@layoutit/polycss-core";
import type { Polygon, Vec3 } from "@layoutit/polycss-core";
import type {
PolyPerspectiveCameraHandle,
Expand All @@ -35,7 +42,7 @@ export const LAMBERT_BUCKET_PRECISION = 0.1;
* (standard XYZ in world units) and absorb the conversion at the boundary.
*/
export function worldPositionToCss(p: Vec3): Vec3 {
return [p[1] * DEFAULT_TILE, p[0] * DEFAULT_TILE, p[2] * DEFAULT_TILE];
return coreWorldPositionToCss(p);
}

/**
Expand All @@ -46,14 +53,13 @@ export function worldPositionToCss(p: Vec3): Vec3 {
* are unit vectors.
*/
export function worldDirectionToCss(d: Vec3): Vec3 {
return [d[1], d[0], d[2]];
return coreWorldDirectionToCss(d);
}

export function worldDirectionalLightToCss<
T extends { direction?: Vec3 } | undefined,
>(light: T): T {
if (!light?.direction) return light;
return { ...light, direction: worldDirectionToCss(light.direction) } as T;
return coreWorldDirectionalLightToCss(light);
}

/**
Expand Down Expand Up @@ -104,28 +110,19 @@ export function buildSceneTransformFromCamera(
layoutScale = 1,
): string {
const state = camera.state;
const rotX = state.rotX;
const rotY = state.rotY;
// User-facing zoom is "px per world unit" (Three.js OrthographicCamera.zoom
// shape). Renderer geometry already lives at `× DEFAULT_TILE` CSS px, so
// the scene-root CSS scale is `zoom / DEFAULT_TILE`. At `zoom=1` → 1 world
// unit = 1 CSS px on screen.
const userZoom = state.zoom ?? DEFAULT_ZOOM;
const zoom = (userZoom / DEFAULT_TILE) * layoutScale;
const distance = (state.distance ?? 0) * layoutScale;
const target = state.target ?? [0, 0, 0];
const wx = target[0] + autoCenterOffset[0];
const wy = target[1] + autoCenterOffset[1];
const wz = target[2] + autoCenterOffset[2];
const cssX = wy * DEFAULT_TILE;
const cssY = wx * DEFAULT_TILE;
const cssZ = wz * DEFAULT_TILE;
// rotate() (i.e. rotateZ) — NOT rotateY. After the rotateX tilt, the world's
// Z axis is what reads as "spin in place"; rotateY rotates around an oblique
// axis and makes the mesh wobble. translateZ(-distance) is outermost — pulls
// the camera back from the target along the view axis.
const distancePart = distance !== 0 ? `translateZ(${-distance}px) ` : "";
return `${distancePart}scale(${zoom}) rotateX(${rotX}deg) rotate(${rotY}deg) translate3d(${-cssX}px, ${-cssY}px, ${-cssZ}px)`;
return buildPolySceneTransform({
rotX: state.rotX,
rotY: state.rotY,
zoom: state.zoom ?? DEFAULT_ZOOM,
distance: state.distance ?? 0,
target: state.target ?? [0, 0, 0],
autoCenterOffset,
layoutScale,
});
}

export function parseCssZoom(value: string): number {
Expand Down
27 changes: 27 additions & 0 deletions packages/polycss/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { describe, expect, it } from "vitest";
import { BASE_TILE } from "@layoutit/polycss-core";
import {
buildPolyMeshTransform,
buildPolySceneTransform,
polyCssDistanceToWorld,
polyCssPositionToWorld,
worldDistanceToPolyCss,
worldDirectionalLightToPolyCss,
worldDirectionToPolyCss,
worldPositionToPolyCss,
} from "./index";
import type { PolySceneTransformInput } from "./index";

describe("public transform helpers", () => {
it("exposes the world-to-CSS position conversion used by scene meshes", () => {
Expand All @@ -22,6 +25,18 @@ describe("public transform helpers", () => {
expect(worldDirectionToPolyCss([1, 2, 3])).toEqual([2, 1, 3]);
});

it("exposes the directional-light object conversion", () => {
expect(worldDirectionalLightToPolyCss({
direction: [1, 2, 3],
color: "#ffffff",
intensity: 0.5,
})).toEqual({
direction: [2, 1, 3],
color: "#ffffff",
intensity: 0.5,
});
});

it("exposes scalar and inverse position conversions", () => {
expect(worldDistanceToPolyCss(3)).toBe(3 * BASE_TILE);
expect(polyCssDistanceToWorld(3 * BASE_TILE)).toBe(3);
Expand All @@ -44,4 +59,16 @@ describe("public transform helpers", () => {
`translate3d(${2 * BASE_TILE}px, ${1 * BASE_TILE}px, ${3 * BASE_TILE}px) rotateY(-10deg) rotateX(-20deg) rotateZ(-30deg) scale3d(2, 2, 2)`,
);
});

it("exposes the scene-root transform builder", () => {
const input: PolySceneTransformInput = {
rotX: 30,
rotY: 45,
zoom: 1,
target: [3, 5, 7],
};
expect(buildPolySceneTransform(input)).toBe(
`scale(${1 / BASE_TILE}) rotateX(30deg) rotate(45deg) translate3d(${-5 * BASE_TILE}px, ${-3 * BASE_TILE}px, ${-7 * BASE_TILE}px)`,
);
});
});
6 changes: 6 additions & 0 deletions packages/polycss/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,21 @@ export type {
} from "./api/createPolyScene";
export {
buildPolyMeshTransform,
buildPolySceneTransform,
cssDistanceToWorld,
cssDistanceToWorld as polyCssDistanceToWorld,
cssPositionToWorld,
cssPositionToWorld as polyCssPositionToWorld,
worldDistanceToCss,
worldDistanceToCss as worldDistanceToPolyCss,
worldDirectionalLightToCss as worldDirectionalLightToPolyCss,
worldDirectionToPolyCss,
worldPositionToPolyCss,
} from "@layoutit/polycss-core";
export type {
PolyMeshTransformInput,
PolySceneTransformInput,
} from "@layoutit/polycss-core";

// ── Camera factories ──────────────────────────────────────────────
export { createPolyPerspectiveCamera, createPolyOrthographicCamera, createPolyCamera } from "./api/createPolyCamera";
Expand Down
2 changes: 1 addition & 1 deletion packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po
- `autoCenter` shifts the mesh bbox center to local origin.
- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. Lossy also applies bounded seam repair; STL imports use the conservative lossless path in both modes.
- `castShadow` emits CSS-projected shadows in dynamic lighting mode.
- Tooling can reuse `buildPolyMeshTransform`, `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, and `polyCssPositionToWorld` for renderer-compatible transforms and world/CSS conversions.
- Tooling can reuse `buildPolyMeshTransform`, `buildPolySceneTransform`, `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDirectionalLightToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, and `polyCssPositionToWorld` for renderer-compatible transforms and world/CSS conversions.

### Controls

Expand Down
37 changes: 9 additions & 28 deletions packages/react/src/camera/useCamera.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRef, useCallback, useEffect, useMemo } from "react";
import { createIsometricCamera, BASE_TILE } from "@layoutit/polycss-core";
import { buildPolySceneTransform, createIsometricCamera } from "@layoutit/polycss-core";
import type { CameraState, CameraHandle, Vec3 } from "@layoutit/polycss-core";
import { createSceneStore, type SceneStore } from "../store/sceneStore";

Expand Down Expand Up @@ -66,21 +66,10 @@ export function usePolyCamera(options: UseCameraOptions): UseCameraResult {
// camera continues to orbit the bbox center even during prop-driven moves.
const el = sceneElRef.current;
if (el) {
const s = handle.state;
const [ox, oy, oz] = store.getState().autoCenterOffset;
const tileSize = BASE_TILE;
const wx = s.target[0] + ox;
const wy = s.target[1] + oy;
const wz = s.target[2] + oz;
const cssX = wy * tileSize; // world Y → CSS X
const cssY = wx * tileSize; // world X → CSS Y
const cssZ = wz * tileSize; // world Z → CSS Z
// zoom = px-per-world-unit (Three.js parity). Renderer geometry
// already lives at ×BASE_TILE CSS px, so the scene-root CSS scale
// is zoom / BASE_TILE. Mirrors vanilla's buildSceneTransform.
const cssZoom = s.zoom / tileSize;
const distancePart = s.distance !== 0 ? `translateZ(${-s.distance}px) ` : "";
el.style.transform = `${distancePart}scale(${cssZoom}) rotateX(${s.rotX}deg) rotate(${s.rotY}deg) translate3d(${-cssX}px, ${-cssY}px, ${-cssZ}px)`;
el.style.transform = buildPolySceneTransform({
...handle.state,
autoCenterOffset: store.getState().autoCenterOffset,
});
}
store.updateCameraFromRef(handle);
store.notifyAll(); // props changed — always notify
Expand All @@ -95,18 +84,10 @@ export function usePolyCamera(options: UseCameraOptions): UseCameraResult {
const el = sceneElRef.current;
if (!el) return;
const handle = handleRef.current!;
const s = handle.state;
const [ox, oy, oz] = store.getState().autoCenterOffset;
const tileSize = BASE_TILE;
const wx = s.target[0] + ox;
const wy = s.target[1] + oy;
const wz = s.target[2] + oz;
const cssX = wy * tileSize; // world Y → CSS X
const cssY = wx * tileSize; // world X → CSS Y
const cssZ = wz * tileSize; // world Z → CSS Z
const cssZoom = s.zoom / tileSize; // see comment in useCamera above
const distancePart = s.distance !== 0 ? `translateZ(${-s.distance}px) ` : "";
el.style.transform = `${distancePart}scale(${cssZoom}) rotateX(${s.rotX}deg) rotate(${s.rotY}deg) translate3d(${-cssX}px, ${-cssY}px, ${-cssZ}px)`;
el.style.transform = buildPolySceneTransform({
...handle.state,
autoCenterOffset: store.getState().autoCenterOffset,
});
}, [store]);

return {
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export type {
OptimizeAnimatedMeshPolygonsOptions,
SimplifyTriangleMeshPolygonsOptions,
PolyMeshTransformInput,
PolySceneTransformInput,
} from "@layoutit/polycss-core";
export {
CAMERA_BACKFACE_CULL_EPS,
Expand Down Expand Up @@ -246,6 +247,7 @@ export {
planePolygons,
buildSceneContext,
buildPolyMeshTransform,
buildPolySceneTransform,
computeSceneBbox,
BASE_TILE,
DEFAULT_CAMERA_STATE,
Expand All @@ -262,5 +264,6 @@ export {
cssPositionToWorld as polyCssPositionToWorld,
worldDistanceToCss as worldDistanceToPolyCss,
worldDirectionToCss as worldDirectionToPolyCss,
worldDirectionalLightToCss as worldDirectionalLightToPolyCss,
worldPositionToCss as worldPositionToPolyCss,
} from "@layoutit/polycss-core";
2 changes: 1 addition & 1 deletion packages/vue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po
- `autoCenter` shifts the mesh bbox center to local origin.
- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. Lossy also applies bounded seam repair; STL imports use the conservative lossless path in both modes.
- `castShadow` emits CSS-projected shadows in dynamic lighting mode.
- Tooling can reuse `buildPolyMeshTransform`, `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, and `polyCssPositionToWorld` for renderer-compatible transforms and world/CSS conversions.
- Tooling can reuse `buildPolyMeshTransform`, `buildPolySceneTransform`, `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDirectionalLightToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, and `polyCssPositionToWorld` for renderer-compatible transforms and world/CSS conversions.

### Controls

Expand Down
Loading
Loading