Skip to content

Commit 3d286eb

Browse files
authored
feat: expose scene transform builder (#68)
Co-authored-by: agustin-littlehat <minotopo@gmail.com>
1 parent bcd8a96 commit 3d286eb

17 files changed

Lines changed: 179 additions & 77 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export default function App() {
8484
- `autoCenter` shifts the mesh bbox center to local origin.
8585
- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. STL imports use the conservative lossless path in both modes.
8686
- `castShadow` emits CSS-projected shadows in dynamic lighting mode.
87-
- Tooling can reuse `buildPolyMeshTransform`, `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, and `polyCssPositionToWorld` for renderer-compatible transforms and world/CSS conversions.
87+
- Tooling can reuse `buildPolyMeshTransform`, `buildPolySceneTransform`, `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDirectionalLightToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, and `polyCssPositionToWorld` for renderer-compatible transforms and world/CSS conversions.
8888

8989
### Controls
9090

packages/core/README.md

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

106106
### Controls
107107

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,12 +183,15 @@ export {
183183
worldDirectionToCss,
184184
worldDirectionToCss as worldDirectionToPolyCss,
185185
worldDirectionalLightToCss,
186+
worldDirectionalLightToCss as worldDirectionalLightToPolyCss,
186187
worldPositionToCss,
187188
worldPositionToCss as worldPositionToPolyCss,
188189
} from "./shadow/receiverFaceGroups";
189190
export type { ReceiverPlaneGroup } from "./shadow/receiverFaceGroups";
190191
export { buildPolyMeshTransform } from "./transform/meshTransform";
191192
export type { PolyMeshTransformInput } from "./transform/meshTransform";
193+
export { buildPolySceneTransform } from "./transform/sceneTransform";
194+
export type { PolySceneTransformInput } from "./transform/sceneTransform";
192195
export {
193196
buildSharedEdgeMap,
194197
computeReceiverShadowFaces,

packages/core/src/shadow/receiverFaceGroups.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ export function worldDirectionToCss(d: Vec3): Vec3 {
7272
/** Apply {@link worldDirectionToCss} to a directional-light object,
7373
* preserving the other fields. Used by atlas plan + buildBasisHints +
7474
* receiver-shadow callers so the light vector is in the same CSS-axis
75-
* frame as the polygon normals. Mirror of vanilla's
76-
* `worldDirectionalLightToCss` in `packages/polycss/src/api/scene/transforms.ts`. */
75+
* frame as the polygon normals. Public package wrappers delegate here so
76+
* directional-light conversion stays single-source. */
7777
export function worldDirectionalLightToCss<
7878
T extends { direction?: Vec3 } | undefined,
7979
>(light: T): T {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from "vitest";
2+
import { BASE_TILE, DEFAULT_CAMERA_STATE } from "../camera/camera";
3+
import { buildPolySceneTransform } from "./sceneTransform";
4+
5+
describe("buildPolySceneTransform", () => {
6+
it("uses the default camera state when fields are omitted", () => {
7+
expect(buildPolySceneTransform()).toBe(
8+
`scale(${DEFAULT_CAMERA_STATE.zoom / BASE_TILE}) rotateX(${DEFAULT_CAMERA_STATE.rotX}deg) rotate(${DEFAULT_CAMERA_STATE.rotY}deg) translate3d(0px, 0px, 0px)`,
9+
);
10+
});
11+
12+
it("builds the renderer scene-root transform from explicit camera state", () => {
13+
expect(buildPolySceneTransform({ rotX: 30, rotY: 45, zoom: 1 })).toBe(
14+
`scale(${1 / BASE_TILE}) rotateX(30deg) rotate(45deg) translate3d(0px, 0px, 0px)`,
15+
);
16+
});
17+
18+
it("prepends translateZ when distance is non-zero", () => {
19+
expect(buildPolySceneTransform({ rotX: 0, rotY: 0, zoom: BASE_TILE, distance: 100 })).toBe(
20+
"translateZ(-100px) scale(1) rotateX(0deg) rotate(0deg) translate3d(0px, 0px, 0px)",
21+
);
22+
});
23+
24+
it("adds target and autoCenterOffset before world-to-CSS conversion", () => {
25+
expect(buildPolySceneTransform({
26+
rotX: 0,
27+
rotY: 0,
28+
zoom: BASE_TILE,
29+
target: [1, 2, 3],
30+
autoCenterOffset: [10, 20, 30],
31+
})).toBe(
32+
`scale(1) rotateX(0deg) rotate(0deg) translate3d(${-22 * BASE_TILE}px, ${-11 * BASE_TILE}px, ${-33 * BASE_TILE}px)`,
33+
);
34+
});
35+
36+
it("folds layoutScale into scene scale and camera distance", () => {
37+
expect(buildPolySceneTransform({ rotX: 0, rotY: 0, zoom: 1, distance: 10, layoutScale: 2 })).toBe(
38+
`translateZ(-20px) scale(${(1 / BASE_TILE) * 2}) rotateX(0deg) rotate(0deg) translate3d(0px, 0px, 0px)`,
39+
);
40+
});
41+
42+
it("supports a custom world unit size for external adapters", () => {
43+
expect(buildPolySceneTransform({ rotX: 0, rotY: 0, zoom: 1, target: [3, 5, 7], worldUnitPx: 10 })).toBe(
44+
"scale(0.1) rotateX(0deg) rotate(0deg) translate3d(-50px, -30px, -70px)",
45+
);
46+
});
47+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { BASE_TILE, DEFAULT_CAMERA_STATE } from "../camera/camera";
2+
import type { Vec3 } from "../types";
3+
import { worldPositionToCss } from "../shadow/receiverFaceGroups";
4+
5+
export interface PolySceneTransformInput {
6+
/** World point that should appear at the viewport center. */
7+
target?: Vec3;
8+
/** Scene orbit tilt in degrees. */
9+
rotX?: number;
10+
/** Scene orbit rotation around world up in degrees. */
11+
rotY?: number;
12+
/** User-facing zoom in CSS pixels per world unit. */
13+
zoom?: number;
14+
/** Camera pull-back from target in CSS pixels. */
15+
distance?: number;
16+
/** Auto-center offset added to target before world→CSS conversion. */
17+
autoCenterOffset?: Vec3;
18+
/** Extra scale folded into zoom and distance for CSS zoom compensation. */
19+
layoutScale?: number;
20+
/** CSS pixels per PolyCSS world unit. Defaults to the renderer base tile. */
21+
worldUnitPx?: number;
22+
}
23+
24+
/**
25+
* Build the scene-root transform used by PolyCSS renderers:
26+
*
27+
* `translateZ(-distance) scale(zoom / worldUnitPx) rotateX(rotX) rotate(rotY) translate3d(-targetCss)`
28+
*/
29+
export function buildPolySceneTransform(
30+
input: PolySceneTransformInput = {},
31+
): string {
32+
const target = input.target ?? DEFAULT_CAMERA_STATE.target;
33+
const autoCenterOffset = input.autoCenterOffset ?? [0, 0, 0] as Vec3;
34+
const layoutScale = input.layoutScale ?? 1;
35+
const worldUnitPx = input.worldUnitPx ?? BASE_TILE;
36+
const zoom = ((input.zoom ?? DEFAULT_CAMERA_STATE.zoom) / worldUnitPx) * layoutScale;
37+
const distance = (input.distance ?? DEFAULT_CAMERA_STATE.distance) * layoutScale;
38+
const cssTarget = worldPositionToCss([
39+
target[0] + autoCenterOffset[0],
40+
target[1] + autoCenterOffset[1],
41+
target[2] + autoCenterOffset[2],
42+
], worldUnitPx);
43+
const distancePart = distance !== 0 ? `translateZ(${-distance}px) ` : "";
44+
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)`;
45+
}

packages/polycss/README.md

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

106106
### Controls
107107

packages/polycss/src/api/scene/transforms.ts

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@
1111
*
1212
* Constants are exported so call sites can compare against them.
1313
*/
14-
import { BASE_TILE, buildPolyMeshTransform } from "@layoutit/polycss-core";
14+
import {
15+
BASE_TILE,
16+
buildPolyMeshTransform,
17+
buildPolySceneTransform,
18+
worldDirectionToCss as coreWorldDirectionToCss,
19+
worldDirectionalLightToCss as coreWorldDirectionalLightToCss,
20+
worldPositionToCss as coreWorldPositionToCss,
21+
} from "@layoutit/polycss-core";
1522
import type { Polygon, Vec3 } from "@layoutit/polycss-core";
1623
import type {
1724
PolyPerspectiveCameraHandle,
@@ -35,7 +42,7 @@ export const LAMBERT_BUCKET_PRECISION = 0.1;
3542
* (standard XYZ in world units) and absorb the conversion at the boundary.
3643
*/
3744
export function worldPositionToCss(p: Vec3): Vec3 {
38-
return [p[1] * DEFAULT_TILE, p[0] * DEFAULT_TILE, p[2] * DEFAULT_TILE];
45+
return coreWorldPositionToCss(p);
3946
}
4047

4148
/**
@@ -46,14 +53,13 @@ export function worldPositionToCss(p: Vec3): Vec3 {
4653
* are unit vectors.
4754
*/
4855
export function worldDirectionToCss(d: Vec3): Vec3 {
49-
return [d[1], d[0], d[2]];
56+
return coreWorldDirectionToCss(d);
5057
}
5158

5259
export function worldDirectionalLightToCss<
5360
T extends { direction?: Vec3 } | undefined,
5461
>(light: T): T {
55-
if (!light?.direction) return light;
56-
return { ...light, direction: worldDirectionToCss(light.direction) } as T;
62+
return coreWorldDirectionalLightToCss(light);
5763
}
5864

5965
/**
@@ -104,28 +110,19 @@ export function buildSceneTransformFromCamera(
104110
layoutScale = 1,
105111
): string {
106112
const state = camera.state;
107-
const rotX = state.rotX;
108-
const rotY = state.rotY;
109-
// User-facing zoom is "px per world unit" (Three.js OrthographicCamera.zoom
110-
// shape). Renderer geometry already lives at `× DEFAULT_TILE` CSS px, so
111-
// the scene-root CSS scale is `zoom / DEFAULT_TILE`. At `zoom=1` → 1 world
112-
// unit = 1 CSS px on screen.
113-
const userZoom = state.zoom ?? DEFAULT_ZOOM;
114-
const zoom = (userZoom / DEFAULT_TILE) * layoutScale;
115-
const distance = (state.distance ?? 0) * layoutScale;
116-
const target = state.target ?? [0, 0, 0];
117-
const wx = target[0] + autoCenterOffset[0];
118-
const wy = target[1] + autoCenterOffset[1];
119-
const wz = target[2] + autoCenterOffset[2];
120-
const cssX = wy * DEFAULT_TILE;
121-
const cssY = wx * DEFAULT_TILE;
122-
const cssZ = wz * DEFAULT_TILE;
123113
// rotate() (i.e. rotateZ) — NOT rotateY. After the rotateX tilt, the world's
124114
// Z axis is what reads as "spin in place"; rotateY rotates around an oblique
125115
// axis and makes the mesh wobble. translateZ(-distance) is outermost — pulls
126116
// the camera back from the target along the view axis.
127-
const distancePart = distance !== 0 ? `translateZ(${-distance}px) ` : "";
128-
return `${distancePart}scale(${zoom}) rotateX(${rotX}deg) rotate(${rotY}deg) translate3d(${-cssX}px, ${-cssY}px, ${-cssZ}px)`;
117+
return buildPolySceneTransform({
118+
rotX: state.rotX,
119+
rotY: state.rotY,
120+
zoom: state.zoom ?? DEFAULT_ZOOM,
121+
distance: state.distance ?? 0,
122+
target: state.target ?? [0, 0, 0],
123+
autoCenterOffset,
124+
layoutScale,
125+
});
129126
}
130127

131128
export function parseCssZoom(value: string): number {

packages/polycss/src/index.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ import { describe, expect, it } from "vitest";
22
import { BASE_TILE } from "@layoutit/polycss-core";
33
import {
44
buildPolyMeshTransform,
5+
buildPolySceneTransform,
56
polyCssDistanceToWorld,
67
polyCssPositionToWorld,
78
worldDistanceToPolyCss,
9+
worldDirectionalLightToPolyCss,
810
worldDirectionToPolyCss,
911
worldPositionToPolyCss,
1012
} from "./index";
13+
import type { PolySceneTransformInput } from "./index";
1114

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

28+
it("exposes the directional-light object conversion", () => {
29+
expect(worldDirectionalLightToPolyCss({
30+
direction: [1, 2, 3],
31+
color: "#ffffff",
32+
intensity: 0.5,
33+
})).toEqual({
34+
direction: [2, 1, 3],
35+
color: "#ffffff",
36+
intensity: 0.5,
37+
});
38+
});
39+
2540
it("exposes scalar and inverse position conversions", () => {
2641
expect(worldDistanceToPolyCss(3)).toBe(3 * BASE_TILE);
2742
expect(polyCssDistanceToWorld(3 * BASE_TILE)).toBe(3);
@@ -44,4 +59,16 @@ describe("public transform helpers", () => {
4459
`translate3d(${2 * BASE_TILE}px, ${1 * BASE_TILE}px, ${3 * BASE_TILE}px) rotateY(-10deg) rotateX(-20deg) rotateZ(-30deg) scale3d(2, 2, 2)`,
4560
);
4661
});
62+
63+
it("exposes the scene-root transform builder", () => {
64+
const input: PolySceneTransformInput = {
65+
rotX: 30,
66+
rotY: 45,
67+
zoom: 1,
68+
target: [3, 5, 7],
69+
};
70+
expect(buildPolySceneTransform(input)).toBe(
71+
`scale(${1 / BASE_TILE}) rotateX(30deg) rotate(45deg) translate3d(${-5 * BASE_TILE}px, ${-3 * BASE_TILE}px, ${-7 * BASE_TILE}px)`,
72+
);
73+
});
4774
});

packages/polycss/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,21 @@ export type {
2020
} from "./api/createPolyScene";
2121
export {
2222
buildPolyMeshTransform,
23+
buildPolySceneTransform,
2324
cssDistanceToWorld,
2425
cssDistanceToWorld as polyCssDistanceToWorld,
2526
cssPositionToWorld,
2627
cssPositionToWorld as polyCssPositionToWorld,
2728
worldDistanceToCss,
2829
worldDistanceToCss as worldDistanceToPolyCss,
30+
worldDirectionalLightToCss as worldDirectionalLightToPolyCss,
2931
worldDirectionToPolyCss,
3032
worldPositionToPolyCss,
3133
} from "@layoutit/polycss-core";
34+
export type {
35+
PolyMeshTransformInput,
36+
PolySceneTransformInput,
37+
} from "@layoutit/polycss-core";
3238

3339
// ── Camera factories ──────────────────────────────────────────────
3440
export { createPolyPerspectiveCamera, createPolyOrthographicCamera, createPolyCamera } from "./api/createPolyCamera";

0 commit comments

Comments
 (0)