Skip to content
Closed
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`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, and `polyCssPositionToWorld` for renderer-compatible transforms and world/CSS conversions.

### Controls

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ export {
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
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)`;
}
29 changes: 10 additions & 19 deletions packages/polycss/src/api/scene/transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*
* Constants are exported so call sites can compare against them.
*/
import { BASE_TILE, buildPolyMeshTransform } from "@layoutit/polycss-core";
import { BASE_TILE, buildPolyMeshTransform, buildPolySceneTransform } from "@layoutit/polycss-core";
import type { Polygon, Vec3 } from "@layoutit/polycss-core";
import type {
PolyPerspectiveCameraHandle,
Expand Down Expand Up @@ -104,28 +104,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
12 changes: 12 additions & 0 deletions packages/polycss/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { BASE_TILE } from "@layoutit/polycss-core";
import {
buildPolyMeshTransform,
buildPolySceneTransform,
polyCssDistanceToWorld,
polyCssPositionToWorld,
worldDistanceToPolyCss,
Expand Down Expand Up @@ -44,4 +45,15 @@ 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", () => {
expect(buildPolySceneTransform({
rotX: 30,
rotY: 45,
zoom: 1,
target: [3, 5, 7],
})).toBe(
`scale(${1 / BASE_TILE}) rotateX(30deg) rotate(45deg) translate3d(${-5 * BASE_TILE}px, ${-3 * BASE_TILE}px, ${-7 * BASE_TILE}px)`,
);
});
});
1 change: 1 addition & 0 deletions packages/polycss/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type {
} from "./api/createPolyScene";
export {
buildPolyMeshTransform,
buildPolySceneTransform,
cssDistanceToWorld,
cssDistanceToWorld as polyCssDistanceToWorld,
cssPositionToWorld,
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
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ export {
planePolygons,
buildSceneContext,
buildPolyMeshTransform,
buildPolySceneTransform,
computeSceneBbox,
BASE_TILE,
DEFAULT_CAMERA_STATE,
Expand Down
22 changes: 5 additions & 17 deletions packages/vue/src/camera/useCamera.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ref, shallowRef, watch } from "vue";
import type { Ref } from "vue";
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";

Expand Down Expand Up @@ -84,22 +84,10 @@ export function usePolyCamera(options: Ref<UseCameraOptions>): UseCameraResult {
function applyTransformDirect(): void {
const el = sceneElRef.value;
if (!el) return;
const s = handle.state;
const tileSize = BASE_TILE;
const offset = autoCenterOffset.value;
// world→CSS axis swap: world[0]→CSS Y, world[1]→CSS X, world[2]→CSS Z
const wx = s.target[0] + offset[0];
const wy = s.target[1] + offset[1];
const wz = s.target[2] + offset[2];
const cssX = wy * tileSize;
const cssY = wx * tileSize;
const cssZ = wz * tileSize;
// 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: autoCenterOffset.value,
});
}

return {
Expand Down
1 change: 1 addition & 0 deletions packages/vue/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export {
planePolygons,
buildSceneContext,
buildPolyMeshTransform,
buildPolySceneTransform,
computeSceneBbox,
BASE_TILE,
DEFAULT_CAMERA_STATE,
Expand Down
2 changes: 1 addition & 1 deletion website/src/content/docs/api/headless.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ The vanilla package also exposes renderer and atlas building blocks for diagnost

- Atlas planning/rendering: `computeTextureAtlasPlanPublic`, `buildAtlasPages`, `renderPolygonsWithTextureAtlas`, `renderPolygonsWithTextureAtlasAsync`, `filterAtlasPlans`, `packTextureAtlasPlansWithScale`, `buildTextureEdgeRepairSets`.
- Stable DOM animation: `renderPolygonsWithStableTriangles`, `updatePolygonsWithStableTopology`, `updateStableTriangleFrame`.
- Scene transform helpers: `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, `polyCssPositionToWorld`, `buildPolyMeshTransform`.
- Scene transform helpers: `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, `polyCssPositionToWorld`, `buildPolyMeshTransform`, `buildPolySceneTransform`.
- Strategy and CSS helpers: `getSolidPaintDefaults`, `getSolidPaintDefaultsFromPlans`, `isBorderShapeSupported`, `isSolidTriangleSupported`, `isFullRectSolid`, `isSolidTrianglePlan`, `isProjectiveQuadPlan`, `cssBorderShapeForPlan`, `formatMatrix3d`, `formatCssLengthPx`, `formatSolidQuadEntryMatrix`, `formatBorderShapeEntryMatrix`.
- Related types: `TextureAtlasPlan`, `PackedTextureAtlasEntry`, `PackedAtlas`, `PackedPage`, `TextureAtlasPage`, `SolidPaintDefaults`, `SolidTriangleFrame`, `PolygonBasisInfo`.

Expand Down
Loading