diff --git a/README.md b/README.md index ff15dbd0..e312c30d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0ce617d0..e90743a8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/transform/sceneTransform.test.ts b/packages/core/src/transform/sceneTransform.test.ts new file mode 100644 index 00000000..8c1fbaeb --- /dev/null +++ b/packages/core/src/transform/sceneTransform.test.ts @@ -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)", + ); + }); +}); diff --git a/packages/core/src/transform/sceneTransform.ts b/packages/core/src/transform/sceneTransform.ts new file mode 100644 index 00000000..1d0b5ceb --- /dev/null +++ b/packages/core/src/transform/sceneTransform.ts @@ -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)`; +} diff --git a/packages/polycss/src/api/scene/transforms.ts b/packages/polycss/src/api/scene/transforms.ts index 67f842ff..d6186c3d 100644 --- a/packages/polycss/src/api/scene/transforms.ts +++ b/packages/polycss/src/api/scene/transforms.ts @@ -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, @@ -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 { diff --git a/packages/polycss/src/index.test.ts b/packages/polycss/src/index.test.ts index c5e296f6..f8e76e6a 100644 --- a/packages/polycss/src/index.test.ts +++ b/packages/polycss/src/index.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { BASE_TILE } from "@layoutit/polycss-core"; import { buildPolyMeshTransform, + buildPolySceneTransform, polyCssDistanceToWorld, polyCssPositionToWorld, worldDistanceToPolyCss, @@ -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)`, + ); + }); }); diff --git a/packages/polycss/src/index.ts b/packages/polycss/src/index.ts index db20048e..6eb567ff 100644 --- a/packages/polycss/src/index.ts +++ b/packages/polycss/src/index.ts @@ -20,6 +20,7 @@ export type { } from "./api/createPolyScene"; export { buildPolyMeshTransform, + buildPolySceneTransform, cssDistanceToWorld, cssDistanceToWorld as polyCssDistanceToWorld, cssPositionToWorld, diff --git a/packages/react/src/camera/useCamera.ts b/packages/react/src/camera/useCamera.ts index 37af2739..07a36b01 100644 --- a/packages/react/src/camera/useCamera.ts +++ b/packages/react/src/camera/useCamera.ts @@ -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"; @@ -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 @@ -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 { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 9bd6b6ec..335c28d3 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -246,6 +246,7 @@ export { planePolygons, buildSceneContext, buildPolyMeshTransform, + buildPolySceneTransform, computeSceneBbox, BASE_TILE, DEFAULT_CAMERA_STATE, diff --git a/packages/vue/src/camera/useCamera.ts b/packages/vue/src/camera/useCamera.ts index 03067ede..f9d326fb 100644 --- a/packages/vue/src/camera/useCamera.ts +++ b/packages/vue/src/camera/useCamera.ts @@ -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"; @@ -84,22 +84,10 @@ export function usePolyCamera(options: Ref): 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 { diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 877dd9e6..ac6567b4 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -237,6 +237,7 @@ export { planePolygons, buildSceneContext, buildPolyMeshTransform, + buildPolySceneTransform, computeSceneBbox, BASE_TILE, DEFAULT_CAMERA_STATE, diff --git a/website/src/content/docs/api/headless.mdx b/website/src/content/docs/api/headless.mdx index 549e550d..447929ef 100644 --- a/website/src/content/docs/api/headless.mdx +++ b/website/src/content/docs/api/headless.mdx @@ -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`.