@framefields/node-compositor 0.0.0-stage → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +22 -0
- package/.turbo/turbo-test.log +21 -0
- package/CHANGELOG.md +32 -0
- package/LICENCE +202 -0
- package/SKILL.md +370 -0
- package/dist/index-B0ir9Z7o.d.mts +139 -0
- package/dist/index-B0ir9Z7o.d.mts.map +1 -0
- package/dist/index.d.mts +230 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +674 -0
- package/dist/index.mjs.map +1 -0
- package/dist/renderer.d.mts +2 -0
- package/dist/renderer.mjs +3 -0
- package/dist/renderers-DoYYCzgA.mjs +3326 -0
- package/dist/renderers-DoYYCzgA.mjs.map +1 -0
- package/package.json +51 -3
- package/src/index.ts +2 -0
- package/src/renderers/audio-processor.ts +123 -0
- package/src/renderers/container-bg.test.ts +22 -0
- package/src/renderers/container-bg.ts +15 -0
- package/src/renderers/index.ts +9 -0
- package/src/renderers/transform.test.ts +120 -0
- package/src/renderers/transform.ts +101 -0
- package/src/renderers/transform3d.test.ts +290 -0
- package/src/renderers/transform3d.ts +428 -0
- package/src/renderers/webgpu-renderer.ts +4038 -0
- package/src/shared/audio-reactive.test.ts +224 -0
- package/src/shared/bezier-solver.test.ts +92 -0
- package/src/shared/bezier-solver.ts +257 -0
- package/src/shared/compiler.test.ts +1309 -0
- package/src/shared/compiler.ts +996 -0
- package/src/shared/config.ts +44 -0
- package/src/shared/index.ts +3 -0
- package/src/shared/kinetic-typography.test.ts +138 -0
- package/src/shared/presets.test.ts +217 -0
- package/src/shared/presets.ts +644 -0
- package/src/shared/processor.ts +105 -0
- package/tsconfig.json +13 -0
- package/tsdown.config.ts +13 -0
- package/README.md +0 -3
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
compileTimeline,
|
|
4
|
+
type CompilerVirtualMedia,
|
|
5
|
+
} from "../shared/compiler.js";
|
|
6
|
+
import {
|
|
7
|
+
has3DTransform,
|
|
8
|
+
type Point2D,
|
|
9
|
+
solveHomography,
|
|
10
|
+
Transform3DMath,
|
|
11
|
+
} from "./transform3d.js";
|
|
12
|
+
|
|
13
|
+
const closeTo = (actual: number, expected: number, eps = 1e-5) =>
|
|
14
|
+
expect(Math.abs(actual - expected)).toBeLessThan(eps);
|
|
15
|
+
|
|
16
|
+
const expectPoint = (a: Point2D, e: Point2D, eps = 1e-5) => {
|
|
17
|
+
closeTo(a.x, e.x, eps);
|
|
18
|
+
closeTo(a.y, e.y, eps);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("Transform3DMath (Pure 4x4 Projections)", () => {
|
|
22
|
+
const w = 400;
|
|
23
|
+
const h = 300;
|
|
24
|
+
const x = 50;
|
|
25
|
+
const y = 80;
|
|
26
|
+
|
|
27
|
+
it("Invariant 1: Zero Rotation Invariant — projected quad corners match 2D bounding rect exactly", () => {
|
|
28
|
+
const m = Transform3DMath.buildLayer3DMatrix({
|
|
29
|
+
x,
|
|
30
|
+
y,
|
|
31
|
+
width: w,
|
|
32
|
+
height: h,
|
|
33
|
+
rotateXDeg: 0,
|
|
34
|
+
rotateYDeg: 0,
|
|
35
|
+
rotateZDeg: 0,
|
|
36
|
+
translateZPx: 0,
|
|
37
|
+
perspectivePx: 0,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const quad = Transform3DMath.projectRectangleCorners(m, w, h);
|
|
41
|
+
expectPoint(quad.topLeft, { x: 50, y: 80 });
|
|
42
|
+
expectPoint(quad.topRight, { x: 450, y: 80 });
|
|
43
|
+
expectPoint(quad.bottomLeft, { x: 50, y: 380 });
|
|
44
|
+
expectPoint(quad.bottomRight, { x: 450, y: 380 });
|
|
45
|
+
|
|
46
|
+
const signedArea = Transform3DMath.computeSignedArea(quad);
|
|
47
|
+
expect(signedArea).toBeCloseTo(w * h, 3);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("Invariant 2: Perspective Foreshortening Invariant — rotateY > 0 foreshortens right edge", () => {
|
|
51
|
+
const m = Transform3DMath.buildLayer3DMatrix({
|
|
52
|
+
x: 0,
|
|
53
|
+
y: 0,
|
|
54
|
+
width: w,
|
|
55
|
+
height: h,
|
|
56
|
+
rotateYDeg: 30,
|
|
57
|
+
perspectivePx: 1000,
|
|
58
|
+
originXRatio: 0.5,
|
|
59
|
+
originYRatio: 0.5,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const quad = Transform3DMath.projectRectangleCorners(m, w, h);
|
|
63
|
+
|
|
64
|
+
const leftHeight = quad.bottomLeft.y - quad.topLeft.y;
|
|
65
|
+
const rightHeight = quad.bottomRight.y - quad.topRight.y;
|
|
66
|
+
|
|
67
|
+
// Right edge turned away into depth: height must be strictly smaller than left edge
|
|
68
|
+
expect(rightHeight).toBeLessThan(leftHeight);
|
|
69
|
+
expect(rightHeight).toBeGreaterThan(0);
|
|
70
|
+
expect(leftHeight).toBeGreaterThan(h);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("Invariant 3: Depth Scaling Invariant — translateZ > 0 with perspective > 0 magnifies dimensions", () => {
|
|
74
|
+
const unprojectedM = Transform3DMath.buildLayer3DMatrix({
|
|
75
|
+
x: 0,
|
|
76
|
+
y: 0,
|
|
77
|
+
width: w,
|
|
78
|
+
height: h,
|
|
79
|
+
perspectivePx: 1000,
|
|
80
|
+
translateZPx: 0,
|
|
81
|
+
});
|
|
82
|
+
const unprojectedQuad = Transform3DMath.projectRectangleCorners(
|
|
83
|
+
unprojectedM,
|
|
84
|
+
w,
|
|
85
|
+
h,
|
|
86
|
+
);
|
|
87
|
+
const unprojectedW = unprojectedQuad.topRight.x - unprojectedQuad.topLeft.x;
|
|
88
|
+
const unprojectedH =
|
|
89
|
+
unprojectedQuad.bottomLeft.y - unprojectedQuad.topLeft.y;
|
|
90
|
+
|
|
91
|
+
const nearM = Transform3DMath.buildLayer3DMatrix({
|
|
92
|
+
x: 0,
|
|
93
|
+
y: 0,
|
|
94
|
+
width: w,
|
|
95
|
+
height: h,
|
|
96
|
+
perspectivePx: 1000,
|
|
97
|
+
translateZPx: 200,
|
|
98
|
+
});
|
|
99
|
+
const nearQuad = Transform3DMath.projectRectangleCorners(nearM, w, h);
|
|
100
|
+
const nearW = nearQuad.topRight.x - nearQuad.topLeft.x;
|
|
101
|
+
const nearH = nearQuad.bottomLeft.y - nearQuad.topLeft.y;
|
|
102
|
+
|
|
103
|
+
expect(nearW).toBeGreaterThan(unprojectedW);
|
|
104
|
+
expect(nearH).toBeGreaterThan(unprojectedH);
|
|
105
|
+
// With d = 1000 and tz = 200: W = 1 - 200/1000 = 0.8 -> scale = 1/0.8 = 1.25
|
|
106
|
+
closeTo(nearW, w * 1.25, 0.01);
|
|
107
|
+
closeTo(nearH, h * 1.25, 0.01);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("Invariant 4: Backface Culling Invariant — 180° Y rotation flips signed area to negative", () => {
|
|
111
|
+
const frontM = Transform3DMath.buildLayer3DMatrix({
|
|
112
|
+
x: 0,
|
|
113
|
+
y: 0,
|
|
114
|
+
width: w,
|
|
115
|
+
height: h,
|
|
116
|
+
rotateYDeg: 0,
|
|
117
|
+
});
|
|
118
|
+
const frontQuad = Transform3DMath.projectRectangleCorners(frontM, w, h);
|
|
119
|
+
expect(Transform3DMath.computeSignedArea(frontQuad)).toBeGreaterThan(0);
|
|
120
|
+
|
|
121
|
+
const backM = Transform3DMath.buildLayer3DMatrix({
|
|
122
|
+
x: 0,
|
|
123
|
+
y: 0,
|
|
124
|
+
width: w,
|
|
125
|
+
height: h,
|
|
126
|
+
rotateYDeg: 180,
|
|
127
|
+
});
|
|
128
|
+
const backQuad = Transform3DMath.projectRectangleCorners(backM, w, h);
|
|
129
|
+
expect(Transform3DMath.computeSignedArea(backQuad)).toBeLessThan(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("preserves pivot anchor invariance under 3D rotation", () => {
|
|
133
|
+
for (const [ox, oy] of [
|
|
134
|
+
[0.5, 0.5],
|
|
135
|
+
[0, 0],
|
|
136
|
+
[1, 1],
|
|
137
|
+
[0.2, 0.8],
|
|
138
|
+
]) {
|
|
139
|
+
const m = Transform3DMath.buildLayer3DMatrix({
|
|
140
|
+
x: 100,
|
|
141
|
+
y: 150,
|
|
142
|
+
width: 200,
|
|
143
|
+
height: 100,
|
|
144
|
+
rotateXDeg: 25,
|
|
145
|
+
rotateYDeg: -35,
|
|
146
|
+
rotateZDeg: 15,
|
|
147
|
+
perspectivePx: 1200,
|
|
148
|
+
originXRatio: ox,
|
|
149
|
+
originYRatio: oy,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const pivotLocal = { x: 200 * ox, y: 100 * oy, z: 0 };
|
|
153
|
+
const projectedPivot = Transform3DMath.projectPoint(m, pivotLocal);
|
|
154
|
+
|
|
155
|
+
expectPoint(projectedPivot, {
|
|
156
|
+
x: 100 + 200 * ox,
|
|
157
|
+
y: 150 + 100 * oy,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("Invariant 5: solveHomography Invariant — reconstructs source unit square accurately", () => {
|
|
163
|
+
const m = Transform3DMath.buildLayer3DMatrix({
|
|
164
|
+
x: 100,
|
|
165
|
+
y: 100,
|
|
166
|
+
width: 400,
|
|
167
|
+
height: 300,
|
|
168
|
+
rotateXDeg: 20,
|
|
169
|
+
rotateYDeg: -25,
|
|
170
|
+
perspectivePx: 1000,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const quad = Transform3DMath.projectRectangleCorners(m, 400, 300);
|
|
174
|
+
|
|
175
|
+
// Normalized destination coordinates across a 1920x1080 canvas
|
|
176
|
+
const destW = 1920;
|
|
177
|
+
const destH = 1080;
|
|
178
|
+
const dstPoints: Point2D[] = [
|
|
179
|
+
{ x: quad.topLeft.x / destW, y: quad.topLeft.y / destH },
|
|
180
|
+
{ x: quad.topRight.x / destW, y: quad.topRight.y / destH },
|
|
181
|
+
{ x: quad.bottomLeft.x / destW, y: quad.bottomLeft.y / destH },
|
|
182
|
+
{ x: quad.bottomRight.x / destW, y: quad.bottomRight.y / destH },
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const h = solveHomography(dstPoints);
|
|
186
|
+
|
|
187
|
+
// Check that mapping dstPoints through H yields (0,0), (1,0), (0,1), (1,1)
|
|
188
|
+
const expectedSrc = [
|
|
189
|
+
{ x: 0, y: 0 },
|
|
190
|
+
{ x: 1, y: 0 },
|
|
191
|
+
{ x: 0, y: 1 },
|
|
192
|
+
{ x: 1, y: 1 },
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
for (let i = 0; i < 4; i++) {
|
|
196
|
+
const pt = dstPoints[i];
|
|
197
|
+
const d = h[6] * pt.x + h[7] * pt.y + h[8];
|
|
198
|
+
const u = (h[0] * pt.x + h[1] * pt.y + h[2]) / d;
|
|
199
|
+
const v = (h[3] * pt.x + h[4] * pt.y + h[5]) / d;
|
|
200
|
+
|
|
201
|
+
closeTo(u, expectedSrc[i].x, 1e-4);
|
|
202
|
+
closeTo(v, expectedSrc[i].y, 1e-4);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("has3DTransform predicate identifies active 3D properties and tracks", () => {
|
|
207
|
+
expect(has3DTransform({}, {})).toBe(false);
|
|
208
|
+
expect(has3DTransform({ rotateX: 0, rotateY: 0 }, {})).toBe(false);
|
|
209
|
+
expect(has3DTransform({ rotateX: 10 }, {})).toBe(true);
|
|
210
|
+
expect(has3DTransform({ rotateY: -5 }, {})).toBe(true);
|
|
211
|
+
expect(has3DTransform({ translateZ: 50 }, {})).toBe(true);
|
|
212
|
+
expect(has3DTransform({ rotateZ: 45 }, {})).toBe(true);
|
|
213
|
+
expect(has3DTransform({ perspective: 1000, rotateX: 5 }, {})).toBe(true);
|
|
214
|
+
|
|
215
|
+
// Animation track triggers 3D path even at frame 0 when value is 0
|
|
216
|
+
expect(
|
|
217
|
+
has3DTransform(
|
|
218
|
+
{ rotateX: 0 },
|
|
219
|
+
{
|
|
220
|
+
animation: {
|
|
221
|
+
tracks: [{ prop: "rotateX" }],
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
),
|
|
225
|
+
).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("GSAP Timeline 3D Compilation Continuity", () => {
|
|
230
|
+
it("Invariant 6: Animation Continuity Invariant — animates rotateX without frame 0 jumping", () => {
|
|
231
|
+
const virtualMedia = {
|
|
232
|
+
metadata: { width: 1920, height: 1080, durationMs: 2000 },
|
|
233
|
+
operation: { op: "Compositor" },
|
|
234
|
+
children: [
|
|
235
|
+
{
|
|
236
|
+
operation: {
|
|
237
|
+
op: "CompositorLayer",
|
|
238
|
+
id: "card",
|
|
239
|
+
kind: "box",
|
|
240
|
+
x: 100,
|
|
241
|
+
y: 100,
|
|
242
|
+
width: 400,
|
|
243
|
+
height: 300,
|
|
244
|
+
rotateX: 0,
|
|
245
|
+
rotateY: 0,
|
|
246
|
+
perspective: 1000,
|
|
247
|
+
animation: {
|
|
248
|
+
tracks: [
|
|
249
|
+
{
|
|
250
|
+
id: "tilt-track",
|
|
251
|
+
prop: "rotateX",
|
|
252
|
+
keyframes: [
|
|
253
|
+
{ id: "k0", frame: 0, value: 0 },
|
|
254
|
+
{ id: "k1", frame: 24, value: 25 },
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
children: [],
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const { tl, targetsById } = compileTimeline(
|
|
266
|
+
"test-3d-render",
|
|
267
|
+
virtualMedia as CompilerVirtualMedia,
|
|
268
|
+
{
|
|
269
|
+
fps: 24,
|
|
270
|
+
durationSec: 2,
|
|
271
|
+
},
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const cardStub = targetsById.card;
|
|
275
|
+
expect(cardStub).toBeDefined();
|
|
276
|
+
|
|
277
|
+
// Frame 0: exactly 0, no jumping
|
|
278
|
+
tl.seek(0);
|
|
279
|
+
expect(cardStub.rotateX).toBe(0);
|
|
280
|
+
|
|
281
|
+
// Halfway (frame 12): smoothly interpolated
|
|
282
|
+
tl.seek(12 / 24);
|
|
283
|
+
expect(cardStub.rotateX).toBeGreaterThan(0);
|
|
284
|
+
expect(cardStub.rotateX).toBeLessThan(25);
|
|
285
|
+
|
|
286
|
+
// Frame 24: reaches target 25
|
|
287
|
+
tl.seek(24 / 24);
|
|
288
|
+
expect(cardStub.rotateX).toBe(25);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure 4x4 matrix and perspective projection mathematics for 3D layout containers.
|
|
3
|
+
* Column-major 4x4 representation matching WebGPU standard conventions.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type Matrix4x4 = [
|
|
7
|
+
number,
|
|
8
|
+
number,
|
|
9
|
+
number,
|
|
10
|
+
number, // col 0
|
|
11
|
+
number,
|
|
12
|
+
number,
|
|
13
|
+
number,
|
|
14
|
+
number, // col 1
|
|
15
|
+
number,
|
|
16
|
+
number,
|
|
17
|
+
number,
|
|
18
|
+
number, // col 2
|
|
19
|
+
number,
|
|
20
|
+
number,
|
|
21
|
+
number,
|
|
22
|
+
number, // col 3
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export interface Point2D {
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Point3D {
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
z: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Quad2D {
|
|
37
|
+
topLeft: Point2D;
|
|
38
|
+
topRight: Point2D;
|
|
39
|
+
bottomLeft: Point2D;
|
|
40
|
+
bottomRight: Point2D;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface Layer3DMatrixConfig {
|
|
44
|
+
x: number;
|
|
45
|
+
y: number;
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
rotateXDeg?: number;
|
|
49
|
+
rotateYDeg?: number;
|
|
50
|
+
rotateZDeg?: number;
|
|
51
|
+
scaleX?: number;
|
|
52
|
+
scaleY?: number;
|
|
53
|
+
perspectivePx?: number;
|
|
54
|
+
originXRatio?: number;
|
|
55
|
+
originYRatio?: number;
|
|
56
|
+
translateZPx?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const Transform3DMath = {
|
|
60
|
+
/**
|
|
61
|
+
* Creates a 4x4 identity matrix.
|
|
62
|
+
*/
|
|
63
|
+
identity(): Matrix4x4 {
|
|
64
|
+
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Multiplies two 4x4 matrices: out = A x B.
|
|
69
|
+
*/
|
|
70
|
+
multiply(a: Matrix4x4, b: Matrix4x4): Matrix4x4 {
|
|
71
|
+
const out = new Array(16) as Matrix4x4;
|
|
72
|
+
for (let row = 0; row < 4; row++) {
|
|
73
|
+
for (let col = 0; col < 4; col++) {
|
|
74
|
+
out[col * 4 + row] =
|
|
75
|
+
a[0 * 4 + row] * b[col * 4 + 0] +
|
|
76
|
+
a[1 * 4 + row] * b[col * 4 + 1] +
|
|
77
|
+
a[2 * 4 + row] * b[col * 4 + 2] +
|
|
78
|
+
a[3 * 4 + row] * b[col * 4 + 3];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Translation matrix in 3D.
|
|
86
|
+
*/
|
|
87
|
+
translate(tx: number, ty: number, tz = 0): Matrix4x4 {
|
|
88
|
+
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, tx, ty, tz, 1];
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Scale matrix in 3D.
|
|
93
|
+
*/
|
|
94
|
+
scale(sx: number, sy: number, sz = 1): Matrix4x4 {
|
|
95
|
+
return [sx, 0, 0, 0, 0, sy, 0, 0, 0, 0, sz, 0, 0, 0, 0, 1];
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Rotation around X axis (pitch) in radians.
|
|
100
|
+
*/
|
|
101
|
+
rotateX(rad: number): Matrix4x4 {
|
|
102
|
+
const c = Math.cos(rad);
|
|
103
|
+
const s = Math.sin(rad);
|
|
104
|
+
return [1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1];
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Rotation around Y axis (yaw) in radians.
|
|
109
|
+
*/
|
|
110
|
+
rotateY(rad: number): Matrix4x4 {
|
|
111
|
+
const c = Math.cos(rad);
|
|
112
|
+
const s = Math.sin(rad);
|
|
113
|
+
return [c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1];
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Rotation around Z axis (roll) in radians.
|
|
118
|
+
*/
|
|
119
|
+
rotateZ(rad: number): Matrix4x4 {
|
|
120
|
+
const c = Math.cos(rad);
|
|
121
|
+
const s = Math.sin(rad);
|
|
122
|
+
return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Perspective projection matrix.
|
|
127
|
+
* If `d <= 0`, returns identity (orthographic projection).
|
|
128
|
+
* In standard CSS/computer graphics coordinate system:
|
|
129
|
+
* M[11] = -1 / d (in column-major index 11 is row 3, col 2).
|
|
130
|
+
*/
|
|
131
|
+
perspective(d: number): Matrix4x4 {
|
|
132
|
+
if (d <= 0) return Transform3DMath.identity();
|
|
133
|
+
const m = Transform3DMath.identity();
|
|
134
|
+
m[11] = -1 / d;
|
|
135
|
+
return m;
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Computes the complete 3D composite transform for a layer.
|
|
140
|
+
*
|
|
141
|
+
* Order of operations:
|
|
142
|
+
* 1. Move origin to perspective origin / pivot anchor.
|
|
143
|
+
* 2. Apply perspective projection matrix P(d).
|
|
144
|
+
* 3. Apply translation (translateZ).
|
|
145
|
+
* 4. Apply 3D rotations: RotateZ * RotateY * RotateX (Euler angle order).
|
|
146
|
+
* 5. Move origin back from pivot anchor.
|
|
147
|
+
* 6. Apply scale (scaleX, scaleY).
|
|
148
|
+
*/
|
|
149
|
+
buildLayer3DMatrix(config: Layer3DMatrixConfig): Matrix4x4 {
|
|
150
|
+
const {
|
|
151
|
+
x,
|
|
152
|
+
y,
|
|
153
|
+
width,
|
|
154
|
+
height,
|
|
155
|
+
rotateXDeg = 0,
|
|
156
|
+
rotateYDeg = 0,
|
|
157
|
+
rotateZDeg = 0,
|
|
158
|
+
scaleX = 1,
|
|
159
|
+
scaleY = 1,
|
|
160
|
+
perspectivePx = 0,
|
|
161
|
+
originXRatio = 0.5,
|
|
162
|
+
originYRatio = 0.5,
|
|
163
|
+
translateZPx = 0,
|
|
164
|
+
} = config;
|
|
165
|
+
|
|
166
|
+
const pivotX = x + width * originXRatio;
|
|
167
|
+
const pivotY = y + height * originYRatio;
|
|
168
|
+
|
|
169
|
+
const rxRad = (rotateXDeg * Math.PI) / 180;
|
|
170
|
+
const ryRad = (rotateYDeg * Math.PI) / 180;
|
|
171
|
+
const rzRad = (rotateZDeg * Math.PI) / 180;
|
|
172
|
+
|
|
173
|
+
let m = Transform3DMath.identity();
|
|
174
|
+
|
|
175
|
+
// 1. Move to pivot center
|
|
176
|
+
m = Transform3DMath.multiply(
|
|
177
|
+
m,
|
|
178
|
+
Transform3DMath.translate(pivotX, pivotY, 0),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// 2. Perspective
|
|
182
|
+
if (perspectivePx > 0) {
|
|
183
|
+
m = Transform3DMath.multiply(
|
|
184
|
+
m,
|
|
185
|
+
Transform3DMath.perspective(perspectivePx),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 3. Depth translation
|
|
190
|
+
if (translateZPx !== 0) {
|
|
191
|
+
m = Transform3DMath.multiply(
|
|
192
|
+
m,
|
|
193
|
+
Transform3DMath.translate(0, 0, translateZPx),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 4. Rotations (Z, Y, X)
|
|
198
|
+
if (rzRad !== 0) {
|
|
199
|
+
m = Transform3DMath.multiply(m, Transform3DMath.rotateZ(rzRad));
|
|
200
|
+
}
|
|
201
|
+
if (ryRad !== 0) {
|
|
202
|
+
m = Transform3DMath.multiply(m, Transform3DMath.rotateY(ryRad));
|
|
203
|
+
}
|
|
204
|
+
if (rxRad !== 0) {
|
|
205
|
+
m = Transform3DMath.multiply(m, Transform3DMath.rotateX(rxRad));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 5. Un-pivot relative to layer top-left
|
|
209
|
+
m = Transform3DMath.multiply(
|
|
210
|
+
m,
|
|
211
|
+
Transform3DMath.translate(
|
|
212
|
+
-width * originXRatio,
|
|
213
|
+
-height * originYRatio,
|
|
214
|
+
0,
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// 6. Scale
|
|
219
|
+
if (scaleX !== 1 || scaleY !== 1) {
|
|
220
|
+
m = Transform3DMath.multiply(m, Transform3DMath.scale(scaleX, scaleY, 1));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return m;
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Projects a 3D point (x, y, z, 1) through a 4x4 matrix, including perspective division by W.
|
|
228
|
+
*/
|
|
229
|
+
projectPoint(m: Matrix4x4, p: Point3D): Point2D {
|
|
230
|
+
const x = p.x;
|
|
231
|
+
const y = p.y;
|
|
232
|
+
const z = p.z;
|
|
233
|
+
|
|
234
|
+
const X = m[0] * x + m[4] * y + m[8] * z + m[12];
|
|
235
|
+
const Y = m[1] * x + m[5] * y + m[9] * z + m[13];
|
|
236
|
+
const W = m[3] * x + m[7] * y + m[11] * z + m[15];
|
|
237
|
+
|
|
238
|
+
const wInv = Math.abs(W) > 1e-6 ? 1 / W : 1.0;
|
|
239
|
+
return {
|
|
240
|
+
x: X * wInv,
|
|
241
|
+
y: Y * wInv,
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Projects the 4 corner vertices of a flat 2D rectangle (0,0, w, h) through the 3D matrix.
|
|
247
|
+
* Produces the target Quad2D for the WebGPU homography/corner-pin renderer.
|
|
248
|
+
*/
|
|
249
|
+
projectRectangleCorners(
|
|
250
|
+
matrix: Matrix4x4,
|
|
251
|
+
width: number,
|
|
252
|
+
height: number,
|
|
253
|
+
): Quad2D {
|
|
254
|
+
return {
|
|
255
|
+
topLeft: Transform3DMath.projectPoint(matrix, { x: 0, y: 0, z: 0 }),
|
|
256
|
+
topRight: Transform3DMath.projectPoint(matrix, { x: width, y: 0, z: 0 }),
|
|
257
|
+
bottomLeft: Transform3DMath.projectPoint(matrix, {
|
|
258
|
+
x: 0,
|
|
259
|
+
y: height,
|
|
260
|
+
z: 0,
|
|
261
|
+
}),
|
|
262
|
+
bottomRight: Transform3DMath.projectPoint(matrix, {
|
|
263
|
+
x: width,
|
|
264
|
+
y: height,
|
|
265
|
+
z: 0,
|
|
266
|
+
}),
|
|
267
|
+
};
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Computes the signed area of the projected quad to check whether the layer is facing away
|
|
272
|
+
* (backface culling).
|
|
273
|
+
* If signedArea < 0 in screen space (y-down), the surface normal points away from the camera.
|
|
274
|
+
*/
|
|
275
|
+
computeSignedArea(quad: Quad2D): number {
|
|
276
|
+
const { topLeft: p0, topRight: p1, bottomRight: p2, bottomLeft: p3 } = quad;
|
|
277
|
+
return (
|
|
278
|
+
0.5 *
|
|
279
|
+
(p0.x * p1.y -
|
|
280
|
+
p1.x * p0.y +
|
|
281
|
+
(p1.x * p2.y - p2.x * p1.y) +
|
|
282
|
+
(p2.x * p3.y - p3.x * p2.y) +
|
|
283
|
+
(p3.x * p0.y - p0.x * p3.y))
|
|
284
|
+
);
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Computes an 8-parameter 3x3 homography matrix mapping destination coordinates (quad)
|
|
290
|
+
* to normalized source coordinates (unit square [0, 1]^2).
|
|
291
|
+
*
|
|
292
|
+
* The homography H maps destination (x, y) to source (u, v):
|
|
293
|
+
* u = (h00*x + h01*y + h02) / (h20*x + h21*y + h22)
|
|
294
|
+
* v = (h10*x + h11*y + h12) / (h20*x + h21*y + h22)
|
|
295
|
+
*
|
|
296
|
+
* Resulting array: [h00, h01, h02, h10, h11, h12, h20, h21, 1.0]
|
|
297
|
+
*/
|
|
298
|
+
export function solveHomography(
|
|
299
|
+
dstPoints: Point2D[],
|
|
300
|
+
srcPoints: Point2D[] = [
|
|
301
|
+
{ x: 0.0, y: 0.0 }, // TL
|
|
302
|
+
{ x: 1.0, y: 0.0 }, // TR
|
|
303
|
+
{ x: 0.0, y: 1.0 }, // BL
|
|
304
|
+
{ x: 1.0, y: 1.0 }, // BR
|
|
305
|
+
],
|
|
306
|
+
): number[] {
|
|
307
|
+
const A: number[][] = [];
|
|
308
|
+
const B: number[] = [];
|
|
309
|
+
|
|
310
|
+
for (let i = 0; i < 4; i++) {
|
|
311
|
+
const xi = dstPoints[i]?.x ?? 0;
|
|
312
|
+
const yi = dstPoints[i]?.y ?? 0;
|
|
313
|
+
const ui = srcPoints[i]?.x ?? 0;
|
|
314
|
+
const vi = srcPoints[i]?.y ?? 0;
|
|
315
|
+
|
|
316
|
+
A.push([xi, yi, 1.0, 0.0, 0.0, 0.0, -xi * ui, -yi * ui]);
|
|
317
|
+
B.push(ui);
|
|
318
|
+
|
|
319
|
+
A.push([0.0, 0.0, 0.0, xi, yi, 1.0, -xi * vi, -yi * vi]);
|
|
320
|
+
B.push(vi);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const n = 8;
|
|
324
|
+
for (let i = 0; i < n; i++) {
|
|
325
|
+
// Find pivot row
|
|
326
|
+
let maxRow = i;
|
|
327
|
+
let maxVal = Math.abs(A[i][i]);
|
|
328
|
+
for (let k = i + 1; k < n; k++) {
|
|
329
|
+
if (Math.abs(A[k][i]) > maxVal) {
|
|
330
|
+
maxVal = Math.abs(A[k][i]);
|
|
331
|
+
maxRow = k;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Swap rows in A and B
|
|
336
|
+
const tempRow = A[i];
|
|
337
|
+
A[i] = A[maxRow];
|
|
338
|
+
A[maxRow] = tempRow;
|
|
339
|
+
|
|
340
|
+
const tempB = B[i];
|
|
341
|
+
B[i] = B[maxRow];
|
|
342
|
+
B[maxRow] = tempB;
|
|
343
|
+
|
|
344
|
+
const pivot = A[i][i];
|
|
345
|
+
if (Math.abs(pivot) < 1e-12) {
|
|
346
|
+
A[i][i] = pivot < 0 ? -1e-12 : 1e-12;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (let k = i + 1; k < n; k++) {
|
|
350
|
+
const factor = A[k][i] / A[i][i];
|
|
351
|
+
for (let j = i; j < n; j++) {
|
|
352
|
+
A[k][j] -= factor * A[i][j];
|
|
353
|
+
}
|
|
354
|
+
B[k] -= factor * B[i];
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Back-substitution
|
|
359
|
+
const h = new Array(8).fill(0.0);
|
|
360
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
361
|
+
let sum = B[i];
|
|
362
|
+
for (let j = i + 1; j < n; j++) {
|
|
363
|
+
sum -= A[i][j] * h[j];
|
|
364
|
+
}
|
|
365
|
+
h[i] = sum / A[i][i];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1.0];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export interface Transform3DPropsLike {
|
|
372
|
+
rotateX?: number;
|
|
373
|
+
rotateY?: number;
|
|
374
|
+
rotateZ?: number;
|
|
375
|
+
translateZ?: number;
|
|
376
|
+
perspective?: number;
|
|
377
|
+
perspectiveOriginX?: number;
|
|
378
|
+
perspectiveOriginY?: number;
|
|
379
|
+
animation?: { tracks?: Array<{ prop: string }> };
|
|
380
|
+
[key: string]: unknown;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Checks whether a layer operation or compiled stub activates the 3D perspective path.
|
|
385
|
+
*/
|
|
386
|
+
export function has3DTransform(
|
|
387
|
+
target?: Transform3DPropsLike,
|
|
388
|
+
lop?: Transform3DPropsLike,
|
|
389
|
+
): boolean {
|
|
390
|
+
const rx = target?.rotateX ?? lop?.rotateX ?? 0;
|
|
391
|
+
const ry = target?.rotateY ?? lop?.rotateY ?? 0;
|
|
392
|
+
const rz = target?.rotateZ ?? lop?.rotateZ ?? 0;
|
|
393
|
+
const tz = target?.translateZ ?? lop?.translateZ ?? 0;
|
|
394
|
+
const p = target?.perspective ?? lop?.perspective ?? 0;
|
|
395
|
+
|
|
396
|
+
if (
|
|
397
|
+
rx !== 0 ||
|
|
398
|
+
ry !== 0 ||
|
|
399
|
+
tz !== 0 ||
|
|
400
|
+
(p > 0 && (rx !== 0 || ry !== 0 || tz !== 0 || rz !== 0))
|
|
401
|
+
) {
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (
|
|
406
|
+
(target?.rotateZ !== undefined && target.rotateZ !== 0) ||
|
|
407
|
+
(lop?.rotateZ !== undefined && lop.rotateZ !== 0)
|
|
408
|
+
) {
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const tracks = lop?.animation?.tracks;
|
|
413
|
+
if (tracks && tracks.length > 0) {
|
|
414
|
+
for (const t of tracks) {
|
|
415
|
+
if (
|
|
416
|
+
t.prop === "rotateX" ||
|
|
417
|
+
t.prop === "rotateY" ||
|
|
418
|
+
t.prop === "rotateZ" ||
|
|
419
|
+
t.prop === "translateZ" ||
|
|
420
|
+
t.prop === "perspective"
|
|
421
|
+
) {
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return false;
|
|
428
|
+
}
|